mpvVideoPlayer.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823
  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, confirm }) {
  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. * @type {boolean}
  105. */
  106. this._hasConnection = false;
  107. /**
  108. * @private
  109. */
  110. this.onEnded = () => {
  111. this.onEndedInternal();
  112. };
  113. /**
  114. * @private
  115. */
  116. this.onTimeUpdate = (time) => {
  117. if (time && !this._timeUpdated) {
  118. this._timeUpdated = true;
  119. }
  120. this._currentTime = time;
  121. this.events.trigger(this, 'timeupdate');
  122. };
  123. /**
  124. * @private
  125. */
  126. this.onNavigatedToOsd = () => {
  127. const dlg = this._videoDialog;
  128. if (dlg) {
  129. dlg.style.zIndex = 'unset';
  130. }
  131. };
  132. /**
  133. * @private
  134. */
  135. this.onPlaying = () => {
  136. if (!this._started) {
  137. this._started = true;
  138. this.loading.hide();
  139. const volume = this.getSavedVolume() * 100;
  140. this.setVolume(volume, false);
  141. this.setPlaybackRate(1);
  142. this.setMute(false, false);
  143. if (this._currentPlayOptions.fullscreen) {
  144. this.appRouter.showVideoOsd().then(this.onNavigatedToOsd);
  145. } else {
  146. this.appRouter.setTransparency('backdrop');
  147. this._videoDialog.dlg.style.zIndex = 'unset';
  148. }
  149. // Need to override default style.
  150. this._videoDialog.style.setProperty('background', 'transparent', 'important');
  151. }
  152. if (this._paused) {
  153. this._paused = false;
  154. this.events.trigger(this, 'unpause');
  155. }
  156. this.events.trigger(this, 'playing');
  157. };
  158. /**
  159. * @private
  160. */
  161. this.onPause = () => {
  162. this._paused = true;
  163. // For Syncplay ready notification
  164. this.events.trigger(this, 'pause');
  165. };
  166. this.onWaiting = () => {
  167. this.events.trigger(this, 'waiting');
  168. };
  169. /**
  170. * @private
  171. * @param e {Event} The event received from the `<video>` element
  172. */
  173. this.onError = async (error) => {
  174. this.removeMediaDialog();
  175. console.error(`media error: ${error}`);
  176. const errorData = {
  177. type: 'mediadecodeerror'
  178. };
  179. try {
  180. await confirm({
  181. title: "Playback Failed",
  182. text: `Playback failed with error "${error}". Retry with transcode? (Note this may hang the player.)`,
  183. cancelText: "Cancel",
  184. confirmText: "Retry"
  185. });
  186. } catch (ex) {
  187. // User declined retry
  188. errorData.streamInfo = {
  189. // Prevent jellyfin-web retrying with transcode
  190. // which crashes the player
  191. mediaSource: {
  192. SupportsTranscoding: false
  193. }
  194. };
  195. }
  196. this.events.trigger(this, 'error', [errorData]);
  197. };
  198. this.onDuration = (duration) => {
  199. this._duration = duration;
  200. };
  201. }
  202. currentSrc() {
  203. return this._currentSrc;
  204. }
  205. async play(options) {
  206. this._started = false;
  207. this._timeUpdated = false;
  208. this._currentTime = null;
  209. this.resetSubtitleOffset();
  210. this.loading.show();
  211. window.api.power.setScreensaverEnabled(false);
  212. const elem = await this.createMediaElement(options);
  213. return await this.setCurrentSrc(elem, options);
  214. }
  215. getSavedVolume() {
  216. return this.appSettings.get('volume') || 1;
  217. }
  218. /**
  219. * @private
  220. */
  221. getRelativeIndexByType(mediaStreams, jellyIndex, streamType) {
  222. let relIndex = 1;
  223. for (const source of mediaStreams) {
  224. if (source.Type != streamType || source.IsExternal) {
  225. continue;
  226. }
  227. if (source.Index == jellyIndex) {
  228. return relIndex;
  229. }
  230. relIndex += 1;
  231. }
  232. return null;
  233. }
  234. /**
  235. * @private
  236. */
  237. getStreamByIndex(mediaStreams, jellyIndex) {
  238. for (const source of mediaStreams) {
  239. if (source.Index == jellyIndex) {
  240. return source;
  241. }
  242. }
  243. return null;
  244. }
  245. /**
  246. * @private
  247. */
  248. getSubtitleParam() {
  249. const options = this._currentPlayOptions;
  250. if (this._subtitleTrackIndexToSetOnPlaying != null && this._subtitleTrackIndexToSetOnPlaying >= 0) {
  251. const initialSubtitleStream = this.getStreamByIndex(options.mediaSource.MediaStreams, this._subtitleTrackIndexToSetOnPlaying);
  252. if (!initialSubtitleStream || initialSubtitleStream.DeliveryMethod === 'Encode') {
  253. this._subtitleTrackIndexToSetOnPlaying = -1;
  254. } else if (initialSubtitleStream.DeliveryMethod === 'External') {
  255. return '#,' + initialSubtitleStream.DeliveryUrl;
  256. }
  257. }
  258. if (this._subtitleTrackIndexToSetOnPlaying == -1 || this._subtitleTrackIndexToSetOnPlaying == null) {
  259. return '';
  260. }
  261. const subtitleRelIndex = this.getRelativeIndexByType(
  262. options.mediaSource.MediaStreams,
  263. this._subtitleTrackIndexToSetOnPlaying,
  264. 'Subtitle'
  265. );
  266. return subtitleRelIndex != null
  267. ? '#' + subtitleRelIndex
  268. : '';
  269. }
  270. /**
  271. * @private
  272. */
  273. getAudioParam() {
  274. const options = this._currentPlayOptions;
  275. if (this._audioTrackIndexToSetOnPlaying != null && this._audioTrackIndexToSetOnPlaying >= 0) {
  276. const initialAudioStream = this.getStreamByIndex(options.mediaSource.MediaStreams, this._audioTrackIndexToSetOnPlaying);
  277. if (!initialAudioStream) {
  278. return '#1';
  279. }
  280. }
  281. if (this._audioTrackIndexToSetOnPlaying == -1 || this._audioTrackIndexToSetOnPlaying == null) {
  282. return '#1';
  283. }
  284. const audioRelIndex = this.getRelativeIndexByType(
  285. options.mediaSource.MediaStreams,
  286. this._audioTrackIndexToSetOnPlaying,
  287. 'Audio'
  288. );
  289. return audioRelIndex != null
  290. ? '#' + audioRelIndex
  291. : '#1';
  292. }
  293. tryGetFramerate(options) {
  294. if (options.mediaSource && options.mediaSource.MediaStreams) {
  295. for (let stream of options.mediaSource.MediaStreams) {
  296. if (stream.Type == "Video") {
  297. return stream.RealFrameRate || stream.AverageFrameRate || null;
  298. }
  299. }
  300. }
  301. }
  302. /**
  303. * @private
  304. */
  305. setCurrentSrc(elem, options) {
  306. return new Promise((resolve) => {
  307. const val = options.url;
  308. this._currentSrc = val;
  309. console.debug(`playing url: ${val}`);
  310. // Convert to seconds
  311. const ms = (options.playerStartPositionTicks || 0) / 10000;
  312. this._currentPlayOptions = options;
  313. this._subtitleTrackIndexToSetOnPlaying = options.mediaSource.DefaultSubtitleStreamIndex == null ? -1 : options.mediaSource.DefaultSubtitleStreamIndex;
  314. this._audioTrackIndexToSetOnPlaying = options.playMethod === 'Transcode' ? null : options.mediaSource.DefaultAudioStreamIndex;
  315. const streamdata = {type: 'video', headers: {'User-Agent': 'JellyfinMediaPlayer'}, metadata: options.item, media: {}};
  316. const fps = this.tryGetFramerate(options);
  317. if (fps) {
  318. streamdata.frameRate = fps;
  319. }
  320. const player = window.api.player;
  321. player.load(val,
  322. { startMilliseconds: ms, autoplay: true },
  323. streamdata,
  324. this.getAudioParam(),
  325. this.getSubtitleParam(),
  326. resolve);
  327. });
  328. }
  329. setSubtitleStreamIndex(index) {
  330. this._subtitleTrackIndexToSetOnPlaying = index;
  331. window.api.player.setSubtitleStream(this.getSubtitleParam());
  332. }
  333. resetSubtitleOffset() {
  334. this._currentTrackOffset = 0;
  335. this._showTrackOffset = false;
  336. window.api.player.setSubtitleDelay(0);
  337. }
  338. enableShowingSubtitleOffset() {
  339. this._showTrackOffset = true;
  340. }
  341. disableShowingSubtitleOffset() {
  342. this._showTrackOffset = false;
  343. }
  344. isShowingSubtitleOffsetEnabled() {
  345. return this._showTrackOffset;
  346. }
  347. setSubtitleOffset(offset) {
  348. const offsetValue = parseFloat(offset);
  349. this._currentTrackOffset = offsetValue;
  350. window.api.player.setSubtitleDelay(Math.round(offsetValue * 1000));
  351. }
  352. getSubtitleOffset() {
  353. return this._currentTrackOffset;
  354. }
  355. /**
  356. * @private
  357. */
  358. isAudioStreamSupported() {
  359. return true;
  360. }
  361. /**
  362. * @private
  363. */
  364. getSupportedAudioStreams() {
  365. const profile = this._lastProfile;
  366. return getMediaStreamAudioTracks(this._currentPlayOptions.mediaSource).filter((stream) => {
  367. return this.isAudioStreamSupported(stream, profile);
  368. });
  369. }
  370. setAudioStreamIndex(index) {
  371. this._audioTrackIndexToSetOnPlaying = index;
  372. const streams = this.getSupportedAudioStreams();
  373. if (streams.length < 2) {
  374. // If there's only one supported stream then trust that the player will handle it on it's own
  375. return;
  376. }
  377. window.api.player.setAudioStream(this.getAudioParam());
  378. }
  379. onEndedInternal() {
  380. const stopInfo = {
  381. src: this._currentSrc
  382. };
  383. this.events.trigger(this, 'stopped', [stopInfo]);
  384. this._currentTime = null;
  385. this._currentSrc = null;
  386. this._currentPlayOptions = null;
  387. }
  388. stop(destroyPlayer) {
  389. window.api.player.stop();
  390. window.api.power.setScreensaverEnabled(true);
  391. this.onEndedInternal();
  392. if (destroyPlayer) {
  393. this.destroy();
  394. }
  395. return Promise.resolve();
  396. }
  397. removeMediaDialog() {
  398. this.loading.hide();
  399. window.api.player.stop();
  400. window.api.power.setScreensaverEnabled(true);
  401. this.appRouter.setTransparency('none');
  402. document.body.classList.remove('hide-scroll');
  403. const dlg = this._videoDialog;
  404. if (dlg) {
  405. this._videoDialog = null;
  406. dlg.parentNode.removeChild(dlg);
  407. }
  408. // Only supporting QtWebEngine here
  409. if (document.webkitIsFullScreen && document.webkitExitFullscreen) {
  410. document.webkitExitFullscreen();
  411. }
  412. }
  413. destroy() {
  414. this.removeMediaDialog();
  415. const player = window.api.player;
  416. this._hasConnection = false;
  417. player.playing.disconnect(this.onPlaying);
  418. player.positionUpdate.disconnect(this.onTimeUpdate);
  419. player.finished.disconnect(this.onEnded);
  420. this._duration = undefined;
  421. player.updateDuration.disconnect(this.onDuration);
  422. player.error.disconnect(this.onError);
  423. player.paused.disconnect(this.onPause);
  424. }
  425. /**
  426. * @private
  427. */
  428. createMediaElement(options) {
  429. const dlg = document.querySelector('.videoPlayerContainer');
  430. if (!dlg) {
  431. this.loading.show();
  432. const dlg = document.createElement('div');
  433. dlg.classList.add('videoPlayerContainer');
  434. dlg.style.position = 'fixed';
  435. dlg.style.top = 0;
  436. dlg.style.bottom = 0;
  437. dlg.style.left = 0;
  438. dlg.style.right = 0;
  439. dlg.style.display = 'flex';
  440. dlg.style.alignItems = 'center';
  441. if (options.fullscreen) {
  442. dlg.style.zIndex = 1000;
  443. }
  444. const html = '';
  445. dlg.innerHTML = html;
  446. document.body.insertBefore(dlg, document.body.firstChild);
  447. this._videoDialog = dlg;
  448. const player = window.api.player;
  449. if (!this._hasConnection) {
  450. this._hasConnection = true;
  451. player.playing.connect(this.onPlaying);
  452. player.positionUpdate.connect(this.onTimeUpdate);
  453. player.finished.connect(this.onEnded);
  454. player.updateDuration.connect(this.onDuration);
  455. player.error.connect(this.onError);
  456. player.paused.connect(this.onPause);
  457. }
  458. if (options.fullscreen) {
  459. // At this point, we must hide the scrollbar placeholder, so it's not being displayed while the item is being loaded
  460. document.body.classList.add('hide-scroll');
  461. }
  462. return Promise.resolve();
  463. } else {
  464. // we need to hide scrollbar when starting playback from page with animated background
  465. if (options.fullscreen) {
  466. document.body.classList.add('hide-scroll');
  467. }
  468. return Promise.resolve();
  469. }
  470. }
  471. /**
  472. * @private
  473. */
  474. canPlayMediaType(mediaType) {
  475. return (mediaType || '').toLowerCase() === 'video';
  476. }
  477. /**
  478. * @private
  479. */
  480. supportsPlayMethod() {
  481. return true;
  482. }
  483. /**
  484. * @private
  485. */
  486. getDeviceProfile(item, options) {
  487. if (this.appHost.getDeviceProfile) {
  488. return this.appHost.getDeviceProfile(item, options);
  489. }
  490. return Promise.resolve({});
  491. }
  492. /**
  493. * @private
  494. */
  495. static getSupportedFeatures() {
  496. return ['PlaybackRate'];
  497. }
  498. supports(feature) {
  499. if (!this._supportedFeatures) {
  500. this._supportedFeatures = mpvVideoPlayer.getSupportedFeatures();
  501. }
  502. return this._supportedFeatures.includes(feature);
  503. }
  504. // Save this for when playback stops, because querying the time at that point might return 0
  505. currentTime(val) {
  506. if (val != null) {
  507. window.api.player.seekTo(val);
  508. return;
  509. }
  510. return this._currentTime;
  511. }
  512. currentTimeAsync() {
  513. return new Promise((resolve) => {
  514. window.api.player.getPosition(resolve);
  515. });
  516. }
  517. duration() {
  518. if (this._duration) {
  519. return this._duration;
  520. }
  521. return null;
  522. }
  523. canSetAudioStreamIndex() {
  524. return true;
  525. }
  526. static onPictureInPictureError(err) {
  527. console.error(`Picture in picture error: ${err}`);
  528. }
  529. setPictureInPictureEnabled() {}
  530. isPictureInPictureEnabled() {
  531. return false;
  532. }
  533. isAirPlayEnabled() {
  534. return false;
  535. }
  536. setAirPlayEnabled() {}
  537. setBrightness() {}
  538. getBrightness() {
  539. return 100;
  540. }
  541. seekable() {
  542. return Boolean(this._duration);
  543. }
  544. pause() {
  545. window.api.player.pause();
  546. window.api.power.setScreensaverEnabled(true);
  547. }
  548. // This is a retry after error
  549. resume() {
  550. this._paused = false;
  551. window.api.player.play();
  552. }
  553. unpause() {
  554. window.api.player.play();
  555. window.api.power.setScreensaverEnabled(false);
  556. }
  557. paused() {
  558. return this._paused;
  559. }
  560. setPlaybackRate(value) {
  561. this._playRate = value;
  562. window.api.player.setPlaybackRate(value * 1000);
  563. }
  564. getPlaybackRate() {
  565. return this._playRate;
  566. }
  567. getSupportedPlaybackRates() {
  568. return [{
  569. name: '0.5x',
  570. id: 0.5
  571. }, {
  572. name: '0.75x',
  573. id: 0.75
  574. }, {
  575. name: '1x',
  576. id: 1.0
  577. }, {
  578. name: '1.25x',
  579. id: 1.25
  580. }, {
  581. name: '1.5x',
  582. id: 1.5
  583. }, {
  584. name: '1.75x',
  585. id: 1.75
  586. }, {
  587. name: '2x',
  588. id: 2.0
  589. }];
  590. }
  591. saveVolume(value) {
  592. if (value) {
  593. this.appSettings.set('volume', value);
  594. }
  595. }
  596. setVolume(val, save = true) {
  597. this._volume = val;
  598. if (save) {
  599. this.saveVolume((val || 100) / 100);
  600. this.events.trigger(this, 'volumechange');
  601. }
  602. window.api.player.setVolume(val);
  603. }
  604. getVolume() {
  605. return this._volume;
  606. }
  607. volumeUp() {
  608. this.setVolume(Math.min(this.getVolume() + 2, 100));
  609. }
  610. volumeDown() {
  611. this.setVolume(Math.max(this.getVolume() - 2, 0));
  612. }
  613. setMute(mute, triggerEvent = true) {
  614. this._muted = mute;
  615. window.api.player.setMuted(mute);
  616. if (triggerEvent) {
  617. this.events.trigger(this, 'volumechange');
  618. }
  619. }
  620. isMuted() {
  621. return this._muted;
  622. }
  623. setAspectRatio() {
  624. }
  625. getAspectRatio() {
  626. return this._currentAspectRatio || 'auto';
  627. }
  628. getSupportedAspectRatios() {
  629. return [{
  630. name: this.globalize.translate('Auto'),
  631. id: 'auto'
  632. }];
  633. }
  634. togglePictureInPicture() {
  635. }
  636. toggleAirPlay() {
  637. }
  638. getBufferedRanges() {
  639. return [];
  640. }
  641. getStats() {
  642. const playOptions = this._currentPlayOptions || [];
  643. const categories = [];
  644. if (!this._currentPlayOptions) {
  645. return Promise.resolve({
  646. categories: categories
  647. });
  648. }
  649. const mediaCategory = {
  650. stats: [],
  651. type: 'media'
  652. };
  653. categories.push(mediaCategory);
  654. if (playOptions.url) {
  655. // create an anchor element (note: no need to append this element to the document)
  656. let link = document.createElement('a');
  657. // set href to any path
  658. link.setAttribute('href', playOptions.url);
  659. const protocol = (link.protocol || '').replace(':', '');
  660. if (protocol) {
  661. mediaCategory.stats.push({
  662. label: this.globalize.translate('LabelProtocol'),
  663. value: protocol
  664. });
  665. }
  666. link = null;
  667. }
  668. mediaCategory.stats.push({
  669. label: this.globalize.translate('LabelStreamType'),
  670. value: 'Video'
  671. });
  672. const videoCategory = {
  673. stats: [],
  674. type: 'video'
  675. };
  676. categories.push(videoCategory);
  677. const audioCategory = {
  678. stats: [],
  679. type: 'audio'
  680. };
  681. categories.push(audioCategory);
  682. return Promise.resolve({
  683. categories: categories
  684. });
  685. }
  686. }
  687. /* eslint-enable indent */
  688. window._mpvVideoPlayer = mpvVideoPlayer;