diff --git a/src/apps/stable/features/playback/constants/mediaSegmentAction.ts b/src/apps/stable/features/playback/constants/mediaSegmentAction.ts index c1eb9652f0d..86a6a759f45 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', + AskToSkip = 'AskToSkip', Skip = 'Skip' } diff --git a/src/apps/stable/features/playback/constants/playerEvent.ts b/src/apps/stable/features/playback/constants/playerEvent.ts index c2364753831..ad9d558a139 100644 --- a/src/apps/stable/features/playback/constants/playerEvent.ts +++ b/src/apps/stable/features/playback/constants/playerEvent.ts @@ -14,6 +14,7 @@ export enum PlayerEvent { PlaylistItemAdd = 'playlistitemadd', PlaylistItemMove = 'playlistitemmove', PlaylistItemRemove = 'playlistitemremove', + PromptSkip = 'promptskip', RepeatModeChange = 'repeatmodechange', ShuffleModeChange = 'shufflequeuemodechange', Stopped = 'stopped', diff --git a/src/apps/stable/features/playback/utils/mediaSegmentManager.ts b/src/apps/stable/features/playback/utils/mediaSegmentManager.ts index 7957ff23342..6a79838aa8c 100644 --- a/src/apps/stable/features/playback/utils/mediaSegmentManager.ts +++ b/src/apps/stable/features/playback/utils/mediaSegmentManager.ts @@ -37,6 +37,38 @@ class MediaSegmentManager extends PlaybackSubscriber { } } + skipSegment(mediaSegment: MediaSegmentDto) { + // Ignore segment if playback progress has passed the segment's start time + if (mediaSegment.StartTicks !== undefined && this.lastTime > mediaSegment.StartTicks) { + console.info('[MediaSegmentManager] ignoring skipping segment that has been seeked back into', mediaSegment); + this.isLastSegmentIgnored = true; + } else if (mediaSegment.EndTicks) { + // If there is an end time, seek to it + // Do not skip if duration < 1s to avoid slow stream changes + if (mediaSegment.StartTicks && mediaSegment.EndTicks - mediaSegment.StartTicks < TICKS_PER_SECOND) { + console.info('[MediaSegmentManager] ignoring skipping segment with duration <1s', mediaSegment); + this.isLastSegmentIgnored = true; + return; + } + console.debug('[MediaSegmentManager] skipping to %s ms', mediaSegment.EndTicks / TICKS_PER_MILLISECOND); + this.playbackManager.seek(mediaSegment.EndTicks, this.player); + } else { + // If there is no end time, skip to the next track + console.debug('[MediaSegmentManager] skipping to next item in queue'); + this.playbackManager.nextTrack(this.player); + } + } + + promptToSkip(mediaSegment: MediaSegmentDto) { + if (mediaSegment.StartTicks && mediaSegment.EndTicks + && mediaSegment.EndTicks - mediaSegment.StartTicks < TICKS_PER_SECOND * 3) { + console.info('[MediaSegmentManager] ignoring segment prompt with duration <3s', mediaSegment); + this.isLastSegmentIgnored = true; + return; + } + this.playbackManager.promptToSkip(mediaSegment); + } + private performAction(mediaSegment: MediaSegmentDto) { if (!this.mediaSegmentTypeActions || !mediaSegment.Type || !this.mediaSegmentTypeActions[mediaSegment.Type]) { console.error('[MediaSegmentManager] segment type missing from action map', mediaSegment, this.mediaSegmentTypeActions); @@ -45,27 +77,9 @@ class MediaSegmentManager extends PlaybackSubscriber { const action = this.mediaSegmentTypeActions[mediaSegment.Type]; if (action === MediaSegmentAction.Skip) { - // Ignore segment if playback progress has passed the segment's start time - if (mediaSegment.StartTicks !== undefined && this.lastTime > mediaSegment.StartTicks) { - console.info('[MediaSegmentManager] ignoring skipping segment that has been seeked back into', mediaSegment); - this.isLastSegmentIgnored = true; - return; - } else if (mediaSegment.EndTicks) { - // If there is an end time, seek to it - // Do not skip if duration < 1s to avoid slow stream changes - if (mediaSegment.StartTicks && mediaSegment.EndTicks - mediaSegment.StartTicks < TICKS_PER_SECOND) { - console.info('[MediaSegmentManager] ignoring skipping segment with duration <1s', mediaSegment); - this.isLastSegmentIgnored = true; - return; - } - - console.debug('[MediaSegmentManager] skipping to %s ms', mediaSegment.EndTicks / TICKS_PER_MILLISECOND); - this.playbackManager.seek(mediaSegment.EndTicks, this.player); - } else { - // If there is no end time, skip to the next track - console.debug('[MediaSegmentManager] skipping to next item in queue'); - this.playbackManager.nextTrack(this.player); - } + this.skipSegment(mediaSegment); + } else if (action === MediaSegmentAction.AskToSkip) { + this.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/apps/stable/features/playback/utils/playbackSubscriber.ts b/src/apps/stable/features/playback/utils/playbackSubscriber.ts index d492469f715..830d3bc8ee1 100644 --- a/src/apps/stable/features/playback/utils/playbackSubscriber.ts +++ b/src/apps/stable/features/playback/utils/playbackSubscriber.ts @@ -11,6 +11,7 @@ import Events, { type Event } from 'utils/events'; import { PlaybackManagerEvent } from '../constants/playbackManagerEvent'; import { PlayerEvent } from '../constants/playerEvent'; import type { ManagedPlayerStopInfo, MovedItem, PlayerError, PlayerErrorCode, PlayerStopInfo, RemovedItems } from '../types/callbacks'; +import type { MediaSegmentDto } from '@jellyfin/sdk/lib/generated-client/models/media-segment-dto'; export interface PlaybackSubscriber { onPlaybackCancelled?(e: Event): void @@ -18,6 +19,7 @@ export interface PlaybackSubscriber { onPlaybackStart?(e: Event, player: Plugin, state: PlayerState): void onPlaybackStop?(e: Event, info: PlaybackStopInfo): void onPlayerChange?(e: Event, player: Plugin, target: PlayTarget, previousPlayer: Plugin): void + onPromptSkip?(e: Event, mediaSegment: MediaSegmentDto): void onPlayerError?(e: Event, error: PlayerError): void onPlayerFullscreenChange?(e: Event): void onPlayerItemStarted?(e: Event, item?: BaseItemDto, mediaSource?: MediaSourceInfo): void @@ -62,6 +64,7 @@ export abstract class PlaybackSubscriber { [PlayerEvent.PlaylistItemAdd]: this.onPlayerPlaylistItemAdd?.bind(this), [PlayerEvent.PlaylistItemMove]: this.onPlayerPlaylistItemMove?.bind(this), [PlayerEvent.PlaylistItemRemove]: this.onPlayerPlaylistItemRemove?.bind(this), + [PlayerEvent.PromptSkip]: this.onPromptSkip?.bind(this), [PlayerEvent.RepeatModeChange]: this.onPlayerRepeatModeChange?.bind(this), [PlayerEvent.ShuffleModeChange]: this.onPlayerShuffleModeChange?.bind(this), [PlayerEvent.Stopped]: this.onPlayerStopped?.bind(this), diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js index d3d74bdfc2d..25b3e32a73c 100644 --- a/src/components/playback/playbackmanager.js +++ b/src/components/playback/playbackmanager.js @@ -22,10 +22,13 @@ import { getItems } from '../../utils/jellyfin-apiclient/getItems.ts'; import { getItemBackdropImageUrl } from '../../utils/jellyfin-apiclient/backdropImage'; import { bindMediaSegmentManager } from 'apps/stable/features/playback/utils/mediaSegmentManager'; +import { PlayerEvent } from 'apps/stable/features/playback/constants/playerEvent'; 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 +936,14 @@ export class PlaybackManager { return Promise.resolve(self._playQueueManager.getPlaylist()); }; + self.promptToSkip = function (mediaSegment, player) { + player = player || self._currentPlayer; + + if (mediaSegment && this._skipSegment) { + Events.trigger(player, PlayerEvent.PromptSkip, [mediaSegment]); + } + }; + function removeCurrentPlayer(player) { const previousPlayer = self._currentPlayer; @@ -3668,6 +3679,9 @@ export class PlaybackManager { } bindMediaSegmentManager(self); + if (!browser.tv && !browser.xboxOne && !browser.ps4) { + this._skipSegment = bindSkipSegment(self); + } } getCurrentPlayer() { @@ -3682,6 +3696,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..458297d6823 --- /dev/null +++ b/src/components/playback/skipsegment.ts @@ -0,0 +1,170 @@ +import { PlaybackManager } from './playbackmanager'; +import { TICKS_PER_MILLISECOND, TICKS_PER_SECOND } from 'constants/time'; +import type { MediaSegmentDto } from '@jellyfin/sdk/lib/generated-client/models/media-segment-dto'; +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); + } + + 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', () => { + const time = this.playbackManager.currentTime() * TICKS_PER_MILLISECOND; + if (this.currentSegment?.EndTicks) { + if (time < this.currentSegment.EndTicks - TICKS_PER_SECOND) { + this.playbackManager.seek(this.currentSegment.EndTicks); + } else { + this.hideSkipButton(); + } + } + }); + + document.body.appendChild(elem); + this.skipElement = elem; + } + } + + setButtonText() { + if (this.skipElement && this.currentSegment) { + 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), 8000); + } + }); + } + } + + 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; + } + } + + onOsdChanged(_e: Event, isOpen: boolean) { + if (this.currentSegment) { + if (isOpen) { + this.showSkipButton({ + animate: false, + keep: true + }); + } else if (!this.hideTimeout) { + this.hideSkipButton(); + } + } + } + + onPromptSkip(e: Event, segment: MediaSegmentDto) { + if (this.player && segment.EndTicks != null + && segment.EndTicks >= this.playbackManager.currentItem(this.player).RunTimeTicks + && this.playbackManager.getNextItem() + ) { + // Don't display button when UpNextDialog is expected. + return; + } + 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(); + } + } + } + + onPlayerChange(): void { + if (this.playbackManager.getCurrentPlayer()) { + Events.off(document, EventType.SHOW_VIDEO_OSD, this.onOsdChanged); + Events.on(document, EventType.SHOW_VIDEO_OSD, this.onOsdChanged); + } + } + + 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/controllers/playback/video/index.js b/src/controllers/playback/video/index.js index a662e905a09..df0dde9bb8b 100644 --- a/src/controllers/playback/video/index.js +++ b/src/controllers/playback/video/index.js @@ -29,9 +29,8 @@ import { setBackdropTransparency, TRANSPARENCY_LEVEL } from '../../../components import { pluginManager } from '../../../components/pluginManager'; import { PluginType } from '../../../types/plugin.ts'; import { EventType } from 'types/eventType'; - -const TICKS_PER_MINUTE = 600000000; -const TICKS_PER_SECOND = 10000000; +import { TICKS_PER_MINUTE, TICKS_PER_SECOND } from 'constants/time'; +import { PlayerEvent } from 'apps/stable/features/playback/constants/playerEvent'; function getOpenedDialog() { return document.querySelector('.dialogContainer .dialog.opened'); @@ -579,6 +578,7 @@ export default function (view) { }, state); Events.on(player, 'playbackstart', onPlaybackStart); Events.on(player, 'playbackstop', onPlaybackStopped); + Events.on(player, PlayerEvent.PromptSkip, onPromptSkip); Events.on(player, 'volumechange', onVolumeChanged); Events.on(player, 'pause', onPlayPauseStateChanged); Events.on(player, 'unpause', onPlayPauseStateChanged); @@ -603,6 +603,7 @@ export default function (view) { if (player) { Events.off(player, 'playbackstart', onPlaybackStart); Events.off(player, 'playbackstop', onPlaybackStopped); + Events.off(player, PlayerEvent.PromptSkip, onPromptSkip); Events.off(player, 'volumechange', onVolumeChanged); Events.off(player, 'pause', onPlayPauseStateChanged); Events.off(player, 'unpause', onPlayPauseStateChanged); @@ -631,6 +632,16 @@ export default function (view) { } } + function onPromptSkip(e, mediaSegment) { + const player = this; + if (mediaSegment && player && mediaSegment.EndTicks != null + && mediaSegment.EndTicks >= playbackManager.duration(player) + && playbackManager.getNextItem() + ) { + showComingUpNext(player); + } + } + function showComingUpNextIfNeeded(player, currentItem, currentTimeTicks, runtimeTicks) { if (runtimeTicks && currentTimeTicks && !comingUpNextDisplayed && !currentVisibleMenu && currentItem.Type === 'Episode' && userSettings.enableNextVideoInfoOverlay()) { let showAtSecondsLeft = 30; diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 96c619a77f2..096b71c8fc1 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -1072,7 +1072,9 @@ "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.AskToSkip": "Ask To Skip", "MediaSegmentAction.Skip": "Skip", + "MediaSegmentSkipPrompt": "Skip {0}", "MediaSegmentType.Commercial": "Commercial", "MediaSegmentType.Intro": "Intro", "MediaSegmentType.Outro": "Outro",