Add skip intro / next episode at credits, via Emby's own detection #4
11 changed files with 288 additions and 4 deletions
|
|
@ -328,6 +328,10 @@ Then put it behind your existing reverse proxy like everything else
|
|||
as its own always-on-top bar, duplicating the cast icon and
|
||||
overlapping the new bottom bar in fullscreen) in favor of fully
|
||||
custom play/pause, a seek bar, and mute — see docs/architecture.md
|
||||
- [x] Skip intro / jump to next episode at end credits, using Emby's own
|
||||
native marker detection (`EnableMarkerDetection` + its "Detect
|
||||
Episode Intros" task) rather than building fingerprinting
|
||||
ourselves — see docs/architecture.md
|
||||
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
|
||||
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
|
||||
confirmed here — no real device/session)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// module just wraps fetch calls to our own /api/* routes. See
|
||||
// docs/architecture.md for why this changed from the first version (CORS,
|
||||
// plus not wanting the browser to hold an Emby token at all).
|
||||
import type { ItemDto, LibraryDto, PlaybackOptionsDto, SeasonDto } from '../types';
|
||||
import type { ItemDto, LibraryDto, PlaybackMarkersDto, PlaybackOptionsDto, SeasonDto } from '../types';
|
||||
|
||||
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
|
|
@ -116,6 +116,10 @@ export function getPlaybackOptions(itemId: string): Promise<PlaybackOptionsDto>
|
|||
return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
|
||||
}
|
||||
|
||||
export function getPlaybackMarkers(itemId: string): Promise<PlaybackMarkersDto> {
|
||||
return apiFetch(`/api/catalog/item/${itemId}/markers`);
|
||||
}
|
||||
|
||||
/** The party's own shared Emby stream (server/src/services/
|
||||
* PartyStreamService.ts) - one negotiated PlaybackInfo session reused
|
||||
* by every member, instead of each viewer's own <video>/<audio>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,16 @@ export interface PlaybackOptionsDto {
|
|||
subtitleTracks: PlaybackTrackOption[];
|
||||
}
|
||||
|
||||
/** Emby's own native intro/credits detection - see server/src/types.ts's
|
||||
* own copy of this shape for where the field names come from. Every
|
||||
* field is independently nullable ("not detected", never an error) -
|
||||
* see views/player/Player.ts's updateSkipButton, the only consumer. */
|
||||
export interface PlaybackMarkersDto {
|
||||
introStartTicks: number | null;
|
||||
introEndTicks: number | null;
|
||||
creditsStartTicks: number | null;
|
||||
}
|
||||
|
||||
export type MediaType = 'video' | 'music';
|
||||
|
||||
export interface PublicMember {
|
||||
|
|
|
|||
|
|
@ -187,6 +187,26 @@
|
|||
transition: padding .25s ease, background-color .25s ease, border-color .25s ease;
|
||||
}
|
||||
|
||||
/* Floating above #bottomBar (higher z-index, positioned clear of its
|
||||
tallest/expanded height) rather than living inside it - see
|
||||
Player.html's own comment on why: it must stay reachable through the
|
||||
bar's own auto-hide/.minimal collapse, not disappear along with it. */
|
||||
.skipMarkerBtn {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 84px;
|
||||
z-index: 7;
|
||||
background: rgba(20, 18, 22, .85);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, .25);
|
||||
padding: 10px 18px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.skipMarkerBtn:hover { background: rgba(20, 18, 22, .95); border-color: rgba(255, 255, 255, .4); }
|
||||
|
||||
/* Collapsed down to just the seek bar after a stretch of no activity
|
||||
(see Player.ts's scheduleControlsHide) - asked for directly: the
|
||||
full bar, always visible, sits right where a native <track> subtitle
|
||||
|
|
|
|||
|
|
@ -63,6 +63,17 @@
|
|||
notification rather than a fourth thing bolted onto a fixed layout. -->
|
||||
<div id="requestBanner" class="requestBanner hide"></div>
|
||||
|
||||
<!-- Emby's own native intro detection (see Player.ts's updateSkipButton/
|
||||
CatalogService.getPlaybackMarkers) - shown independently of #bottomBar
|
||||
below, and NOT subject to its own auto-hide/.minimal collapse, since
|
||||
this needs to stay reachable the moment the intro starts even if
|
||||
nobody's touched the player yet. Host-only, like every other seek -
|
||||
see updateSkipButton's own comment for why a follower can't get this
|
||||
button at all. Explicit label, not just an icon - see
|
||||
docs/architecture.md's player-rework section for why this app
|
||||
standardized on that for every request-shaped control. -->
|
||||
<button id="skipIntroBtn" class="skipMarkerBtn hide">Passer l'intro</button>
|
||||
|
||||
<!-- One bar, bottom-anchored, replacing what used to be three separate
|
||||
floating pieces (a centered banner/notice column, a request
|
||||
toolbar cornered bottom-right, an "Épisode suivant" link up in the
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import '../../styles/presence.css';
|
||||
import './Player.css';
|
||||
import html from './Player.html';
|
||||
import { auth, burnInStreamUrl, getPlaybackOptions, partyStreamUrl, subtitleUrl } from '../../api/embyApi';
|
||||
import { auth, burnInStreamUrl, getPlaybackMarkers, getPlaybackOptions, partyStreamUrl, subtitleUrl } from '../../api/embyApi';
|
||||
import { getParty, leaveParty, connectParty, issueCastToken, revokeCastToken } from '../../api/party';
|
||||
import type { PartyConnection } from '../../api/party';
|
||||
import { navigate } from '../../router';
|
||||
import type { CleanupFn, RouteParams } from '../../router';
|
||||
import { colorForName, escapeHtml, formatDuration, initialsFor } from '../../tiles';
|
||||
import { LOADING_HINT } from '../../loading';
|
||||
import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg, TextSubtitleChangedMsg } from '../../types';
|
||||
import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackMarkersDto, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg, TextSubtitleChangedMsg } from '../../types';
|
||||
import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
|
||||
import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../../cast';
|
||||
import type { PlaybackDriver } from '../../cast';
|
||||
|
|
@ -203,6 +203,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
const playPauseBtn = root.querySelector('#playPauseBtn') as HTMLButtonElement;
|
||||
const muteBtn = root.querySelector('#muteBtn') as HTMLButtonElement;
|
||||
const timeLabel = root.querySelector('#timeLabel')!;
|
||||
const skipIntroBtn = root.querySelector('#skipIntroBtn') as HTMLButtonElement;
|
||||
|
||||
qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => `<option value="${q.value}">${q.label}</option>`).join('');
|
||||
|
||||
|
|
@ -262,6 +263,15 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
// - see its own comment) can re-render the subtitle picker's host-only
|
||||
// gating for burn-in options without a redundant playback-options fetch.
|
||||
let lastPlaybackOptions: PlaybackOptionsDto | null = null;
|
||||
// Emby's own native intro/credits detection for currentItemId, if any -
|
||||
// see loadMarkers/updateSkipButton below and CatalogService
|
||||
// .getPlaybackMarkers. Null fields (or this whole thing being null,
|
||||
// while still loading) just mean no skip button, never an error.
|
||||
let currentMarkers: PlaybackMarkersDto | null = null;
|
||||
// One-shot per item - see updateSkipButton's own comment for why this
|
||||
// must NOT re-fire (force the bar open) on every tick for as long as
|
||||
// credits are running.
|
||||
let creditsControlsShown = false;
|
||||
// "The player" sendLocalState/applyRemoteState actually read and write -
|
||||
// the local <video> by default, swapped for a Chromecast's RemotePlayer
|
||||
// while casting (see toggleCast/cast.ts). Casting is available to any
|
||||
|
|
@ -427,6 +437,42 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
timeLabel.textContent = duration > 0 ? `${formatDuration(current)} / ${formatDuration(duration)}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows/hides #skipIntroBtn from activeDriver's current position against
|
||||
* currentMarkers - called continuously (progressTicker, below), same as
|
||||
* updateProgressBar. Host-only: a follower's own seek never broadcasts
|
||||
* (see sendLocalState's own gate), so a local jump here would just get
|
||||
* overwritten by the next correction from the real host - same
|
||||
* reasoning as progressBar being read-only for a follower.
|
||||
*
|
||||
* Credits get no button of their own: #nextBtn already does exactly
|
||||
* "go to the next queued item" and is already shown whenever
|
||||
* hasNextQueued is true (see onItemChanged) - detection's only real
|
||||
* contribution there is making sure it's actually reachable the moment
|
||||
* credits start, by un-minimizing the bar once, rather than requiring
|
||||
* the host to have touched the player recently. The `creditsControlsShown`
|
||||
* guard is what keeps this a one-shot nudge instead of fighting anyone
|
||||
* who deliberately re-minimizes the bar during a long credits scroll.
|
||||
*/
|
||||
function updateSkipButton() {
|
||||
if (!isHost || !currentMarkers) {
|
||||
skipIntroBtn.classList.add('hide');
|
||||
return;
|
||||
}
|
||||
const current = activeDriver.currentTime;
|
||||
const { introStartTicks, introEndTicks, creditsStartTicks } = currentMarkers;
|
||||
|
||||
const inIntro = introStartTicks != null && introEndTicks != null
|
||||
&& current >= introStartTicks / TICKS_PER_SECOND && current < introEndTicks / TICKS_PER_SECOND;
|
||||
skipIntroBtn.classList.toggle('hide', !inIntro);
|
||||
|
||||
const inCredits = creditsStartTicks != null && current >= creditsStartTicks / TICKS_PER_SECOND;
|
||||
if (inCredits && hasNextQueued && !creditsControlsShown) {
|
||||
creditsControlsShown = true;
|
||||
showControls();
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared by every discrete play/pause/seek event this player reacts
|
||||
* to - the local <video>'s own native events (becomeHost) and a cast
|
||||
* session's RemotePlayerController events (startCasting's own
|
||||
|
|
@ -505,6 +551,12 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
updateMuteBtn();
|
||||
});
|
||||
|
||||
skipIntroBtn.addEventListener('click', () => {
|
||||
if (currentMarkers?.introEndTicks == null) return;
|
||||
activeDriver.currentTime = currentMarkers.introEndTicks / TICKS_PER_SECOND;
|
||||
onDriverStateChanged();
|
||||
});
|
||||
|
||||
// Ticks the seek bar/time label between discrete events - currentTime
|
||||
// moves continuously while playing, with no event of its own for
|
||||
// "still playing, time passed" the way play/pause/seeked have. Runs
|
||||
|
|
@ -515,6 +567,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
progressTicker = window.setInterval(() => {
|
||||
if (!currentItemId) return;
|
||||
updateProgressBar();
|
||||
updateSkipButton();
|
||||
}, 500);
|
||||
updatePlayPauseBtn();
|
||||
updateMuteBtn();
|
||||
|
|
@ -650,6 +703,24 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
}
|
||||
}
|
||||
|
||||
/** Emby's own native intro/credits markers for `itemId`, if the library
|
||||
* it's from has detection enabled and the task has already reached it
|
||||
* - see CatalogService.getPlaybackMarkers. Reset first, synchronously,
|
||||
* not just on a fetch failure: without this, the OLD item's markers
|
||||
* (and skip button) would keep showing for however long this fetch
|
||||
* takes on the new one. */
|
||||
async function loadMarkers(itemId: string) {
|
||||
currentMarkers = null;
|
||||
skipIntroBtn.classList.add('hide');
|
||||
creditsControlsShown = false;
|
||||
try {
|
||||
currentMarkers = await getPlaybackMarkers(itemId);
|
||||
} catch (err) {
|
||||
console.error('[watchparty player] playback markers failed', err);
|
||||
// Non-fatal, same as loadTrackOptions above - playback works fine with no skip button.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* (Re)builds the subtitle <select> from the last-fetched playback
|
||||
* options, called both on a genuine item load and whenever burn-in/
|
||||
|
|
@ -934,6 +1005,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
burnInAudioIndex = null;
|
||||
textSubtitleIndex = null;
|
||||
streamAudioIndex = null;
|
||||
loadMarkers(msg.itemId); // not awaited - never needed before playback can start, see loadVideo below
|
||||
await loadTrackOptions(msg.itemId);
|
||||
loadVideo(msg.itemId, 0, true);
|
||||
}
|
||||
|
|
@ -1096,6 +1168,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
// image-subtitle option as disabled, as if they were a follower.
|
||||
if (party.youAreHost) becomeHost(false);
|
||||
|
||||
loadMarkers(party.itemId); // not awaited - see onItemChanged's own comment
|
||||
await loadTrackOptions(party.itemId);
|
||||
loadVideo(party.itemId, 0, false);
|
||||
|
||||
|
|
|
|||
|
|
@ -3101,6 +3101,82 @@ and play the stream end to end - no device reachable from this
|
|||
sandbox, same standing limitation as the rest of "Casting to a
|
||||
Chromecast".
|
||||
|
||||
## Skipping the intro/end credits, using Emby's own native detection
|
||||
|
||||
Asked for directly: propose skipping a TV episode's opening/end credits,
|
||||
Netflix-style. Checked directly against the real server before building
|
||||
anything, rather than assuming a data source existed - no plugin
|
||||
provides this (the installed "Cinema Intros" plugin is a different
|
||||
feature entirely - pre-show trailers, not per-episode skip points), and
|
||||
the embedded `Chapters` Emby already returns turned out to be
|
||||
auto-generated placeholders every 5 minutes ("Chapitre 1", "Chapitre
|
||||
2", ...), not real markers.
|
||||
|
||||
What the server DOES have, confirmed from its own `/openapi.json`
|
||||
rather than assumed: a native, per-library `EnableMarkerDetection`
|
||||
option (`Library/VirtualFolders`'s `LibraryOptions`) backed by a
|
||||
built-in "Detect Episode Intros" scheduled task - audio-fingerprint
|
||||
detection, the same idea as Jellyfin's separate Intro Skipper plugin,
|
||||
just already part of Emby itself. Once enabled and run, it adds real
|
||||
entries to the exact same `Chapters` array, distinguished from the
|
||||
auto-generated ones by `MarkerType`: `IntroStart`/`IntroEnd`/
|
||||
`CreditsStart` (confirmed from the schema, not guessed - there is no
|
||||
`CreditsEnd`, since credits are assumed to run to the end of the file).
|
||||
|
||||
Given that, this feature is almost entirely "read what Emby already
|
||||
computed and expose it" - no fingerprinting, no new persistence, no
|
||||
background jobs of this app's own. `CatalogService.getPlaybackMarkers`
|
||||
fetches `Fields=Chapters` for an item and picks out just those three
|
||||
marker types (nulls when detection hasn't reached this item, or isn't
|
||||
enabled for its library - never an error), behind a new
|
||||
`GET /api/catalog/item/:id/markers` route, mirroring
|
||||
`getPlaybackOptions`'s own shape exactly.
|
||||
|
||||
**The player side** adds one new floating button, `#skipIntroBtn` -
|
||||
explicit label ("Passer l'intro"), not an icon, matching this app's own
|
||||
established convention for every request-shaped control. It's
|
||||
positioned as a sibling of `#bottomBar`, not inside it, specifically so
|
||||
it stays reachable through the bar's own auto-hide/`.minimal` collapse -
|
||||
someone who hasn't touched the player still needs to see it the moment
|
||||
the intro starts. `updateSkipButton` (ticked every 500ms alongside
|
||||
`updateProgressBar`) compares `activeDriver.currentTime` against the
|
||||
fetched markers and shows/hides it; clicking it seeks straight to
|
||||
`introEndTicks` through the exact same `activeDriver`/`onDriverStateChanged`
|
||||
path any other seek already uses, so it's automatically host-only (a
|
||||
follower's seek never broadcasts - see `sendLocalState`'s own gate) and
|
||||
automatically cast-aware (works identically whether `activeDriver` is
|
||||
the local `<video>` or a Chromecast's `RemotePlayer`) with no new
|
||||
special-casing for either.
|
||||
|
||||
End credits deliberately got no button of their own: `#nextBtn` already
|
||||
does exactly "go to the next queued item" and was already shown
|
||||
whenever `hasNextQueued` is true, for the whole episode - not something
|
||||
this feature needed to duplicate. The only real gap it closes is
|
||||
*visibility*: if nobody's touched the player in a while, `#bottomBar`
|
||||
(and `#nextBtn` inside it) is collapsed to `.minimal` right when
|
||||
credits start. `updateSkipButton` calls the existing `showControls()`
|
||||
once (a `creditsControlsShown` flag keeps it one-shot, not a fight
|
||||
against someone deliberately re-minimizing the bar during a long
|
||||
credits scroll) the moment playback crosses `creditsStartTicks`, so the
|
||||
already-existing button is actually reachable without requiring a mouse
|
||||
nudge first.
|
||||
|
||||
Verified: full `typecheck`/`test`/`build` on both projects (125/125
|
||||
server tests, including new coverage for `getPlaybackMarkers` - the
|
||||
real `MarkerType` values mixed with auto-generated `Chapter` ones, and
|
||||
both "no Chapters field" and "no markers among the Chapters that are
|
||||
there" resolving to nulls rather than throwing). The button's on-screen
|
||||
position was checked directly (`getBoundingClientRect` against the real
|
||||
compiled CSS, not eyeballed) - it sits clear of `#bottomBar` with a
|
||||
10px gap at both a 960×540 and a phone-width viewport, since both use
|
||||
fixed pixel offsets off the same `.playerWrap`. **Not verified**: the
|
||||
detection task had only just been enabled on the real server (confirmed
|
||||
via its own `EnableMarkerDetection`/`ScheduledTasks` state) and hadn't
|
||||
finished analyzing any episode yet at the time this shipped, so the
|
||||
actual end-to-end "does a detected marker make the button appear at
|
||||
the right second" behavior is unconfirmed against real generated data -
|
||||
worth a real check once the task has had time to run.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Playback always goes through `hls.js` against whatever Emby's
|
||||
|
|
|
|||
|
|
@ -212,6 +212,13 @@ app.get('/api/catalog/item/:itemId/playback-options', requireSession, asyncRoute
|
|||
res.json(await CatalogService.getPlaybackOptions(req.embySession!, param(req, 'itemId')));
|
||||
}));
|
||||
|
||||
// Emby's own native intro/credits detection, when the library it's from has
|
||||
// the feature enabled - powers the player's "Passer l'intro"/"Épisode
|
||||
// suivant" buttons. See CatalogService.getPlaybackMarkers.
|
||||
app.get('/api/catalog/item/:itemId/markers', requireSession, asyncRoute(async (req, res) => {
|
||||
res.json(await CatalogService.getPlaybackMarkers(req.embySession!, param(req, 'itemId')));
|
||||
}));
|
||||
|
||||
app.get('/api/catalog/libraries/:libraryId/items', requireSession, asyncRoute(async (req, res) => {
|
||||
res.json({ items: await CatalogService.getLibraryItems(req.embySession!, param(req, 'libraryId')) });
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { Request, Response } from 'express';
|
|||
import { EMBY_SERVER_URL } from '../config';
|
||||
import { EmbyClient } from '../emby/EmbyClient';
|
||||
import { Logger } from '../Logger';
|
||||
import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackOptionsDto, SeasonDto } from '../types';
|
||||
import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackMarkersDto, PlaybackOptionsDto, SeasonDto } from '../types';
|
||||
|
||||
// 'MusicVideo' is Emby's own item type for a musicvideos library's
|
||||
// contents (distinct from the generic 'Video', which is home videos).
|
||||
|
|
@ -272,6 +272,30 @@ class CatalogServiceClass {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emby's own native intro/credits detection - see PlaybackMarkersDto's
|
||||
* own comment for what MarkerType actually looks like and where that
|
||||
* was confirmed. Read from the same Chapters field getRawItem/toItemDto
|
||||
* never request (they have no use for chapters at all) - a dedicated
|
||||
* fetch here rather than folding it into getItem, since a chapter list
|
||||
* is only ever needed by the player deciding whether to show a skip
|
||||
* button, nowhere a plain ItemDto is used.
|
||||
*/
|
||||
async getPlaybackMarkers(session: EmbySession, itemId: string): Promise<PlaybackMarkersDto> {
|
||||
const data = await this.fetchJson<{ Chapters?: Array<{ StartPositionTicks: number; MarkerType?: string }> }>(
|
||||
`/Users/${session.userId}/Items/${itemId}?Fields=Chapters`,
|
||||
session,
|
||||
'Impossible de récupérer les marqueurs de lecture.'
|
||||
);
|
||||
const chapters = data.Chapters || [];
|
||||
const startOf = (markerType: string) => chapters.find((c) => c.MarkerType === markerType)?.StartPositionTicks ?? null;
|
||||
return {
|
||||
introStartTicks: startOf('IntroStart'),
|
||||
introEndTicks: startOf('IntroEnd'),
|
||||
creditsStartTicks: startOf('CreditsStart')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The negotiated MediaSource's id/container/streams - used by
|
||||
* TranscodeService to build its own ffmpeg source URL and translate
|
||||
|
|
|
|||
|
|
@ -219,6 +219,25 @@ export interface PlaybackOptionsDto {
|
|||
subtitleTracks: PlaybackTrackOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Emby's own native intro/credits detection (a library's own
|
||||
* EnableMarkerDetection option, run by its "Detect Episode Intros"
|
||||
* scheduled task - confirmed directly against a real server's
|
||||
* /openapi.json: MarkerType is one of Chapter/IntroStart/IntroEnd/
|
||||
* CreditsStart, mixed into the same Chapters array as the auto-generated
|
||||
* per-5-minutes ones). Every field is nullable, independently - the task
|
||||
* may not have reached this item yet, the library may not have the
|
||||
* feature enabled at all (it's per-library, not global), or a real
|
||||
* cold-open episode may genuinely have no IntroStart/IntroEnd. None of
|
||||
* that is an error - see CatalogService.getPlaybackMarkers - a caller
|
||||
* just checks each field for null before offering the matching skip
|
||||
* button. */
|
||||
export interface PlaybackMarkersDto {
|
||||
introStartTicks: number | null;
|
||||
introEndTicks: number | null;
|
||||
creditsStartTicks: number | null;
|
||||
}
|
||||
|
||||
// --- Party (video) -----------------------------------------------------------
|
||||
|
||||
/** One entry in a video party's fixed, pre-built queue (a movie/standalone
|
||||
|
|
|
|||
|
|
@ -117,6 +117,42 @@ describe('CatalogService', () => {
|
|||
assert.equal(options.subtitleTracks.find((t) => t.index === 4)?.isTextBased, true, 'ass is text too');
|
||||
});
|
||||
|
||||
it('getPlaybackMarkers picks out Intro/Credits markers by MarkerType, ignoring the auto-generated Chapter ones', async (t) => {
|
||||
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
|
||||
Chapters: [
|
||||
{ StartPositionTicks: 0, Name: 'Chapitre 1', MarkerType: 'Chapter' },
|
||||
{ StartPositionTicks: 50000000, MarkerType: 'IntroStart' },
|
||||
{ StartPositionTicks: 900000000, MarkerType: 'IntroEnd' },
|
||||
{ StartPositionTicks: 3000000000, Name: 'Chapitre 2', MarkerType: 'Chapter' },
|
||||
{ StartPositionTicks: 20000000000, MarkerType: 'CreditsStart' }
|
||||
]
|
||||
}));
|
||||
|
||||
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
|
||||
|
||||
assert.equal(markers.introStartTicks, 50000000);
|
||||
assert.equal(markers.introEndTicks, 900000000);
|
||||
assert.equal(markers.creditsStartTicks, 20000000000);
|
||||
});
|
||||
|
||||
it('getPlaybackMarkers returns nulls, not an error, when the item has no detected markers at all', async (t) => {
|
||||
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
|
||||
Chapters: [{ StartPositionTicks: 0, Name: 'Chapitre 1', MarkerType: 'Chapter' }]
|
||||
}));
|
||||
|
||||
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
|
||||
|
||||
assert.deepEqual(markers, { introStartTicks: null, introEndTicks: null, creditsStartTicks: null });
|
||||
});
|
||||
|
||||
it('getPlaybackMarkers returns nulls when the item has no Chapters field at all', async (t) => {
|
||||
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({}));
|
||||
|
||||
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
|
||||
|
||||
assert.deepEqual(markers, { introStartTicks: null, introEndTicks: null, creditsStartTicks: null });
|
||||
});
|
||||
|
||||
it('proxySubtitle asks Emby for this track as WebVTT, using the negotiated MediaSourceId', async (t) => {
|
||||
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src7' }] }));
|
||||
let capturedTarget = '';
|
||||
|
|
|
|||
Loading…
Reference in a new issue