mpvVideoPlayer.js 24 KB

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