Skip to content

Commit

Permalink
Merge pull request #6196 from viown/prompt-to-skip
Browse files Browse the repository at this point in the history
Add 'ask to skip' to media segments
  • Loading branch information
thornbill authored Oct 25, 2024
2 parents 6ae494e + 8cc23f2 commit fa1934a
Show file tree
Hide file tree
Showing 10 changed files with 277 additions and 25 deletions.
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'
}
1 change: 1 addition & 0 deletions src/apps/stable/features/playback/constants/playerEvent.ts
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 @@ -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);
Expand All @@ -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);
}
}

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
3 changes: 3 additions & 0 deletions src/apps/stable/features/playback/utils/playbackSubscriber.ts
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 @@ -3676,6 +3687,9 @@ export class PlaybackManager {
}

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

getCurrentPlayer() {
Expand All @@ -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();
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

0 comments on commit fa1934a

Please sign in to comment.