Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add 'ask to skip' to media segments #6196

Merged
merged 12 commits into from
Oct 25, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
*/
export enum MediaSegmentAction {
None = 'None',
AskToSkip = 'AskToSkip',
Skip = 'Skip'
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export enum PlayerEvent {
PlaylistItemAdd = 'playlistitemadd',
PlaylistItemMove = 'playlistitemmove',
PlaylistItemRemove = 'playlistitemremove',
PromptSkip = 'promptskip',
RepeatModeChange = 'repeatmodechange',
ShuffleModeChange = 'shufflequeuemodechange',
Stopped = 'stopped',
Expand Down
56 changes: 35 additions & 21 deletions src/apps/stable/features/playback/utils/mediaSegmentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
private mediaSegments: MediaSegmentDto[] = [];

private async fetchMediaSegments(api: Api, itemId: string, includeSegmentTypes: MediaSegmentType[]) {
// FIXME: Replace with SDK getMediaSegmentsApi function when available in stable

Check warning on line 28 in src/apps/stable/features/playback/utils/mediaSegmentManager.ts

View workflow job for this annotation

GitHub Actions / Quality checks 👌🧪 / Run lint 🕵️‍♂️

Unexpected 'fixme' comment: 'FIXME: Replace with SDK...'
const mediaSegmentsApi = new MediaSegmentsApi(api.configuration, undefined, api.axiosInstance);

try {
Expand All @@ -37,6 +37,38 @@
}
}

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);
Expand All @@ -45,27 +77,9 @@

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

Expand Down
2 changes: 1 addition & 1 deletion src/apps/stable/features/playback/utils/mediaSegments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ 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
onPlaybackError?(e: Event, errorType: MediaError): void
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
Expand Down Expand Up @@ -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),
Expand Down
18 changes: 18 additions & 0 deletions src/components/playback/playbackmanager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -3668,6 +3679,9 @@ export class PlaybackManager {
}

bindMediaSegmentManager(self);
if (!browser.tv && !browser.xboxOne && !browser.ps4) {
this._skipSegment = bindSkipSegment(self);
}
}

getCurrentPlayer() {
Expand All @@ -3682,6 +3696,10 @@ export class PlaybackManager {
return this.getCurrentTicks(player) / 10000;
}

getNextItem() {
return this._playQueueManager.getNextItemInfo();
thornbill marked this conversation as resolved.
Show resolved Hide resolved
}

nextItem(player = this._currentPlayer) {
if (player && !enableLocalPlaylistManagement(player)) {
return player.nextItem();
Expand Down
32 changes: 32 additions & 0 deletions src/components/playback/skipbutton.scss
Original file line number Diff line number Diff line change
@@ -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 changes: 170 additions & 0 deletions src/components/playback/skipsegment.ts
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading