Skip to content

Commit

Permalink
Add media segment manager
Browse files Browse the repository at this point in the history
  • Loading branch information
thornbill committed Oct 2, 2024
1 parent dbcabe0 commit 9a4420b
Show file tree
Hide file tree
Showing 5 changed files with 175 additions and 5 deletions.
153 changes: 153 additions & 0 deletions src/apps/stable/features/playback/utils/mediaSegmentManager.ts
Original file line number Diff line number Diff line change
@@ -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<Partial<MediaSegmentType>, 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<Partial<MediaSegmentType>, 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);
14 changes: 14 additions & 0 deletions src/apps/stable/features/playback/utils/mediaSegmentSettings.ts
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 3 additions & 3 deletions src/apps/stable/features/playback/utils/playbackSubscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -87,15 +87,15 @@ 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));
});
}

this.player = newPlayer;
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));
});
}
}
5 changes: 4 additions & 1 deletion src/components/playback/playbackmanager.js
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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';

Expand Down Expand Up @@ -3645,6 +3646,8 @@ export class PlaybackManager {
Events.on(serverNotifications, 'ServerRestarting', self.setDefaultPlayerActive.bind(self));
});
}

bindMediaSegmentManager(self);
}

getCurrentPlayer() {
Expand Down
2 changes: 1 addition & 1 deletion src/scripts/settings/userSettings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down

0 comments on commit 9a4420b

Please sign in to comment.