mirror of
https://github.com/jellyfin/jellyfin-web.git
synced 2025-02-21 23:13:10 +00:00
Merge pull request #6196 from viown/prompt-to-skip
Add 'ask to skip' to media segments
This commit is contained in:
commit
fa1934a124
@ -3,5 +3,6 @@
|
||||
*/
|
||||
export enum MediaSegmentAction {
|
||||
None = 'None',
|
||||
AskToSkip = 'AskToSkip',
|
||||
Skip = 'Skip'
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ export enum PlayerEvent {
|
||||
PlaylistItemAdd = 'playlistitemadd',
|
||||
PlaylistItemMove = 'playlistitemmove',
|
||||
PlaylistItemRemove = 'playlistitemremove',
|
||||
PromptSkip = 'promptskip',
|
||||
RepeatModeChange = 'repeatmodechange',
|
||||
ShuffleModeChange = 'shufflequeuemodechange',
|
||||
Stopped = 'stopped',
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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),
|
||||
|
@ -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();
|
||||
|
32
src/components/playback/skipbutton.scss
Normal file
32
src/components/playback/skipbutton.scss
Normal 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;
|
||||
}
|
170
src/components/playback/skipsegment.ts
Normal file
170
src/components/playback/skipsegment.ts
Normal 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);
|
@ -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;
|
||||
|
@ -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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user