mpvVideoPlayer.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721
  1. /* eslint-disable indent */
  2. function getMediaStreamAudioTracks(mediaSource) {
  3. return mediaSource.MediaStreams.filter(function (s) {
  4. return s.Type === 'Audio';
  5. });
  6. }
  7. class mpvVideoPlayer {
  8. constructor({ events, loading, appRouter, globalize, appHost, appSettings }) {
  9. this.events = events;
  10. this.loading = loading;
  11. this.appRouter = appRouter;
  12. this.globalize = globalize;
  13. this.appHost = appHost;
  14. this.appSettings = appSettings;
  15. /**
  16. * @type {string}
  17. */
  18. this.name = 'MPV Video Player';
  19. /**
  20. * @type {string}
  21. */
  22. this.type = 'mediaplayer';
  23. /**
  24. * @type {string}
  25. */
  26. this.id = 'mpvvideoplayer';
  27. this.syncPlayWrapAs = 'htmlvideoplayer';
  28. this.priority = -1;
  29. this.useFullSubtitleUrls = true;
  30. /**
  31. * @type {boolean}
  32. */
  33. this.isFetching = false;
  34. /**
  35. * @type {HTMLDivElement | null | undefined}
  36. */
  37. this._videoDialog = undefined;
  38. /**
  39. * @type {number | undefined}
  40. */
  41. this._subtitleTrackIndexToSetOnPlaying = undefined;
  42. /**
  43. * @type {number | null}
  44. */
  45. this._audioTrackIndexToSetOnPlaying = undefined;
  46. /**
  47. * @type {boolean | undefined}
  48. */
  49. this._showTrackOffset = undefined;
  50. /**
  51. * @type {number | undefined}
  52. */
  53. this._currentTrackOffset = undefined;
  54. /**
  55. * @type {string[] | undefined}
  56. */
  57. this._supportedFeatures = undefined;
  58. /**
  59. * @type {string | undefined}
  60. */
  61. this._currentSrc = undefined;
  62. /**
  63. * @type {boolean | undefined}
  64. */
  65. this._started = undefined;
  66. /**
  67. * @type {boolean | undefined}
  68. */
  69. this._timeUpdated = undefined;
  70. /**
  71. * @type {number | null | undefined}
  72. */
  73. this._currentTime = undefined;
  74. /**
  75. * @private (used in other files)
  76. * @type {any | undefined}
  77. */
  78. this._currentPlayOptions = undefined;
  79. /**
  80. * @type {any | undefined}
  81. */
  82. this._lastProfile = undefined;
  83. /**
  84. * @type {number | undefined}
  85. */
  86. this._duration = undefined;
  87. /**
  88. * @type {boolean}
  89. */
  90. this._paused = false;
  91. /**
  92. * @type {int}
  93. */
  94. this._volume = 100;
  95. /**
  96. * @type {boolean}
  97. */
  98. this._muted = false;
  99. /**
  100. * @type {float}
  101. */
  102. this._playRate = 1;
  103. /**
  104. * @private
  105. */
  106. this.onEnded = () => {
  107. this.onEndedInternal();
  108. };
  109. /**
  110. * @private
  111. */
  112. this.onTimeUpdate = (time) => {
  113. if (time && !this._timeUpdated) {
  114. this._timeUpdated = true;
  115. }
  116. this._currentTime = time;
  117. this.events.trigger(this, 'timeupdate');
  118. };
  119. /**
  120. * @private
  121. */
  122. this.onNavigatedToOsd = () => {
  123. const dlg = this._videoDialog;
  124. if (dlg) {
  125. dlg.style.zIndex = 'unset';
  126. }
  127. };
  128. /**
  129. * @private
  130. */
  131. this.onPlaying = () => {
  132. if (!this._started) {
  133. this._started = true;
  134. this.loading.hide();
  135. const volume = this.getSavedVolume() * 100;
  136. if (volume != this._volume) {
  137. this.setVolume(volume, false);
  138. }
  139. this.setPlaybackRate(1);
  140. this.setMute(false);
  141. if (this._currentPlayOptions.fullscreen) {
  142. this.appRouter.showVideoOsd().then(this.onNavigatedToOsd);
  143. } else {
  144. this.appRouter.setTransparency('backdrop');
  145. this._videoDialog.dlg.style.zIndex = 'unset';
  146. }
  147. // Need to override default style.
  148. this._videoDialog.style.setProperty('background', 'transparent', 'important');
  149. }
  150. if (this._paused) {
  151. this._paused = false;
  152. this.events.trigger(this, 'unpause');
  153. }
  154. this.events.trigger(this, 'playing');
  155. };
  156. /**
  157. * @private
  158. */
  159. this.onPause = () => {
  160. this._paused = true;
  161. // For Syncplay ready notification
  162. this.events.trigger(this, 'pause');
  163. };
  164. this.onWaiting = () => {
  165. this.events.trigger(this, 'waiting');
  166. };
  167. /**
  168. * @private
  169. * @param e {Event} The event received from the `<video>` element
  170. */
  171. this.onError = (error) => {
  172. console.error(`media element error: ${error}`);
  173. this.events.trigger(this, 'error', [
  174. {
  175. type: 'mediadecodeerror'
  176. }
  177. ]);
  178. };
  179. this.onDuration = (duration) => {
  180. this._duration = duration;
  181. };
  182. }
  183. currentSrc() {
  184. return this._currentSrc;
  185. }
  186. async play(options) {
  187. this._started = false;
  188. this._timeUpdated = false;
  189. this._currentTime = null;
  190. this.resetSubtitleOffset();
  191. this.loading.show();
  192. window.api.power.setScreensaverEnabled(false);
  193. const elem = await this.createMediaElement(options);
  194. return await this.setCurrentSrc(elem, options);
  195. }
  196. getSavedVolume() {
  197. return this.appSettings.get('volume') || 1;
  198. }
  199. /**
  200. * @private
  201. */
  202. getSubtitleParam() {
  203. const options = this._currentPlayOptions;
  204. if (this._subtitleTrackIndexToSetOnPlaying != null && this._subtitleTrackIndexToSetOnPlaying >= 0) {
  205. const initialSubtitleStream = options.mediaSource.MediaStreams[this._subtitleTrackIndexToSetOnPlaying];
  206. if (!initialSubtitleStream || initialSubtitleStream.DeliveryMethod === 'Encode') {
  207. this._subtitleTrackIndexToSetOnPlaying = -1;
  208. } else if (initialSubtitleStream.DeliveryMethod === 'External') {
  209. return '#,' + initialSubtitleStream.DeliveryUrl;
  210. }
  211. }
  212. if (this._subtitleTrackIndexToSetOnPlaying == -1 || this._subtitleTrackIndexToSetOnPlaying == null) {
  213. return '';
  214. }
  215. return '#' + this._subtitleTrackIndexToSetOnPlaying;
  216. }
  217. tryGetFramerate(options) {
  218. if (options.mediaSource && options.mediaSource.MediaStreams) {
  219. for (let stream of options.mediaSource.MediaStreams) {
  220. if (stream.Type == "Video") {
  221. return stream.RealFrameRate || stream.AverageFrameRate || null;
  222. }
  223. }
  224. }
  225. }
  226. /**
  227. * @private
  228. */
  229. setCurrentSrc(elem, options) {
  230. return new Promise((resolve) => {
  231. const val = options.url;
  232. this._currentSrc = val;
  233. console.debug(`playing url: ${val}`);
  234. // Convert to seconds
  235. const ms = (options.playerStartPositionTicks || 0) / 10000;
  236. this._currentPlayOptions = options;
  237. this._subtitleTrackIndexToSetOnPlaying = options.mediaSource.DefaultSubtitleStreamIndex == null ? -1 : options.mediaSource.DefaultSubtitleStreamIndex;
  238. this._audioTrackIndexToSetOnPlaying = options.playMethod === 'Transcode' ? null : options.mediaSource.DefaultAudioStreamIndex;
  239. const streamdata = {type: 'video', headers: {'User-Agent': 'JellyfinMediaPlayer'}, media: {}};
  240. const fps = this.tryGetFramerate(options);
  241. if (fps) {
  242. streamdata.frameRate = fps;
  243. }
  244. const player = window.api.player;
  245. player.load(val,
  246. { startMilliseconds: ms, autoplay: true },
  247. streamdata,
  248. (this._audioTrackIndexToSetOnPlaying != null)
  249. ? '#' + this._audioTrackIndexToSetOnPlaying : '#1',
  250. this.getSubtitleParam(),
  251. resolve);
  252. });
  253. }
  254. setSubtitleStreamIndex(index) {
  255. this._subtitleTrackIndexToSetOnPlaying = index;
  256. window.api.player.setSubtitleStream(this.getSubtitleParam());
  257. }
  258. resetSubtitleOffset() {
  259. this._currentTrackOffset = 0;
  260. this._showTrackOffset = false;
  261. window.api.player.setSubtitleDelay(0);
  262. }
  263. enableShowingSubtitleOffset() {
  264. this._showTrackOffset = true;
  265. }
  266. disableShowingSubtitleOffset() {
  267. this._showTrackOffset = false;
  268. }
  269. isShowingSubtitleOffsetEnabled() {
  270. return this._showTrackOffset;
  271. }
  272. setSubtitleOffset(offset) {
  273. const offsetValue = parseFloat(offset);
  274. this._currentTrackOffset = offsetValue;
  275. window.api.player.setSubtitleDelay(offset);
  276. }
  277. getSubtitleOffset() {
  278. return this._currentTrackOffset;
  279. }
  280. /**
  281. * @private
  282. */
  283. isAudioStreamSupported() {
  284. return true;
  285. }
  286. /**
  287. * @private
  288. */
  289. getSupportedAudioStreams() {
  290. const profile = this._lastProfile;
  291. return getMediaStreamAudioTracks(this._currentPlayOptions.mediaSource).filter((stream) => {
  292. return this.isAudioStreamSupported(stream, profile);
  293. });
  294. }
  295. setAudioStreamIndex(index) {
  296. this._audioTrackIndexToSetOnPlaying = index;
  297. const streams = this.getSupportedAudioStreams();
  298. if (streams.length < 2) {
  299. // If there's only one supported stream then trust that the player will handle it on it's own
  300. return;
  301. }
  302. window.api.player.setAudioStream(index != -1 ? '#' + index : '');
  303. }
  304. onEndedInternal() {
  305. const stopInfo = {
  306. src: this._currentSrc
  307. };
  308. this.events.trigger(this, 'stopped', [stopInfo]);
  309. this._currentTime = null;
  310. this._currentSrc = null;
  311. this._currentPlayOptions = null;
  312. }
  313. stop(destroyPlayer) {
  314. window.api.player.stop();
  315. window.api.power.setScreensaverEnabled(true);
  316. this.onEndedInternal();
  317. if (destroyPlayer) {
  318. this.destroy();
  319. }
  320. return Promise.resolve();
  321. }
  322. destroy() {
  323. window.api.player.stop();
  324. window.api.power.setScreensaverEnabled(true);
  325. this.appRouter.setTransparency('none');
  326. document.body.classList.remove('hide-scroll');
  327. const player = window.api.player;
  328. player.playing.disconnect(this.onPlaying);
  329. player.positionUpdate.disconnect(this.onTimeUpdate);
  330. player.finished.disconnect(this.onEnded);
  331. this._duration = undefined;
  332. player.updateDuration.disconnect(this.onDuration);
  333. player.error.disconnect(this.onError);
  334. player.paused.disconnect(this.onPause);
  335. const dlg = this._videoDialog;
  336. if (dlg) {
  337. this._videoDialog = null;
  338. dlg.parentNode.removeChild(dlg);
  339. }
  340. // Only supporting QtWebEngine here
  341. if (document.webkitIsFullScreen && document.webkitExitFullscreen) {
  342. document.webkitExitFullscreen();
  343. }
  344. }
  345. /**
  346. * @private
  347. */
  348. createMediaElement(options) {
  349. const dlg = document.querySelector('.videoPlayerContainer');
  350. if (!dlg) {
  351. this.loading.show();
  352. const dlg = document.createElement('div');
  353. dlg.classList.add('videoPlayerContainer');
  354. dlg.style.position = 'fixed';
  355. dlg.style.top = 0;
  356. dlg.style.bottom = 0;
  357. dlg.style.left = 0;
  358. dlg.style.right = 0;
  359. dlg.style.display = 'flex';
  360. dlg.style.alignItems = 'center';
  361. if (options.fullscreen) {
  362. dlg.style.zIndex = 1000;
  363. }
  364. const html = '';
  365. dlg.innerHTML = html;
  366. document.body.insertBefore(dlg, document.body.firstChild);
  367. this._videoDialog = dlg;
  368. const player = window.api.player;
  369. player.playing.connect(this.onPlaying);
  370. player.positionUpdate.connect(this.onTimeUpdate);
  371. player.finished.connect(this.onEnded);
  372. player.updateDuration.connect(this.onDuration);
  373. player.error.connect(this.onError);
  374. player.paused.connect(this.onPause);
  375. if (options.fullscreen) {
  376. // At this point, we must hide the scrollbar placeholder, so it's not being displayed while the item is being loaded
  377. document.body.classList.add('hide-scroll');
  378. }
  379. return Promise.resolve();
  380. } else {
  381. // we need to hide scrollbar when starting playback from page with animated background
  382. if (options.fullscreen) {
  383. document.body.classList.add('hide-scroll');
  384. }
  385. return Promise.resolve();
  386. }
  387. }
  388. /**
  389. * @private
  390. */
  391. canPlayMediaType(mediaType) {
  392. return (mediaType || '').toLowerCase() === 'video';
  393. }
  394. /**
  395. * @private
  396. */
  397. supportsPlayMethod() {
  398. return true;
  399. }
  400. /**
  401. * @private
  402. */
  403. getDeviceProfile(item, options) {
  404. if (this.appHost.getDeviceProfile) {
  405. return this.appHost.getDeviceProfile(item, options);
  406. }
  407. return Promise.resolve({});
  408. }
  409. /**
  410. * @private
  411. */
  412. static getSupportedFeatures() {
  413. return ['PlaybackRate'];
  414. }
  415. supports(feature) {
  416. if (!this._supportedFeatures) {
  417. this._supportedFeatures = mpvVideoPlayer.getSupportedFeatures();
  418. }
  419. return this._supportedFeatures.includes(feature);
  420. }
  421. // Save this for when playback stops, because querying the time at that point might return 0
  422. currentTime(val) {
  423. if (val != null) {
  424. window.api.player.seekTo(val);
  425. return;
  426. }
  427. return this._currentTime;
  428. }
  429. currentTimeAsync() {
  430. return new Promise((resolve) => {
  431. window.api.player.getPosition(resolve);
  432. });
  433. }
  434. duration() {
  435. if (this._duration) {
  436. return this._duration;
  437. }
  438. return null;
  439. }
  440. canSetAudioStreamIndex() {
  441. return true;
  442. }
  443. static onPictureInPictureError(err) {
  444. console.error(`Picture in picture error: ${err}`);
  445. }
  446. setPictureInPictureEnabled() {}
  447. isPictureInPictureEnabled() {
  448. return false;
  449. }
  450. isAirPlayEnabled() {
  451. return false;
  452. }
  453. setAirPlayEnabled() {}
  454. setBrightness() {}
  455. getBrightness() {
  456. return 100;
  457. }
  458. seekable() {
  459. return Boolean(this._duration);
  460. }
  461. pause() {
  462. window.api.player.pause();
  463. }
  464. // This is a retry after error
  465. resume() {
  466. this._paused = false;
  467. window.api.player.play();
  468. }
  469. unpause() {
  470. window.api.player.play();
  471. }
  472. paused() {
  473. return this._paused;
  474. }
  475. setPlaybackRate(value) {
  476. this._playRate = value;
  477. window.api.player.setPlaybackRate(value * 1000);
  478. }
  479. getPlaybackRate() {
  480. return this._playRate;
  481. }
  482. getSupportedPlaybackRates() {
  483. return [{
  484. name: '0.5x',
  485. id: 0.5
  486. }, {
  487. name: '0.75x',
  488. id: 0.75
  489. }, {
  490. name: '1x',
  491. id: 1.0
  492. }, {
  493. name: '1.25x',
  494. id: 1.25
  495. }, {
  496. name: '1.5x',
  497. id: 1.5
  498. }, {
  499. name: '1.75x',
  500. id: 1.75
  501. }, {
  502. name: '2x',
  503. id: 2.0
  504. }];
  505. }
  506. saveVolume(value) {
  507. if (value) {
  508. this.appSettings.set('volume', value);
  509. }
  510. }
  511. setVolume(val, save = true) {
  512. this._volume = val;
  513. if (save) {
  514. this.saveVolume((val || 100) / 100);
  515. this.events.trigger(this, 'volumechange');
  516. }
  517. window.api.player.setVolume(val);
  518. }
  519. getVolume() {
  520. return this._volume;
  521. }
  522. volumeUp() {
  523. this.setVolume(Math.min(this.getVolume() + 2, 100));
  524. }
  525. volumeDown() {
  526. this.setVolume(Math.max(this.getVolume() - 2, 0));
  527. }
  528. setMute(mute) {
  529. this._muted = mute;
  530. window.api.player.setMuted(mute);
  531. }
  532. isMuted() {
  533. return this._muted;
  534. }
  535. setAspectRatio() {
  536. }
  537. getAspectRatio() {
  538. return this._currentAspectRatio || 'auto';
  539. }
  540. getSupportedAspectRatios() {
  541. return [{
  542. name: this.globalize.translate('Auto'),
  543. id: 'auto'
  544. }];
  545. }
  546. togglePictureInPicture() {
  547. }
  548. toggleAirPlay() {
  549. }
  550. getBufferedRanges() {
  551. return [];
  552. }
  553. getStats() {
  554. const playOptions = this._currentPlayOptions || [];
  555. const categories = [];
  556. if (!this._currentPlayOptions) {
  557. return Promise.resolve({
  558. categories: categories
  559. });
  560. }
  561. const mediaCategory = {
  562. stats: [],
  563. type: 'media'
  564. };
  565. categories.push(mediaCategory);
  566. if (playOptions.url) {
  567. // create an anchor element (note: no need to append this element to the document)
  568. let link = document.createElement('a');
  569. // set href to any path
  570. link.setAttribute('href', playOptions.url);
  571. const protocol = (link.protocol || '').replace(':', '');
  572. if (protocol) {
  573. mediaCategory.stats.push({
  574. label: this.globalize.translate('LabelProtocol'),
  575. value: protocol
  576. });
  577. }
  578. link = null;
  579. }
  580. mediaCategory.stats.push({
  581. label: this.globalize.translate('LabelStreamType'),
  582. value: 'Video'
  583. });
  584. const videoCategory = {
  585. stats: [],
  586. type: 'video'
  587. };
  588. categories.push(videoCategory);
  589. const audioCategory = {
  590. stats: [],
  591. type: 'audio'
  592. };
  593. categories.push(audioCategory);
  594. return Promise.resolve({
  595. categories: categories
  596. });
  597. }
  598. }
  599. /* eslint-enable indent */
  600. window._mpvVideoPlayer = mpvVideoPlayer;