mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Add 'prompt to skip' to media segments
This commit is contained in:
parent
effa74356b
commit
0fb38c6894
7 changed files with 216 additions and 1 deletions
|
@ -3,5 +3,6 @@
|
||||||
*/
|
*/
|
||||||
export enum MediaSegmentAction {
|
export enum MediaSegmentAction {
|
||||||
None = 'None',
|
None = 'None',
|
||||||
|
PromptToSkip = 'PromptToSkip',
|
||||||
Skip = 'Skip'
|
Skip = 'Skip'
|
||||||
}
|
}
|
||||||
|
|
|
@ -66,6 +66,8 @@ class MediaSegmentManager extends PlaybackSubscriber {
|
||||||
console.debug('[MediaSegmentManager] skipping to next item in queue');
|
console.debug('[MediaSegmentManager] skipping to next item in queue');
|
||||||
this.playbackManager.nextTrack(this.player);
|
this.playbackManager.nextTrack(this.player);
|
||||||
}
|
}
|
||||||
|
} else if (action === MediaSegmentAction.PromptToSkip) {
|
||||||
|
this.playbackManager.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'
|
typeof segment.StartTicks !== 'undefined'
|
||||||
&& segment.StartTicks <= time
|
&& segment.StartTicks <= time
|
||||||
&& (typeof segment.EndTicks === 'undefined' || segment.EndTicks > time)
|
&& (typeof segment.EndTicks === 'undefined' || segment.EndTicks > time)
|
||||||
|
|
|
@ -26,6 +26,8 @@ import { MediaError } from 'types/mediaError';
|
||||||
import { getMediaError } from 'utils/mediaError';
|
import { getMediaError } from 'utils/mediaError';
|
||||||
import { toApi } from 'utils/jellyfin-apiclient/compat';
|
import { toApi } from 'utils/jellyfin-apiclient/compat';
|
||||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind.js';
|
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;
|
const UNLIMITED_ITEMS = -1;
|
||||||
|
|
||||||
|
@ -933,6 +935,12 @@ export class PlaybackManager {
|
||||||
return Promise.resolve(self._playQueueManager.getPlaylist());
|
return Promise.resolve(self._playQueueManager.getPlaylist());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
self.promptToSkip = function (mediaSegment) {
|
||||||
|
if (mediaSegment && this._skipSegment) {
|
||||||
|
this._skipSegment.onPromptSkip(mediaSegment);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
function removeCurrentPlayer(player) {
|
function removeCurrentPlayer(player) {
|
||||||
const previousPlayer = self._currentPlayer;
|
const previousPlayer = self._currentPlayer;
|
||||||
|
|
||||||
|
@ -3666,6 +3674,9 @@ export class PlaybackManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
bindMediaSegmentManager(self);
|
bindMediaSegmentManager(self);
|
||||||
|
if (!browser.tv && !browser.xboxOne && !browser.ps4) {
|
||||||
|
this._skipSegment = bindSkipSegment(self);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentPlayer() {
|
getCurrentPlayer() {
|
||||||
|
@ -3680,6 +3691,10 @@ export class PlaybackManager {
|
||||||
return this.getCurrentTicks(player) / 10000;
|
return this.getCurrentTicks(player) / 10000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getNextItem() {
|
||||||
|
return this._playQueueManager.getNextItemInfo();
|
||||||
|
}
|
||||||
|
|
||||||
nextItem(player = this._currentPlayer) {
|
nextItem(player = this._currentPlayer) {
|
||||||
if (player && !enableLocalPlaylistManagement(player)) {
|
if (player && !enableLocalPlaylistManagement(player)) {
|
||||||
return player.nextItem();
|
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;
|
||||||
|
}
|
162
src/components/playback/skipsegment.ts
Normal file
162
src/components/playback/skipsegment.ts
Normal file
|
@ -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<typeof setTimeout> | 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 += '<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), 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);
|
|
@ -1072,7 +1072,10 @@
|
||||||
"MediaInfoVideoRange": "Video range",
|
"MediaInfoVideoRange": "Video range",
|
||||||
"MediaIsBeingConverted": "The media is being converted into a format that is compatible with the device that is playing the media.",
|
"MediaIsBeingConverted": "The media is being converted into a format that is compatible with the device that is playing the media.",
|
||||||
"MediaSegmentAction.None": "None",
|
"MediaSegmentAction.None": "None",
|
||||||
|
"MediaSegmentAction.PromptToSkip": "Prompt To Skip",
|
||||||
"MediaSegmentAction.Skip": "Skip",
|
"MediaSegmentAction.Skip": "Skip",
|
||||||
|
"MediaSegmentNextEpisode": "Next Episode",
|
||||||
|
"MediaSegmentSkipPrompt": "Skip {0}",
|
||||||
"MediaSegmentType.Commercial": "Commercial",
|
"MediaSegmentType.Commercial": "Commercial",
|
||||||
"MediaSegmentType.Intro": "Intro",
|
"MediaSegmentType.Intro": "Intro",
|
||||||
"MediaSegmentType.Outro": "Outro",
|
"MediaSegmentType.Outro": "Outro",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue