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 0000000000..8d245cdd92 --- /dev/null +++ b/src/apps/stable/features/playback/utils/mediaSegmentManager.ts @@ -0,0 +1,102 @@ +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 { getMediaSegmentAction } from './mediaSegmentSettings'; +import { findCurrentSegment } from './mediaSegments'; +import { PlaybackSubscriber } from './playbackSubscriber'; +import { MediaSegmentAction } from '../constants/mediaSegmentAction'; + +class MediaSegmentManager extends PlaybackSubscriber { + private hasSegments = false; + private lastIndex = 0; + private mediaSegmentTypeActions: Record, MediaSegmentAction> | undefined; + private mediaSegments: MediaSegmentDto[] = []; + + 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) { + console.debug('[MediaSegmentManager] skipping to %s ms', mediaSegment.EndTicks / 10000); + this.playbackManager.seek(mediaSegment.EndTicks, this.player); + } else { + console.debug('[MediaSegmentManager] skipping to next item in queue'); + this.playbackManager.nextTrack(this.player); + } + } + } + + onPlayerPlaybackStart(_e: Event, state: PlayerState) { + this.lastIndex = 0; + this.hasSegments = !!state.MediaSource?.HasSegments; + + const itemId = state.MediaSource?.Id; + const serverId = state.NowPlayingItem?.ServerId || ServerConnections.currentApiClient()?.serverId(); + + if (!this.hasSegments || !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 api = toApi(ServerConnections.getApiClient(serverId)); + void this.fetchMediaSegments( + api, + itemId, + Object.keys(this.mediaSegmentTypeActions).map(t => t as keyof typeof MediaSegmentType)); + } + + onPlayerTimeUpdate() { + if (this.hasSegments && this.mediaSegments.length) { + const time = this.playbackManager.currentTime(this.player) * 10000; + const currentSegmentDetails = findCurrentSegment(this.mediaSegments, time, this.lastIndex); + if (currentSegmentDetails) { + console.debug('[MediaSegmentManager] found %s segment at %s ms', currentSegmentDetails.segment.Type, time / 10000, currentSegmentDetails); + this.performAction(currentSegmentDetails.segment); + this.lastIndex = currentSegmentDetails.index; + } + } + } +} + +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 0000000000..e190a60f95 --- /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'; + +export 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/mediaSegments.ts b/src/apps/stable/features/playback/utils/mediaSegments.ts new file mode 100644 index 0000000000..fd2c45e4b7 --- /dev/null +++ b/src/apps/stable/features/playback/utils/mediaSegments.ts @@ -0,0 +1,41 @@ +import type { MediaSegmentDto } from '@jellyfin/sdk/lib/generated-client/models/media-segment-dto'; + +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) +); + +export 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, segment = segments[index] + ) { + if (isBeforeSegment(segment, time, direction)) return; + if (isInSegment(segment, time)) return { index, segment }; + } +}; diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js index 784c7248f2..bddc34930f 100644 --- a/src/components/playback/playbackmanager.js +++ b/src/components/playback/playbackmanager.js @@ -1,5 +1,6 @@ import { PlaybackErrorCode } from '@jellyfin/sdk/lib/generated-client/models/playback-error-code.js'; import { getMediaInfoApi } from '@jellyfin/sdk/lib/utils/api/media-info-api'; +import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type'; import merge from 'lodash-es/merge'; import Screenfull from 'screenfull'; @@ -19,8 +20,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'; import { toApi } from 'utils/jellyfin-apiclient/compat'; @@ -3663,6 +3664,8 @@ export class PlaybackManager { Events.on(serverNotifications, 'ServerRestarting', self.setDefaultPlayerActive.bind(self)); }); } + + bindMediaSegmentManager(self); } getCurrentPlayer() { diff --git a/src/components/playbackSettings/playbackSettings.js b/src/components/playbackSettings/playbackSettings.js index d9688a210a..05374a78d5 100644 --- a/src/components/playbackSettings/playbackSettings.js +++ b/src/components/playbackSettings/playbackSettings.js @@ -2,6 +2,7 @@ import { MediaSegmentType } from '@jellyfin/sdk/lib/generated-client/models/medi import escapeHTML from 'escape-html'; import { MediaSegmentAction } from 'apps/stable/features/playback/constants/mediaSegmentAction'; +import { getId, getMediaSegmentAction } from 'apps/stable/features/playback/utils/mediaSegmentSettings'; import appSettings from '../../scripts/settings/appSettings'; import { appHost } from '../apphost'; @@ -54,18 +55,23 @@ function populateMediaSegments(container, userSettings) { }) .join(''); - const segmentSettings = Object.values(MediaSegmentType) - .map(segmentType => { - const segmentTypeLabel = globalize.translate('LabelMediaSegmentsType', globalize.translate(`MediaSegmentType.${segmentType}`)); - const id = `segmentTypeAction__${segmentType}`; - selectedValues[id] = userSettings.get(id, false) || MediaSegmentAction.None; - return `
- + const segmentSettings = [ + // List the types in a logical order (and exclude "Unknown" type) + MediaSegmentType.Intro, + MediaSegmentType.Preview, + MediaSegmentType.Recap, + MediaSegmentType.Commercial, + MediaSegmentType.Outro + ].map(segmentType => { + const segmentTypeLabel = globalize.translate('LabelMediaSegmentsType', globalize.translate(`MediaSegmentType.${segmentType}`)); + const id = getId(segmentType); + selectedValues[id] = getMediaSegmentAction(userSettings, segmentType) || MediaSegmentAction.None; + return `
+
`; - }) - .join(''); + }).join(''); container.innerHTML = segmentSettings; diff --git a/src/scripts/settings/userSettings.js b/src/scripts/settings/userSettings.js index ba5d16d2e6..27de2f2641 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; diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 8dd84f6110..c378a4a559 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -1069,7 +1069,6 @@ "MediaIsBeingConverted": "The media is being converted into a format that is compatible with the device that is playing the media.", "MediaSegmentAction.None": "None", "MediaSegmentAction.Skip": "Skip", - "MediaSegmentType.Unknown": "Unknown", "MediaSegmentType.Commercial": "Commercial", "MediaSegmentType.Preview": "Preview", "MediaSegmentType.Recap": "Recap",