Merge pull request #6196 from viown/prompt-to-skip

Add 'ask to skip' to media segments
This commit is contained in:
Bill Thornton 2024-10-25 17:18:30 -04:00 committed by GitHub
commit fa1934a124
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 277 additions and 25 deletions

View File

@ -3,5 +3,6 @@
*/
export enum MediaSegmentAction {
None = 'None',
AskToSkip = 'AskToSkip',
Skip = 'Skip'
}

View File

@ -14,6 +14,7 @@ export enum PlayerEvent {
PlaylistItemAdd = 'playlistitemadd',
PlaylistItemMove = 'playlistitemmove',
PlaylistItemRemove = 'playlistitemremove',
PromptSkip = 'promptskip',
RepeatModeChange = 'repeatmodechange',
ShuffleModeChange = 'shufflequeuemodechange',
Stopped = 'stopped',

View File

@ -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);
}
}

View File

@ -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)

View File

@ -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),

View File

@ -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;
@ -3676,6 +3687,9 @@ export class PlaybackManager {
}
bindMediaSegmentManager(self);
if (!browser.tv && !browser.xboxOne && !browser.ps4) {
this._skipSegment = bindSkipSegment(self);
}
}
getCurrentPlayer() {
@ -3690,6 +3704,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();

View File

@ -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;
}

View File

@ -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<typeof setTimeout> | 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 += '<span class="material-icons skip_next" aria-hidden="true"></span>';
}
}
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);

View File

@ -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;

View File

@ -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",