mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Merge pull request #6157 from thornbill/media-segment-actions
Add media segment skipping
This commit is contained in:
commit
a7185ed750
12 changed files with 340 additions and 6 deletions
|
@ -0,0 +1,7 @@
|
||||||
|
/**
|
||||||
|
* Actions that are triggered for media segments.
|
||||||
|
*/
|
||||||
|
export enum MediaSegmentAction {
|
||||||
|
None = 'None',
|
||||||
|
Skip = 'Skip'
|
||||||
|
}
|
131
src/apps/stable/features/playback/utils/mediaSegmentManager.ts
Normal file
131
src/apps/stable/features/playback/utils/mediaSegmentManager.ts
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
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 { 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';
|
||||||
|
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 isLastSegmentIgnored = false;
|
||||||
|
private lastSegmentIndex = 0;
|
||||||
|
private lastTime = -1;
|
||||||
|
private mediaSegmentTypeActions: Record<Partial<MediaSegmentType>, 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) {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPlayerPlaybackStart(_e: Event, state: PlayerState) {
|
||||||
|
this.isLastSegmentIgnored = false;
|
||||||
|
this.lastSegmentIndex = 0;
|
||||||
|
this.lastTime = -1;
|
||||||
|
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<Partial<MediaSegmentType>, 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) * TICKS_PER_MILLISECOND;
|
||||||
|
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.lastSegmentIndex = currentSegmentDetails.index;
|
||||||
|
}
|
||||||
|
this.lastTime = time;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bindMediaSegmentManager = (playbackManager: PlaybackManager) => new MediaSegmentManager(playbackManager);
|
|
@ -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;
|
||||||
|
}
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
41
src/apps/stable/features/playback/utils/mediaSegments.ts
Normal file
41
src/apps/stable/features/playback/utils/mediaSegments.ts
Normal file
|
@ -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 };
|
||||||
|
}
|
||||||
|
};
|
|
@ -1,5 +1,6 @@
|
||||||
import { PlaybackErrorCode } from '@jellyfin/sdk/lib/generated-client/models/playback-error-code.js';
|
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 { 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 merge from 'lodash-es/merge';
|
||||||
import Screenfull from 'screenfull';
|
import Screenfull from 'screenfull';
|
||||||
|
|
||||||
|
@ -19,8 +20,8 @@ import { PluginType } from '../../types/plugin.ts';
|
||||||
import { includesAny } from '../../utils/container.ts';
|
import { includesAny } from '../../utils/container.ts';
|
||||||
import { getItems } from '../../utils/jellyfin-apiclient/getItems.ts';
|
import { getItems } from '../../utils/jellyfin-apiclient/getItems.ts';
|
||||||
import { getItemBackdropImageUrl } from '../../utils/jellyfin-apiclient/backdropImage';
|
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 { MediaError } from 'types/mediaError';
|
||||||
import { getMediaError } from 'utils/mediaError';
|
import { getMediaError } from 'utils/mediaError';
|
||||||
import { toApi } from 'utils/jellyfin-apiclient/compat';
|
import { toApi } from 'utils/jellyfin-apiclient/compat';
|
||||||
|
@ -3663,6 +3664,8 @@ export class PlaybackManager {
|
||||||
Events.on(serverNotifications, 'ServerRestarting', self.setDefaultPlayerActive.bind(self));
|
Events.on(serverNotifications, 'ServerRestarting', self.setDefaultPlayerActive.bind(self));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bindMediaSegmentManager(self);
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentPlayer() {
|
getCurrentPlayer() {
|
||||||
|
|
|
@ -1,3 +1,9 @@
|
||||||
|
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 { getId, getMediaSegmentAction } from 'apps/stable/features/playback/utils/mediaSegmentSettings';
|
||||||
|
|
||||||
import appSettings from '../../scripts/settings/appSettings';
|
import appSettings from '../../scripts/settings/appSettings';
|
||||||
import { appHost } from '../apphost';
|
import { appHost } from '../apphost';
|
||||||
import browser from '../../scripts/browser';
|
import browser from '../../scripts/browser';
|
||||||
|
@ -6,12 +12,12 @@ import qualityoptions from '../qualityOptions';
|
||||||
import globalize from '../../lib/globalize';
|
import globalize from '../../lib/globalize';
|
||||||
import loading from '../loading/loading';
|
import loading from '../loading/loading';
|
||||||
import Events from '../../utils/events.ts';
|
import Events from '../../utils/events.ts';
|
||||||
import '../../elements/emby-select/emby-select';
|
|
||||||
import '../../elements/emby-checkbox/emby-checkbox';
|
|
||||||
import ServerConnections from '../ServerConnections';
|
import ServerConnections from '../ServerConnections';
|
||||||
import toast from '../toast/toast';
|
import toast from '../toast/toast';
|
||||||
import template from './playbackSettings.template.html';
|
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) {
|
function fillSkipLengths(select) {
|
||||||
const options = [5, 10, 15, 20, 25, 30];
|
const options = [5, 10, 15, 20, 25, 30];
|
||||||
|
@ -40,6 +46,42 @@ function populateLanguages(select, languages) {
|
||||||
select.innerHTML = html;
|
select.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function populateMediaSegments(container, userSettings) {
|
||||||
|
const selectedValues = {};
|
||||||
|
const actionOptions = Object.values(MediaSegmentAction)
|
||||||
|
.map(action => {
|
||||||
|
const actionLabel = globalize.translate(`MediaSegmentAction.${action}`);
|
||||||
|
return `<option value='${action}'>${actionLabel}</option>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
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 `<div class="selectContainer">
|
||||||
|
<select is="emby-select" id="${id}" class="segmentTypeAction" label="${segmentTypeLabel}">
|
||||||
|
${actionOptions}
|
||||||
|
</select>
|
||||||
|
</div>`;
|
||||||
|
}).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) {
|
function fillQuality(select, isInNetwork, mediatype, maxVideoWidth) {
|
||||||
const options = mediatype === 'Audio' ? qualityoptions.getAudioQualityOptions({
|
const options = mediatype === 'Audio' ? qualityoptions.getAudioQualityOptions({
|
||||||
|
|
||||||
|
@ -219,6 +261,9 @@ function loadForm(context, user, userSettings, systemInfo, apiClient) {
|
||||||
fillSkipLengths(selectSkipBackLength);
|
fillSkipLengths(selectSkipBackLength);
|
||||||
selectSkipBackLength.value = userSettings.skipBackLength();
|
selectSkipBackLength.value = userSettings.skipBackLength();
|
||||||
|
|
||||||
|
const mediaSegmentContainer = context.querySelector('.mediaSegmentActionContainer');
|
||||||
|
populateMediaSegments(mediaSegmentContainer, userSettings);
|
||||||
|
|
||||||
loading.hide();
|
loading.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -257,6 +302,11 @@ function saveUser(context, user, userSettingsInstance, apiClient) {
|
||||||
userSettingsInstance.skipForwardLength(context.querySelector('.selectSkipForwardLength').value);
|
userSettingsInstance.skipForwardLength(context.querySelector('.selectSkipForwardLength').value);
|
||||||
userSettingsInstance.skipBackLength(context.querySelector('.selectSkipBackLength').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);
|
return apiClient.updateUserConfiguration(user.Id, user.Configuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -156,6 +156,9 @@
|
||||||
<div class="selectContainer">
|
<div class="selectContainer">
|
||||||
<select is="emby-select" class="selectSkipBackLength" label="${LabelSkipBackLength}"></select>
|
<select is="emby-select" class="selectSkipBackLength" label="${LabelSkipBackLength}"></select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 class="sectionTitle">${HeaderMediaSegmentActions}</h3>
|
||||||
|
<div class="mediaSegmentActionContainer"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="verticalSection verticalSection-extrabottompadding">
|
<div class="verticalSection verticalSection-extrabottompadding">
|
||||||
|
|
8
src/constants/time.ts
Normal file
8
src/constants/time.ts
Normal file
|
@ -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;
|
|
@ -19,7 +19,7 @@ export default function (view, params) {
|
||||||
} else {
|
} else {
|
||||||
settingsInstance = new PlaybackSettings({
|
settingsInstance = new PlaybackSettings({
|
||||||
serverId: ApiClient.serverId(),
|
serverId: ApiClient.serverId(),
|
||||||
userId: userId,
|
userId,
|
||||||
element: view.querySelector('.settingsContainer'),
|
element: view.querySelector('.settingsContainer'),
|
||||||
userSettings: currentSettings,
|
userSettings: currentSettings,
|
||||||
enableSaveButton: true,
|
enableSaveButton: true,
|
||||||
|
|
|
@ -91,7 +91,7 @@ export class UserSettings {
|
||||||
* Get value of setting.
|
* Get value of setting.
|
||||||
* @param {string} name - Name of setting.
|
* @param {string} name - Name of setting.
|
||||||
* @param {boolean} [enableOnServer] - Flag to return preferences from server (cached).
|
* @param {boolean} [enableOnServer] - Flag to return preferences from server (cached).
|
||||||
* @return {string} Value of setting.
|
* @return {string | null} Value of setting.
|
||||||
*/
|
*/
|
||||||
get(name, enableOnServer) {
|
get(name, enableOnServer) {
|
||||||
const userId = this.currentUserId;
|
const userId = this.currentUserId;
|
||||||
|
|
|
@ -446,6 +446,7 @@
|
||||||
"HeaderLyricDownloads": "Lyric Downloads",
|
"HeaderLyricDownloads": "Lyric Downloads",
|
||||||
"HeaderMedia": "Media",
|
"HeaderMedia": "Media",
|
||||||
"HeaderMediaFolders": "Media Folders",
|
"HeaderMediaFolders": "Media Folders",
|
||||||
|
"HeaderMediaSegmentActions": "Media Segment Actions",
|
||||||
"HeaderMetadataSettings": "Metadata Settings",
|
"HeaderMetadataSettings": "Metadata Settings",
|
||||||
"HeaderMoreLikeThis": "More Like This",
|
"HeaderMoreLikeThis": "More Like This",
|
||||||
"HeaderMusicQuality": "Music Quality",
|
"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.",
|
"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",
|
"LabelMaxVideoResolution": "Maximum Allowed Video Transcoding Resolution",
|
||||||
"LabelMediaDetails": "Media details",
|
"LabelMediaDetails": "Media details",
|
||||||
|
"LabelMediaSegmentsType": "{0} Segments",
|
||||||
"LabelLineup": "Lineup",
|
"LabelLineup": "Lineup",
|
||||||
"LabelLocalCustomCss": "Custom CSS code for styling which applies to this client only. You may want to disable server custom CSS code.",
|
"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",
|
"LabelLocalHttpServerPortNumber": "Local HTTP port number",
|
||||||
|
@ -1067,6 +1069,13 @@
|
||||||
"MediaInfoTitle": "Title",
|
"MediaInfoTitle": "Title",
|
||||||
"MediaInfoVideoRange": "Video range",
|
"MediaInfoVideoRange": "Video range",
|
||||||
"MediaIsBeingConverted": "The media is being converted into a format that is compatible with the device that is playing the media.",
|
"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.Commercial": "Commercial",
|
||||||
|
"MediaSegmentType.Intro": "Intro",
|
||||||
|
"MediaSegmentType.Outro": "Outro",
|
||||||
|
"MediaSegmentType.Preview": "Preview",
|
||||||
|
"MediaSegmentType.Recap": "Recap",
|
||||||
"Menu": "Menu",
|
"Menu": "Menu",
|
||||||
"MenuOpen": "Open Menu",
|
"MenuOpen": "Open Menu",
|
||||||
"MenuClose": "Close Menu",
|
"MenuClose": "Close Menu",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue