From 9a4420b6239d321728be0311c21d2ef3cbc13cc9 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Wed, 2 Oct 2024 17:09:59 -0400 Subject: [PATCH] Add media segment manager --- .../playback/utils/mediaSegmentManager.ts | 153 ++++++++++++++++++ .../playback/utils/mediaSegmentSettings.ts | 14 ++ .../playback/utils/playbackSubscriber.ts | 6 +- src/components/playback/playbackmanager.js | 5 +- src/scripts/settings/userSettings.js | 2 +- 5 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 src/apps/stable/features/playback/utils/mediaSegmentManager.ts create mode 100644 src/apps/stable/features/playback/utils/mediaSegmentSettings.ts diff --git a/src/apps/stable/features/playback/utils/mediaSegmentManager.ts b/src/apps/stable/features/playback/utils/mediaSegmentManager.ts new file mode 100644 index 000000000000..c7bee552a9aa --- /dev/null +++ b/src/apps/stable/features/playback/utils/mediaSegmentManager.ts @@ -0,0 +1,153 @@ +import type { Api } from '@jellyfin/sdk/lib/api'; +import type { MediaSegmentDto } from '@jellyfin/sdk/lib/generated-client/models/media-segment-dto'; +import { MediaSegmentType } from '@jellyfin/sdk/lib/generated-client/models/media-segment-type'; +import { MediaSegmentsApi } from '@jellyfin/sdk/lib/generated-client/api/media-segments-api'; + +import type { PlaybackManager } from 'components/playback/playbackmanager'; +import ServerConnections from 'components/ServerConnections'; +import { currentSettings as userSettings } from 'scripts/settings/userSettings'; +import type { PlayerState } from 'types/playbackStopInfo'; +import type { Event } from 'utils/events'; +import { toApi } from 'utils/jellyfin-apiclient/compat'; + +import { PlaybackSubscriber } from './playbackSubscriber'; +import { getMediaSegmentAction } from './mediaSegmentSettings'; +import { MediaSegmentAction } from '../constants/mediaSegmentAction'; +import throttle from 'lodash-es/throttle'; + +const isBeforeSegment = (segment: MediaSegmentDto, time: number, direction: number) => { + if (direction === -1) { + return ( + typeof segment.EndTicks !== 'undefined' + && segment.EndTicks < time + ); + } + return ( + typeof segment.StartTicks !== 'undefined' + && segment.StartTicks > time + ); +}; + +const isInSegment = (segment: MediaSegmentDto, time: number) => ( + typeof segment.StartTicks !== 'undefined' + && segment.StartTicks < time + && (typeof segment.EndTicks === 'undefined' || segment.EndTicks > time) +); + +const findCurrentSegment = (segments: MediaSegmentDto[], time: number, lastIndex = 0) => { + const lastSegment = segments[lastIndex]; + if (isInSegment(lastSegment, time)) { + return { index: lastIndex, segment: lastSegment }; + } + + let direction = 1; + if (lastIndex > 0 && lastSegment.StartTicks && lastSegment.StartTicks > time) { + direction = -1; + } + + for (let index = lastIndex, segment = segments[index]; index >= 0 && index < segments.length; index += direction) { + if (isBeforeSegment(segment, time, direction)) return; + if (isInSegment(segment, time)) return { index, segment }; + } +}; + +class MediaSegmentManager extends PlaybackSubscriber { + private hasSegments = false; + private lastIndex = 0; + private mediaSegmentTypeActions: Record, MediaSegmentAction> | undefined; + private mediaSegments: MediaSegmentDto[] = []; + private serverId: string | undefined; + + private handleSegmentActions() { + console.debug('throttled'); + if (this.hasSegments && this.mediaSegments.length) { + const time = this.playbackManager.currentTime(this.player) * 10000; + const currentSegmentDetails = findCurrentSegment(this.mediaSegments, time, this.lastIndex); + if (currentSegmentDetails) { + this.performAction(currentSegmentDetails.segment); + this.lastIndex = currentSegmentDetails.index; + } + } + } + + private debouncedOnTimeUpdate = throttle(this.handleSegmentActions, 500, { leading: true }); + + private async fetchMediaSegments(api: Api, itemId: string, includeSegmentTypes: MediaSegmentType[]) { + // FIXME: Replace with SDK getMediaSegmentsApi function when available in stable + const mediaSegmentsApi = new MediaSegmentsApi(api.configuration, undefined, api.axiosInstance); + + try { + const { data: mediaSegments } = await mediaSegmentsApi.getItemSegments({ itemId, includeSegmentTypes }); + this.mediaSegments = mediaSegments.Items || []; + } catch (err) { + console.error('[MediaSegmentManager] failed to fetch segments', err); + this.mediaSegments = []; + } + } + + 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); + return; + } + + const action = this.mediaSegmentTypeActions[mediaSegment.Type]; + if (action === MediaSegmentAction.Skip) { + // Perform skip + if (mediaSegment.EndTicks) { + this.playbackManager.seek(mediaSegment.EndTicks, this.player); + } else { + this.playbackManager.nextTrack(this.player); + } + } + } + + onPlayerPlaybackStart(_e: Event, state: PlayerState) { + this.lastIndex = 0; + this.hasSegments = !!state.MediaSource?.HasSegments; + // XXX: replace + // this.hasSegments = true; + // TODO: Might not need this at the class level + this.serverId = state.NowPlayingItem?.ServerId || ServerConnections.currentApiClient()?.serverId(); + const itemId = state.MediaSource?.Id; + + if (!this.hasSegments || !this.serverId || !itemId) return; + + // Get the user settings for media segment actions + this.mediaSegmentTypeActions = Object.values(MediaSegmentType) + .map(type => ({ + type, + action: getMediaSegmentAction(userSettings, type) + })) + .filter(({ action }) => !!action && action !== MediaSegmentAction.None) + .reduce((acc, { type, action }) => { + if (action) acc[type] = action; + return acc; + }, {} as Record, MediaSegmentAction>); + + if (!Object.keys(this.mediaSegmentTypeActions).length) { + console.info('[MediaSegmentManager] user has no media segment actions enabled'); + return; + } + + const apiClient = ServerConnections.getApiClient(this.serverId); + + if (!apiClient.isMinServerVersion('10.10.0')) { + console.warn('[MediaSegmentManager] server does not support media segments'); + return; + } + + const api = toApi(apiClient); + void this.fetchMediaSegments( + api, + itemId, + Object.keys(this.mediaSegmentTypeActions).map(t => t as keyof typeof MediaSegmentType)); + } + + onPlayerTimeUpdate() { + console.debug('update'); + this.debouncedOnTimeUpdate(); + } +} + +export const bindMediaSegmentManager = (playbackManager: PlaybackManager) => new MediaSegmentManager(playbackManager); diff --git a/src/apps/stable/features/playback/utils/mediaSegmentSettings.ts b/src/apps/stable/features/playback/utils/mediaSegmentSettings.ts new file mode 100644 index 000000000000..e36532889dad --- /dev/null +++ b/src/apps/stable/features/playback/utils/mediaSegmentSettings.ts @@ -0,0 +1,14 @@ +import { MediaSegmentType } from '@jellyfin/sdk/lib/generated-client/models/media-segment-type'; + +import { UserSettings } from 'scripts/settings/userSettings'; + +import { MediaSegmentAction } from '../constants/mediaSegmentAction'; + +const PREFIX = 'segmentTypeAction__'; + +const getId = (type: MediaSegmentType) => `${PREFIX}${type}`; + +export function getMediaSegmentAction(userSettings: UserSettings, type: MediaSegmentType): MediaSegmentAction | undefined { + const action = userSettings.get(getId(type), false); + return action ? action as MediaSegmentAction : undefined; +} diff --git a/src/apps/stable/features/playback/utils/playbackSubscriber.ts b/src/apps/stable/features/playback/utils/playbackSubscriber.ts index dfc79360a209..96be101f33da 100644 --- a/src/apps/stable/features/playback/utils/playbackSubscriber.ts +++ b/src/apps/stable/features/playback/utils/playbackSubscriber.ts @@ -39,7 +39,7 @@ export interface PlaybackSubscriber { } export abstract class PlaybackSubscriber { - private player: Plugin | undefined; + protected player: Plugin | undefined; private readonly playbackManagerEvents = { [PlaybackManagerEvent.PlaybackCancelled]: this.onPlaybackCancelled, @@ -87,7 +87,7 @@ export abstract class PlaybackSubscriber { if (this.player) { Object.entries(this.playerEvents).forEach(([event, handler]) => { - if (handler) Events.off(this.player, event, handler); + if (handler) Events.off(this.player, event, handler.bind(this)); }); } @@ -95,7 +95,7 @@ export abstract class PlaybackSubscriber { if (!this.player) return; Object.entries(this.playerEvents).forEach(([event, handler]) => { - if (handler) Events.on(this.player, event, handler); + if (handler) Events.on(this.player, event, handler.bind(this)); }); } } diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js index e90aff609fea..d262be19d9a4 100644 --- a/src/components/playback/playbackmanager.js +++ b/src/components/playback/playbackmanager.js @@ -1,4 +1,5 @@ import { PlaybackErrorCode } from '@jellyfin/sdk/lib/generated-client/models/playback-error-code.js'; +import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type'; import merge from 'lodash-es/merge'; import Screenfull from 'screenfull'; @@ -18,8 +19,8 @@ import { PluginType } from '../../types/plugin.ts'; import { includesAny } from '../../utils/container.ts'; import { getItems } from '../../utils/jellyfin-apiclient/getItems.ts'; import { getItemBackdropImageUrl } from '../../utils/jellyfin-apiclient/backdropImage'; -import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type'; +import { bindMediaSegmentManager } from 'apps/stable/features/playback/utils/mediaSegmentManager'; import { MediaError } from 'types/mediaError'; import { getMediaError } from 'utils/mediaError'; @@ -3645,6 +3646,8 @@ export class PlaybackManager { Events.on(serverNotifications, 'ServerRestarting', self.setDefaultPlayerActive.bind(self)); }); } + + bindMediaSegmentManager(self); } getCurrentPlayer() { diff --git a/src/scripts/settings/userSettings.js b/src/scripts/settings/userSettings.js index f667cda465ba..dc47eed3cd26 100644 --- a/src/scripts/settings/userSettings.js +++ b/src/scripts/settings/userSettings.js @@ -91,7 +91,7 @@ export class UserSettings { * Get value of setting. * @param {string} name - Name of setting. * @param {boolean} [enableOnServer] - Flag to return preferences from server (cached). - * @return {string} Value of setting. + * @return {string | null} Value of setting. */ get(name, enableOnServer) { const userId = this.currentUserId;