From 809dba510a45dc7e4a162a02c4b9297b4822d13f Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Wed, 25 Sep 2024 16:55:53 -0400 Subject: [PATCH 1/6] Add media segment action settings --- .../playback/constants/mediaSegmentAction.ts | 7 +++ .../playbackSettings/playbackSettings.js | 50 +++++++++++++++++-- .../playbackSettings.template.html | 3 ++ src/controllers/user/playback/index.js | 2 +- src/strings/en-us.json | 10 ++++ 5 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 src/apps/stable/features/playback/constants/mediaSegmentAction.ts diff --git a/src/apps/stable/features/playback/constants/mediaSegmentAction.ts b/src/apps/stable/features/playback/constants/mediaSegmentAction.ts new file mode 100644 index 00000000000..c1eb9652f0d --- /dev/null +++ b/src/apps/stable/features/playback/constants/mediaSegmentAction.ts @@ -0,0 +1,7 @@ +/** + * Actions that are triggered for media segments. + */ +export enum MediaSegmentAction { + None = 'None', + Skip = 'Skip' +} diff --git a/src/components/playbackSettings/playbackSettings.js b/src/components/playbackSettings/playbackSettings.js index 1461dbb54d1..d9688a210a4 100644 --- a/src/components/playbackSettings/playbackSettings.js +++ b/src/components/playbackSettings/playbackSettings.js @@ -1,3 +1,8 @@ +import { MediaSegmentType } from '@jellyfin/sdk/lib/generated-client/models/media-segment-type'; +import escapeHTML from 'escape-html'; + +import { MediaSegmentAction } from 'apps/stable/features/playback/constants/mediaSegmentAction'; + import appSettings from '../../scripts/settings/appSettings'; import { appHost } from '../apphost'; import browser from '../../scripts/browser'; @@ -6,12 +11,12 @@ import qualityoptions from '../qualityOptions'; import globalize from '../../lib/globalize'; import loading from '../loading/loading'; import Events from '../../utils/events.ts'; -import '../../elements/emby-select/emby-select'; -import '../../elements/emby-checkbox/emby-checkbox'; import ServerConnections from '../ServerConnections'; import toast from '../toast/toast'; import template from './playbackSettings.template.html'; -import escapeHTML from 'escape-html'; + +import '../../elements/emby-select/emby-select'; +import '../../elements/emby-checkbox/emby-checkbox'; function fillSkipLengths(select) { const options = [5, 10, 15, 20, 25, 30]; @@ -40,6 +45,37 @@ function populateLanguages(select, languages) { select.innerHTML = html; } +function populateMediaSegments(container, userSettings) { + const selectedValues = {}; + const actionOptions = Object.values(MediaSegmentAction) + .map(action => { + const actionLabel = globalize.translate(`MediaSegmentAction.${action}`); + return ``; + }) + .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 `
+ +
`; + }) + .join(''); + + container.innerHTML = segmentSettings; + + Object.entries(selectedValues) + .forEach(([id, value]) => { + const field = container.querySelector(`#${id}`); + if (field) field.value = value; + }); +} + function fillQuality(select, isInNetwork, mediatype, maxVideoWidth) { const options = mediatype === 'Audio' ? qualityoptions.getAudioQualityOptions({ @@ -219,6 +255,9 @@ function loadForm(context, user, userSettings, systemInfo, apiClient) { fillSkipLengths(selectSkipBackLength); selectSkipBackLength.value = userSettings.skipBackLength(); + const mediaSegmentContainer = context.querySelector('.mediaSegmentActionContainer'); + populateMediaSegments(mediaSegmentContainer, userSettings); + loading.hide(); } @@ -257,6 +296,11 @@ function saveUser(context, user, userSettingsInstance, apiClient) { userSettingsInstance.skipForwardLength(context.querySelector('.selectSkipForwardLength').value); userSettingsInstance.skipBackLength(context.querySelector('.selectSkipBackLength').value); + const segmentTypeActions = context.querySelectorAll('.segmentTypeAction') || []; + Array.prototype.forEach.call(segmentTypeActions, actionEl => { + userSettingsInstance.set(actionEl.id, actionEl.value, false); + }); + return apiClient.updateUserConfiguration(user.Id, user.Configuration); } diff --git a/src/components/playbackSettings/playbackSettings.template.html b/src/components/playbackSettings/playbackSettings.template.html index 0ad66b3c9a9..ed1409eff0d 100644 --- a/src/components/playbackSettings/playbackSettings.template.html +++ b/src/components/playbackSettings/playbackSettings.template.html @@ -156,6 +156,9 @@

+ +

${HeaderMediaSegmentActions}

+
diff --git a/src/controllers/user/playback/index.js b/src/controllers/user/playback/index.js index efb24c76ee9..6f652be4a49 100644 --- a/src/controllers/user/playback/index.js +++ b/src/controllers/user/playback/index.js @@ -19,7 +19,7 @@ export default function (view, params) { } else { settingsInstance = new PlaybackSettings({ serverId: ApiClient.serverId(), - userId: userId, + userId, element: view.querySelector('.settingsContainer'), userSettings: currentSettings, enableSaveButton: true, diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 88f0125fbcf..8dd84f61105 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -446,6 +446,7 @@ "HeaderLyricDownloads": "Lyric Downloads", "HeaderMedia": "Media", "HeaderMediaFolders": "Media Folders", + "HeaderMediaSegmentActions": "Media Segment Actions", "HeaderMetadataSettings": "Metadata Settings", "HeaderMoreLikeThis": "More Like This", "HeaderMusicQuality": "Music Quality", @@ -751,6 +752,7 @@ "LabelMaxDaysForNextUpHelp": "Set the maximum amount of days a show should stay in the 'Next Up' list without watching it.", "LabelMaxVideoResolution": "Maximum Allowed Video Transcoding Resolution", "LabelMediaDetails": "Media details", + "LabelMediaSegmentsType": "{0} Segments", "LabelLineup": "Lineup", "LabelLocalCustomCss": "Custom CSS code for styling which applies to this client only. You may want to disable server custom CSS code.", "LabelLocalHttpServerPortNumber": "Local HTTP port number", @@ -1065,6 +1067,14 @@ "MediaInfoTitle": "Title", "MediaInfoVideoRange": "Video range", "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", + "MediaSegmentType.Outro": "Outro", + "MediaSegmentType.Intro": "Intro", "Menu": "Menu", "MenuOpen": "Open Menu", "MenuClose": "Close Menu", From b93450098a452242b4cbdef0417a6ffe2d03cd4e Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Wed, 2 Oct 2024 17:09:59 -0400 Subject: [PATCH 2/6] Add media segment manager --- .../playback/utils/mediaSegmentManager.ts | 102 ++++++++++++++++++ .../playback/utils/mediaSegmentSettings.ts | 14 +++ .../features/playback/utils/mediaSegments.ts | 41 +++++++ src/components/playback/playbackmanager.js | 5 +- .../playbackSettings/playbackSettings.js | 28 +++-- src/scripts/settings/userSettings.js | 2 +- src/strings/en-us.json | 1 - 7 files changed, 179 insertions(+), 14 deletions(-) create mode 100644 src/apps/stable/features/playback/utils/mediaSegmentManager.ts create mode 100644 src/apps/stable/features/playback/utils/mediaSegmentSettings.ts create mode 100644 src/apps/stable/features/playback/utils/mediaSegments.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 00000000000..8d245cdd925 --- /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 00000000000..e190a60f95e --- /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 00000000000..fd2c45e4b74 --- /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 784c7248f24..bddc34930fa 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 d9688a210a4..05374a78d5c 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 ba5d16d2e6a..27de2f2641f 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 8dd84f61105..c378a4a5598 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", From 9aeb64e347498e455afa96fdb5b00dd16c06b758 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Wed, 9 Oct 2024 12:04:47 -0400 Subject: [PATCH 3/6] Add tests for segment utils --- .../playback/utils/mediaSegments.test.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/apps/stable/features/playback/utils/mediaSegments.test.ts diff --git a/src/apps/stable/features/playback/utils/mediaSegments.test.ts b/src/apps/stable/features/playback/utils/mediaSegments.test.ts new file mode 100644 index 00000000000..17dc9e73834 --- /dev/null +++ b/src/apps/stable/features/playback/utils/mediaSegments.test.ts @@ -0,0 +1,68 @@ +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 { describe, expect, it } from 'vitest'; + +import { findCurrentSegment } from './mediaSegments'; + +const TEST_SEGMENTS: MediaSegmentDto[] = [ + { + Id: 'intro', + Type: MediaSegmentType.Intro, + StartTicks: 0, + EndTicks: 10 + }, + { + Id: 'preview', + Type: MediaSegmentType.Preview, + StartTicks: 20, + EndTicks: 30 + }, + { + Id: 'recap', + Type: MediaSegmentType.Recap, + StartTicks: 30, + EndTicks: 40 + }, + { + Id: 'commercial', + Type: MediaSegmentType.Commercial, + StartTicks: 40, + EndTicks: 50 + }, + { + Id: 'outro', + Type: MediaSegmentType.Outro, + StartTicks: 50, + EndTicks: 60 + } +]; + +describe('findCurrentSegment()', () => { + it('Should return the current segment', () => { + let segmentDetails = findCurrentSegment(TEST_SEGMENTS, 23); + expect(segmentDetails).toBeDefined(); + expect(segmentDetails?.index).toBe(1); + expect(segmentDetails?.segment?.Id).toBe('preview'); + + segmentDetails = findCurrentSegment(TEST_SEGMENTS, 5, 1); + expect(segmentDetails).toBeDefined(); + expect(segmentDetails?.index).toBe(0); + expect(segmentDetails?.segment?.Id).toBe('intro'); + + segmentDetails = findCurrentSegment(TEST_SEGMENTS, 42, 3); + expect(segmentDetails).toBeDefined(); + expect(segmentDetails?.index).toBe(3); + expect(segmentDetails?.segment?.Id).toBe('commercial'); + }); + + it('Should return undefined if not in a segment', () => { + let segmentDetails = findCurrentSegment(TEST_SEGMENTS, 16); + expect(segmentDetails).toBeUndefined(); + + segmentDetails = findCurrentSegment(TEST_SEGMENTS, 10, 1); + expect(segmentDetails).toBeUndefined(); + + segmentDetails = findCurrentSegment(TEST_SEGMENTS, 100); + expect(segmentDetails).toBeUndefined(); + }); +}); From 7c4962de80986af6d434ec4a8a5bcce55406f26e Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Wed, 9 Oct 2024 13:23:17 -0400 Subject: [PATCH 4/6] Skip skipping if skip is short --- .../playback/utils/mediaSegmentManager.ts | 17 ++++++++++++++--- src/constants/time.ts | 8 ++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 src/constants/time.ts diff --git a/src/apps/stable/features/playback/utils/mediaSegmentManager.ts b/src/apps/stable/features/playback/utils/mediaSegmentManager.ts index 8d245cdd925..68e65dd7aba 100644 --- a/src/apps/stable/features/playback/utils/mediaSegmentManager.ts +++ b/src/apps/stable/features/playback/utils/mediaSegmentManager.ts @@ -5,6 +5,7 @@ import { MediaSegmentsApi } from '@jellyfin/sdk/lib/generated-client/api/media-s import type { PlaybackManager } from 'components/playback/playbackmanager'; import ServerConnections from 'components/ServerConnections'; +import { TICKS_PER_MILLISECOND, TICKS_PER_SECOND } from 'constants/time'; import { currentSettings as userSettings } from 'scripts/settings/userSettings'; import type { PlayerState } from 'types/playbackStopInfo'; import type { Event } from 'utils/events'; @@ -44,7 +45,13 @@ class MediaSegmentManager extends PlaybackSubscriber { if (action === MediaSegmentAction.Skip) { // Perform skip if (mediaSegment.EndTicks) { - console.debug('[MediaSegmentManager] skipping to %s ms', mediaSegment.EndTicks / 10000); + // 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); + return; + } + + console.debug('[MediaSegmentManager] skipping to %s ms', mediaSegment.EndTicks / TICKS_PER_MILLISECOND); this.playbackManager.seek(mediaSegment.EndTicks, this.player); } else { console.debug('[MediaSegmentManager] skipping to next item in queue'); @@ -88,10 +95,14 @@ class MediaSegmentManager extends PlaybackSubscriber { onPlayerTimeUpdate() { if (this.hasSegments && this.mediaSegments.length) { - const time = this.playbackManager.currentTime(this.player) * 10000; + const time = this.playbackManager.currentTime(this.player) * TICKS_PER_MILLISECOND; 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); + console.debug( + '[MediaSegmentManager] found %s segment at %s ms', + currentSegmentDetails.segment.Type, + time / TICKS_PER_MILLISECOND, + currentSegmentDetails); this.performAction(currentSegmentDetails.segment); this.lastIndex = currentSegmentDetails.index; } diff --git a/src/constants/time.ts b/src/constants/time.ts new file mode 100644 index 00000000000..4555b91192b --- /dev/null +++ b/src/constants/time.ts @@ -0,0 +1,8 @@ +/** The number of ticks per millisecond */ +export const TICKS_PER_MILLISECOND = 10_000; + +/** The number of ticks per second */ +export const TICKS_PER_SECOND = 1_000 * TICKS_PER_MILLISECOND; + +/** The number of ticks per minute */ +export const TICKS_PER_MINUTE = 60 * TICKS_PER_SECOND; From 9942fb78562323a4c65ce6342dbfa9dae8e7f11d Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Sun, 13 Oct 2024 01:29:36 -0400 Subject: [PATCH 5/6] Apply suggestions from code review Co-authored-by: Dmitry Lyzo <56478732+dmitrylyzo@users.noreply.github.com> --- src/apps/stable/features/playback/utils/mediaSegments.ts | 4 ++-- src/strings/en-us.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/apps/stable/features/playback/utils/mediaSegments.ts b/src/apps/stable/features/playback/utils/mediaSegments.ts index fd2c45e4b74..9ea8e1bdf71 100644 --- a/src/apps/stable/features/playback/utils/mediaSegments.ts +++ b/src/apps/stable/features/playback/utils/mediaSegments.ts @@ -4,7 +4,7 @@ const isBeforeSegment = (segment: MediaSegmentDto, time: number, direction: numb if (direction === -1) { return ( typeof segment.EndTicks !== 'undefined' - && segment.EndTicks < time + && segment.EndTicks <= time ); } return ( @@ -15,7 +15,7 @@ const isBeforeSegment = (segment: MediaSegmentDto, time: number, direction: numb const isInSegment = (segment: MediaSegmentDto, time: number) => ( typeof segment.StartTicks !== 'undefined' - && segment.StartTicks < time + && segment.StartTicks <= time && (typeof segment.EndTicks === 'undefined' || segment.EndTicks > time) ); diff --git a/src/strings/en-us.json b/src/strings/en-us.json index c378a4a5598..10e5948c576 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -1070,10 +1070,10 @@ "MediaSegmentAction.None": "None", "MediaSegmentAction.Skip": "Skip", "MediaSegmentType.Commercial": "Commercial", + "MediaSegmentType.Intro": "Intro", + "MediaSegmentType.Outro": "Outro", "MediaSegmentType.Preview": "Preview", "MediaSegmentType.Recap": "Recap", - "MediaSegmentType.Outro": "Outro", - "MediaSegmentType.Intro": "Intro", "Menu": "Menu", "MenuOpen": "Open Menu", "MenuClose": "Close Menu", From aef9482824f22efd0e69c72f3ad8112657abd8cc Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Sun, 13 Oct 2024 03:42:45 -0400 Subject: [PATCH 6/6] Prevent skipping when seeking back to segment --- .../playback/utils/mediaSegmentManager.ts | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/apps/stable/features/playback/utils/mediaSegmentManager.ts b/src/apps/stable/features/playback/utils/mediaSegmentManager.ts index 68e65dd7aba..7957ff23342 100644 --- a/src/apps/stable/features/playback/utils/mediaSegmentManager.ts +++ b/src/apps/stable/features/playback/utils/mediaSegmentManager.ts @@ -18,7 +18,9 @@ import { MediaSegmentAction } from '../constants/mediaSegmentAction'; class MediaSegmentManager extends PlaybackSubscriber { private hasSegments = false; - private lastIndex = 0; + private isLastSegmentIgnored = false; + private lastSegmentIndex = 0; + private lastTime = -1; private mediaSegmentTypeActions: Record, MediaSegmentAction> | undefined; private mediaSegments: MediaSegmentDto[] = []; @@ -43,17 +45,24 @@ class MediaSegmentManager extends PlaybackSubscriber { const action = this.mediaSegmentTypeActions[mediaSegment.Type]; if (action === MediaSegmentAction.Skip) { - // Perform skip - if (mediaSegment.EndTicks) { + // 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); } @@ -61,7 +70,9 @@ class MediaSegmentManager extends PlaybackSubscriber { } onPlayerPlaybackStart(_e: Event, state: PlayerState) { - this.lastIndex = 0; + this.isLastSegmentIgnored = false; + this.lastSegmentIndex = 0; + this.lastTime = -1; this.hasSegments = !!state.MediaSource?.HasSegments; const itemId = state.MediaSource?.Id; @@ -96,16 +107,23 @@ class MediaSegmentManager extends PlaybackSubscriber { onPlayerTimeUpdate() { if (this.hasSegments && this.mediaSegments.length) { const time = this.playbackManager.currentTime(this.player) * TICKS_PER_MILLISECOND; - const currentSegmentDetails = findCurrentSegment(this.mediaSegments, time, this.lastIndex); - if (currentSegmentDetails) { + const currentSegmentDetails = findCurrentSegment(this.mediaSegments, time, this.lastSegmentIndex); + if ( + // The current time falls within a segment + currentSegmentDetails + // and the last segment is not ignored or the segment index has changed + && (!this.isLastSegmentIgnored || this.lastSegmentIndex !== currentSegmentDetails.index) + ) { console.debug( '[MediaSegmentManager] found %s segment at %s ms', currentSegmentDetails.segment.Type, time / TICKS_PER_MILLISECOND, currentSegmentDetails); + this.isLastSegmentIgnored = false; this.performAction(currentSegmentDetails.segment); - this.lastIndex = currentSegmentDetails.index; + this.lastSegmentIndex = currentSegmentDetails.index; } + this.lastTime = time; } } }