diff --git a/src/apps/stable/features/playback/constants/mediaSegmentAction.ts b/src/apps/stable/features/playback/constants/mediaSegmentAction.ts index c1eb9652f0d..87b54f4bcee 100644 --- a/src/apps/stable/features/playback/constants/mediaSegmentAction.ts +++ b/src/apps/stable/features/playback/constants/mediaSegmentAction.ts @@ -3,5 +3,6 @@ */ export enum MediaSegmentAction { None = 'None', + PromptToSkip = 'PromptToSkip', Skip = 'Skip' } diff --git a/src/apps/stable/features/playback/utils/mediaSegmentManager.ts b/src/apps/stable/features/playback/utils/mediaSegmentManager.ts index 7957ff23342..9618652397c 100644 --- a/src/apps/stable/features/playback/utils/mediaSegmentManager.ts +++ b/src/apps/stable/features/playback/utils/mediaSegmentManager.ts @@ -66,6 +66,8 @@ class MediaSegmentManager extends PlaybackSubscriber { console.debug('[MediaSegmentManager] skipping to next item in queue'); this.playbackManager.nextTrack(this.player); } + } else if (action === MediaSegmentAction.PromptToSkip) { + this.playbackManager.promptToSkip(mediaSegment); } } diff --git a/src/apps/stable/features/playback/utils/mediaSegments.ts b/src/apps/stable/features/playback/utils/mediaSegments.ts index 9ea8e1bdf71..4a782453f18 100644 --- a/src/apps/stable/features/playback/utils/mediaSegments.ts +++ b/src/apps/stable/features/playback/utils/mediaSegments.ts @@ -13,7 +13,7 @@ const isBeforeSegment = (segment: MediaSegmentDto, time: number, direction: numb ); }; -const isInSegment = (segment: MediaSegmentDto, time: number) => ( +export const isInSegment = (segment: MediaSegmentDto, time: number) => ( typeof segment.StartTicks !== 'undefined' && segment.StartTicks <= time && (typeof segment.EndTicks === 'undefined' || segment.EndTicks > time) diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js index bddc34930fa..a7a5ca4bb9d 100644 --- a/src/components/playback/playbackmanager.js +++ b/src/components/playback/playbackmanager.js @@ -26,6 +26,8 @@ import { MediaError } from 'types/mediaError'; import { getMediaError } from 'utils/mediaError'; import { toApi } from 'utils/jellyfin-apiclient/compat'; import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind.js'; +import browser from 'scripts/browser.js'; +import { bindSkipSegment } from './skipsegment.ts'; const UNLIMITED_ITEMS = -1; @@ -933,6 +935,12 @@ export class PlaybackManager { return Promise.resolve(self._playQueueManager.getPlaylist()); }; + self.promptToSkip = function (mediaSegment) { + if (mediaSegment && this._skipSegment) { + this._skipSegment.onPromptSkip(mediaSegment); + } + }; + function removeCurrentPlayer(player) { const previousPlayer = self._currentPlayer; @@ -3666,6 +3674,9 @@ export class PlaybackManager { } bindMediaSegmentManager(self); + if (!browser.tv && !browser.xboxOne && !browser.ps4) { + this._skipSegment = bindSkipSegment(self); + } } getCurrentPlayer() { @@ -3680,6 +3691,10 @@ export class PlaybackManager { return this.getCurrentTicks(player) / 10000; } + getNextItem() { + return this._playQueueManager.getNextItemInfo(); + } + nextItem(player = this._currentPlayer) { if (player && !enableLocalPlaylistManagement(player)) { return player.nextItem(); diff --git a/src/components/playback/skipbutton.scss b/src/components/playback/skipbutton.scss new file mode 100644 index 00000000000..e3a9e0dc0b8 --- /dev/null +++ b/src/components/playback/skipbutton.scss @@ -0,0 +1,32 @@ +.skip-button { + display: flex; + align-items: center; + position: fixed; + bottom: 18%; + right: 16%; + z-index: 10000; + padding: 12px 20px; + color: black; + border: none; + border-radius: 100px; + font-weight: bold; + font-size: 1.2em; + transition: opacity 200ms ease-out; + gap: 3px; + box-shadow: 7px 6px 15px -14px rgba(0, 0, 0, 0.65); + cursor: pointer; +} + +@media (orientation: landscape) and (max-height: 500px) { + .skip-button { + bottom: 27%; + } +} + +.no-transition { + transition: none; +} + +.skip-button-hidden { + opacity: 0; +} diff --git a/src/components/playback/skipsegment.ts b/src/components/playback/skipsegment.ts new file mode 100644 index 00000000000..d4210350a43 --- /dev/null +++ b/src/components/playback/skipsegment.ts @@ -0,0 +1,162 @@ +import { PlaybackManager } from './playbackmanager'; +import { TICKS_PER_MILLISECOND } from 'constants/time'; +import { MediaSegmentDto, MediaSegmentType } from '@jellyfin/sdk/lib/generated-client'; +import { PlaybackSubscriber } from 'apps/stable/features/playback/utils/playbackSubscriber'; +import { isInSegment } from 'apps/stable/features/playback/utils/mediaSegments'; +import Events, { type Event } from '../../utils/events'; +import { EventType } from 'types/eventType'; +import './skipbutton.scss'; +import dom from 'scripts/dom'; +import globalize from 'lib/globalize'; + +interface ShowOptions { + animate?: boolean; + keep?: boolean; +} + +class SkipSegment extends PlaybackSubscriber { + private skipElement: HTMLButtonElement | undefined; + private currentSegment: MediaSegmentDto | null | undefined; + private hideTimeout: ReturnType | null | undefined; + + constructor(playbackManager: PlaybackManager) { + super(playbackManager); + + this.onOsdChanged = this.onOsdChanged.bind(this); + + Events.on(document, EventType.SHOW_VIDEO_OSD, this.onOsdChanged); + } + + onOsdChanged(_e: Event, isOpen: boolean) { + if (this.currentSegment) { + if (isOpen) { + this.showSkipButton({ + animate: false, + keep: true + }); + } else if (!this.hideTimeout) { + this.hideSkipButton(); + } + } + } + + onHideComplete() { + if (this.skipElement) { + this.skipElement.classList.add('hide'); + } + } + + createSkipElement() { + if (!this.skipElement && this.currentSegment) { + const elem = document.createElement('button'); + elem.classList.add('skip-button'); + elem.classList.add('hide'); + elem.classList.add('skip-button-hidden'); + + elem.addEventListener('click', () => { + if (this.currentSegment) { + this.playbackManager.seek(this.currentSegment.EndTicks); + } + }); + + document.body.appendChild(elem); + this.skipElement = elem; + } + } + + setButtonText() { + if (this.skipElement && this.currentSegment) { + if (this.player && this.currentSegment.EndTicks + && this.currentSegment.Type === MediaSegmentType.Outro + && this.currentSegment.EndTicks >= this.playbackManager.currentItem(this.player).RunTimeTicks + && this.playbackManager.getNextItem() + ) { + // Display "Next Episode" if it's an outro segment, exceeds or is equal to the runtime, and if there is a next track. + this.skipElement.innerHTML += globalize.translate('MediaSegmentNextEpisode'); + } else { + this.skipElement.innerHTML = globalize.translate('MediaSegmentSkipPrompt', globalize.translate(`MediaSegmentType.${this.currentSegment.Type}`)); + } + this.skipElement.innerHTML += ''; + } + } + + showSkipButton(options: ShowOptions) { + const elem = this.skipElement; + if (elem) { + this.clearHideTimeout(); + dom.removeEventListener(elem, dom.whichTransitionEvent(), this.onHideComplete, { + once: true + }); + elem.classList.remove('hide'); + if (!options.animate) { + elem.classList.add('no-transition'); + } else { + elem.classList.remove('no-transition'); + } + + void elem.offsetWidth; + + requestAnimationFrame(() => { + elem.classList.remove('skip-button-hidden'); + + if (!options.keep) { + this.hideTimeout = setTimeout(this.hideSkipButton.bind(this), 6000); + } + }); + } + } + + hideSkipButton() { + const elem = this.skipElement; + if (elem) { + elem.classList.remove('no-transition'); + void elem.offsetWidth; + + requestAnimationFrame(() => { + elem.classList.add('skip-button-hidden'); + + dom.addEventListener(elem, dom.whichTransitionEvent(), this.onHideComplete, { + once: true + }); + }); + } + } + + clearHideTimeout() { + if (this.hideTimeout) { + clearTimeout(this.hideTimeout); + this.hideTimeout = null; + } + } + + onPromptSkip(segment: MediaSegmentDto) { + if (!this.currentSegment) { + this.currentSegment = segment; + + this.createSkipElement(); + + this.setButtonText(); + + this.showSkipButton({ animate: true }); + } + } + + onPlayerTimeUpdate() { + if (this.currentSegment) { + const time = this.playbackManager.currentTime(this.player) * TICKS_PER_MILLISECOND; + + if (!isInSegment(this.currentSegment, time)) { + this.currentSegment = null; + this.hideSkipButton(); + } + } + } + + onPlaybackStop() { + this.currentSegment = null; + this.hideSkipButton(); + Events.off(document, EventType.SHOW_VIDEO_OSD, this.onOsdChanged); + } +} + +export const bindSkipSegment = (playbackManager: PlaybackManager) => new SkipSegment(playbackManager); diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 1adc9794c87..7ec6d03886e 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -1072,7 +1072,10 @@ "MediaInfoVideoRange": "Video range", "MediaIsBeingConverted": "The media is being converted into a format that is compatible with the device that is playing the media.", "MediaSegmentAction.None": "None", + "MediaSegmentAction.PromptToSkip": "Prompt To Skip", "MediaSegmentAction.Skip": "Skip", + "MediaSegmentNextEpisode": "Next Episode", + "MediaSegmentSkipPrompt": "Skip {0}", "MediaSegmentType.Commercial": "Commercial", "MediaSegmentType.Intro": "Intro", "MediaSegmentType.Outro": "Outro",