mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
fix(jellyfin): fix remote progress sync, seek reporting, and startup sto
- arm active playback before loadfile with loadedMediaPath: null to suppress premature stop events - force immediate progress report on seek-like position jumps at the mpv time-pos level - send positionTicks and failed=false in reportStopped payload - remove EventName from HTTP timeline payloads (websocket-only field) - add startup grace window to drop stop events before media finishes loading
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: jellyfin
|
||||||
|
|
||||||
|
- Fixed Jellyfin remote controller visibility and progress syncing for mpv/SubMiner seek jumps, stopped sessions, and startup path changes.
|
||||||
@@ -289,6 +289,44 @@ test('reportProgress posts timeline payload and treats failure as non-fatal', as
|
|||||||
assert.deepEqual(JSON.parse(String(timelineCall.init.body)), expectedPostedPayload);
|
assert.deepEqual(JSON.parse(String(timelineCall.init.body)), expectedPostedPayload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('timeline payload omits websocket-only event names', () => {
|
||||||
|
const payload = buildJellyfinTimelinePayload({
|
||||||
|
itemId: 'movie-2',
|
||||||
|
positionTicks: 123456,
|
||||||
|
eventName: 'TimeUpdate',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal('EventName' in payload, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reportStopped posts final position and explicit non-failed state', async () => {
|
||||||
|
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
|
||||||
|
const service = new JellyfinRemoteSessionService({
|
||||||
|
serverUrl: 'http://jellyfin.local',
|
||||||
|
accessToken: 'token-stop-payload',
|
||||||
|
deviceId: 'device-stop-payload',
|
||||||
|
webSocketFactory: () => new FakeWebSocket() as unknown as any,
|
||||||
|
fetchImpl: (async (input, init) => {
|
||||||
|
fetchCalls.push({ input: String(input), init: init ?? {} });
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}) as typeof fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = await service.reportStopped({
|
||||||
|
itemId: 'movie-stop',
|
||||||
|
positionTicks: 7654321,
|
||||||
|
failed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stoppedCall = fetchCalls.find((call) => call.input.endsWith('/Sessions/Playing/Stopped'));
|
||||||
|
assert.equal(ok, true);
|
||||||
|
assert.ok(stoppedCall);
|
||||||
|
assert.ok(typeof stoppedCall.init.body === 'string');
|
||||||
|
const posted = JSON.parse(String(stoppedCall.init.body));
|
||||||
|
assert.equal(posted.PositionTicks, 7654321);
|
||||||
|
assert.equal(posted.Failed, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('advertiseNow validates server registration using Sessions endpoint', async () => {
|
test('advertiseNow validates server registration using Sessions endpoint', async () => {
|
||||||
const sockets: FakeWebSocket[] = [];
|
const sockets: FakeWebSocket[] = [];
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface JellyfinTimelinePlaybackState {
|
|||||||
subtitleStreamIndex?: number | null;
|
subtitleStreamIndex?: number | null;
|
||||||
playlistItemId?: string | null;
|
playlistItemId?: string | null;
|
||||||
eventName?: string;
|
eventName?: string;
|
||||||
|
failed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JellyfinTimelinePayload {
|
export interface JellyfinTimelinePayload {
|
||||||
@@ -36,7 +37,7 @@ export interface JellyfinTimelinePayload {
|
|||||||
AudioStreamIndex?: number | null;
|
AudioStreamIndex?: number | null;
|
||||||
SubtitleStreamIndex?: number | null;
|
SubtitleStreamIndex?: number | null;
|
||||||
PlaylistItemId?: string | null;
|
PlaylistItemId?: string | null;
|
||||||
EventName: string;
|
Failed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JellyfinRemoteSocket {
|
interface JellyfinRemoteSocket {
|
||||||
@@ -168,7 +169,7 @@ export function buildJellyfinTimelinePayload(
|
|||||||
AudioStreamIndex: asNullableInteger(state.audioStreamIndex),
|
AudioStreamIndex: asNullableInteger(state.audioStreamIndex),
|
||||||
SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex),
|
SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex),
|
||||||
PlaylistItemId: state.playlistItemId,
|
PlaylistItemId: state.playlistItemId,
|
||||||
EventName: state.eventName || 'timeupdate',
|
Failed: state.failed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,10 +270,7 @@ export class JellyfinRemoteSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||||
return this.postTimeline('/Sessions/Playing', {
|
return this.postTimeline('/Sessions/Playing', buildJellyfinTimelinePayload(state));
|
||||||
...buildJellyfinTimelinePayload(state),
|
|
||||||
EventName: state.eventName || 'start',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
public async reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||||
@@ -282,7 +280,7 @@ export class JellyfinRemoteSessionService {
|
|||||||
public async reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
public async reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||||
return this.postTimeline('/Sessions/Playing/Stopped', {
|
return this.postTimeline('/Sessions/Playing/Stopped', {
|
||||||
...buildJellyfinTimelinePayload(state),
|
...buildJellyfinTimelinePayload(state),
|
||||||
EventName: state.eventName || 'stop',
|
Failed: state.failed === true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -654,6 +654,7 @@ let jellyfinPlayQuitOnDisconnectArmed = false;
|
|||||||
const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US';
|
const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US';
|
||||||
const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
|
const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
|
||||||
const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000;
|
const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000;
|
||||||
|
const JELLYFIN_REMOTE_STARTUP_STOP_GRACE_MS = 10_000;
|
||||||
const DISCORD_PRESENCE_APP_ID = '1475264834730856619';
|
const DISCORD_PRESENCE_APP_ID = '1475264834730856619';
|
||||||
const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
|
const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
|
||||||
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
|
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
|
||||||
@@ -2968,7 +2969,11 @@ const {
|
|||||||
},
|
},
|
||||||
convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks),
|
convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks),
|
||||||
setActivePlayback: (state) => {
|
setActivePlayback: (state) => {
|
||||||
activeJellyfinRemotePlayback = state as ActiveJellyfinRemotePlaybackState;
|
activeJellyfinRemotePlayback = {
|
||||||
|
...(state as ActiveJellyfinRemotePlaybackState),
|
||||||
|
stopReportsAfterMs:
|
||||||
|
state.stopReportsAfterMs ?? Date.now() + JELLYFIN_REMOTE_STARTUP_STOP_GRACE_MS,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
setLastProgressAtMs: (value) => {
|
setLastProgressAtMs: (value) => {
|
||||||
jellyfinRemoteLastProgressAtMs = value;
|
jellyfinRemoteLastProgressAtMs = value;
|
||||||
@@ -4458,6 +4463,11 @@ const {
|
|||||||
immersionMediaRuntime.syncFromCurrentMediaState();
|
immersionMediaRuntime.syncFromCurrentMediaState();
|
||||||
},
|
},
|
||||||
signalAutoplayReadyIfWarm: (path) => signalAutoplayReadyFromWarmTokenization?.(path),
|
signalAutoplayReadyIfWarm: (path) => signalAutoplayReadyFromWarmTokenization?.(path),
|
||||||
|
markJellyfinRemotePlaybackLoaded: (path) => {
|
||||||
|
if (activeJellyfinRemotePlayback) {
|
||||||
|
activeJellyfinRemotePlayback.loadedMediaPath = path;
|
||||||
|
}
|
||||||
|
},
|
||||||
scheduleCharacterDictionarySync: () => {
|
scheduleCharacterDictionarySync: () => {
|
||||||
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) {
|
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ export function composeJellyfinRemoteHandlers(
|
|||||||
getActivePlayback: options.getActivePlayback,
|
getActivePlayback: options.getActivePlayback,
|
||||||
clearActivePlayback: options.clearActivePlayback,
|
clearActivePlayback: options.clearActivePlayback,
|
||||||
getSession: options.getSession,
|
getSession: options.getSession,
|
||||||
|
getMpvClient: options.getMpvClient,
|
||||||
|
getNow: options.getNow,
|
||||||
|
ticksPerSecond: options.ticksPerSecond,
|
||||||
logDebug: options.logDebug,
|
logDebug: options.logDebug,
|
||||||
});
|
});
|
||||||
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler(
|
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler(
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ test('playback handler drives mpv commands and playback state', async () => {
|
|||||||
assert.equal(activeStates[0]?.playMethod, 'DirectPlay');
|
assert.equal(activeStates[0]?.playMethod, 'DirectPlay');
|
||||||
assert.equal(reportPayloads.length, 1);
|
assert.equal(reportPayloads.length, 1);
|
||||||
assert.equal(reportPayloads[0]?.eventName, 'start');
|
assert.equal(reportPayloads[0]?.eventName, 'start');
|
||||||
|
assert.equal(reportPayloads[0]?.positionTicks, 12_000_000);
|
||||||
|
assert.equal(reportPayloads[0]?.isPaused, false);
|
||||||
assert.deepEqual(statsMetadata, [
|
assert.deepEqual(statsMetadata, [
|
||||||
{
|
{
|
||||||
mediaPath: 'https://stream.example/video.m3u8',
|
mediaPath: 'https://stream.example/video.m3u8',
|
||||||
@@ -180,6 +182,47 @@ test('playback handler publishes Jellyfin title before loading tokenized stream
|
|||||||
assert.equal(timeline[titleIndex]?.includes('api_key'), false);
|
assert.equal(timeline[titleIndex]?.includes('api_key'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('playback handler arms unloaded active playback before loading mpv media', async () => {
|
||||||
|
const timeline: string[] = [];
|
||||||
|
const handler = createPlayJellyfinItemInMpvHandler({
|
||||||
|
ensureMpvConnectedForPlayback: async () => true,
|
||||||
|
getMpvClient: () => ({ connected: true, send: () => {} }),
|
||||||
|
resolvePlaybackPlan: async () => ({
|
||||||
|
url: 'https://stream.example/video.m3u8',
|
||||||
|
mode: 'direct',
|
||||||
|
title: 'Episode 1',
|
||||||
|
itemTitle: 'Episode 1',
|
||||||
|
seriesTitle: null,
|
||||||
|
seasonNumber: null,
|
||||||
|
episodeNumber: null,
|
||||||
|
startTimeTicks: 0,
|
||||||
|
audioStreamIndex: null,
|
||||||
|
subtitleStreamIndex: null,
|
||||||
|
}),
|
||||||
|
applyJellyfinMpvDefaults: () => {},
|
||||||
|
showVisibleOverlay: () => {},
|
||||||
|
sendMpvCommand: (command) => timeline.push(`cmd:${command[0]}`),
|
||||||
|
armQuitOnDisconnect: () => {},
|
||||||
|
schedule: () => {},
|
||||||
|
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||||
|
preloadExternalSubtitles: () => {},
|
||||||
|
setActivePlayback: (state) => timeline.push(`active:${String(state.loadedMediaPath)}`),
|
||||||
|
setLastProgressAtMs: () => {},
|
||||||
|
reportPlaying: () => {},
|
||||||
|
showMpvOsd: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
session: baseSession,
|
||||||
|
clientInfo: baseClientInfo,
|
||||||
|
jellyfinConfig: {},
|
||||||
|
itemId: 'item-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(timeline.indexOf('active:null') >= 0);
|
||||||
|
assert.ok(timeline.indexOf('active:null') < timeline.indexOf('cmd:loadfile'));
|
||||||
|
});
|
||||||
|
|
||||||
test('playback handler applies start override to stream url for remote resume', async () => {
|
test('playback handler applies start override to stream url for remote resume', async () => {
|
||||||
const commands: Array<Array<string | number>> = [];
|
const commands: Array<Array<string | number>> = [];
|
||||||
const handler = createPlayJellyfinItemInMpvHandler({
|
const handler = createPlayJellyfinItemInMpvHandler({
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ type ActivePlaybackState = {
|
|||||||
audioStreamIndex?: number | null;
|
audioStreamIndex?: number | null;
|
||||||
subtitleStreamIndex?: number | null;
|
subtitleStreamIndex?: number | null;
|
||||||
playMethod: 'DirectPlay' | 'Transcode';
|
playMethod: 'DirectPlay' | 'Transcode';
|
||||||
|
loadedMediaPath?: string | null;
|
||||||
|
stopReportsAfterMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type JellyfinPlaybackStatsMetadata = {
|
export type JellyfinPlaybackStatsMetadata = {
|
||||||
@@ -69,6 +71,8 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
|||||||
itemId: string;
|
itemId: string;
|
||||||
mediaSourceId: undefined;
|
mediaSourceId: undefined;
|
||||||
playMethod: 'DirectPlay' | 'Transcode';
|
playMethod: 'DirectPlay' | 'Transcode';
|
||||||
|
positionTicks?: number;
|
||||||
|
isPaused?: boolean;
|
||||||
audioStreamIndex?: number | null;
|
audioStreamIndex?: number | null;
|
||||||
subtitleStreamIndex?: number | null;
|
subtitleStreamIndex?: number | null;
|
||||||
eventName: 'start';
|
eventName: 'start';
|
||||||
@@ -107,6 +111,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
|||||||
deps.applyJellyfinMpvDefaults(mpvClient);
|
deps.applyJellyfinMpvDefaults(mpvClient);
|
||||||
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
||||||
const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride);
|
const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride);
|
||||||
|
const playMethod = plan.mode === 'direct' ? 'DirectPlay' : 'Transcode';
|
||||||
try {
|
try {
|
||||||
deps.updateCurrentMediaTitle?.(plan.title);
|
deps.updateCurrentMediaTitle?.(plan.title);
|
||||||
deps.recordJellyfinPlaybackMetadata?.({
|
deps.recordJellyfinPlaybackMetadata?.({
|
||||||
@@ -121,6 +126,15 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
|||||||
} catch {
|
} catch {
|
||||||
// Best-effort metadata/title hooks must not block playback startup.
|
// Best-effort metadata/title hooks must not block playback startup.
|
||||||
}
|
}
|
||||||
|
deps.setActivePlayback({
|
||||||
|
itemId: params.itemId,
|
||||||
|
mediaSourceId: undefined,
|
||||||
|
audioStreamIndex: plan.audioStreamIndex,
|
||||||
|
subtitleStreamIndex: plan.subtitleStreamIndex,
|
||||||
|
playMethod,
|
||||||
|
loadedMediaPath: null,
|
||||||
|
});
|
||||||
|
deps.setLastProgressAtMs(0);
|
||||||
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']);
|
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']);
|
||||||
if (params.setQuitOnDisconnectArm !== false) {
|
if (params.setQuitOnDisconnectArm !== false) {
|
||||||
deps.armQuitOnDisconnect();
|
deps.armQuitOnDisconnect();
|
||||||
@@ -143,19 +157,12 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
|||||||
itemId: params.itemId,
|
itemId: params.itemId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const playMethod = plan.mode === 'direct' ? 'DirectPlay' : 'Transcode';
|
|
||||||
deps.setActivePlayback({
|
|
||||||
itemId: params.itemId,
|
|
||||||
mediaSourceId: undefined,
|
|
||||||
audioStreamIndex: plan.audioStreamIndex,
|
|
||||||
subtitleStreamIndex: plan.subtitleStreamIndex,
|
|
||||||
playMethod,
|
|
||||||
});
|
|
||||||
deps.setLastProgressAtMs(0);
|
|
||||||
deps.reportPlaying({
|
deps.reportPlaying({
|
||||||
itemId: params.itemId,
|
itemId: params.itemId,
|
||||||
mediaSourceId: undefined,
|
mediaSourceId: undefined,
|
||||||
playMethod,
|
playMethod,
|
||||||
|
positionTicks: startTimeTicks,
|
||||||
|
isPaused: false,
|
||||||
audioStreamIndex: plan.audioStreamIndex,
|
audioStreamIndex: plan.audioStreamIndex,
|
||||||
subtitleStreamIndex: plan.subtitleStreamIndex,
|
subtitleStreamIndex: plan.subtitleStreamIndex,
|
||||||
eventName: 'start',
|
eventName: 'start',
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export type ActiveJellyfinRemotePlaybackState = {
|
|||||||
audioStreamIndex?: number | null;
|
audioStreamIndex?: number | null;
|
||||||
subtitleStreamIndex?: number | null;
|
subtitleStreamIndex?: number | null;
|
||||||
playMethod: 'DirectPlay' | 'Transcode';
|
playMethod: 'DirectPlay' | 'Transcode';
|
||||||
|
loadedMediaPath?: string | null;
|
||||||
|
stopReportsAfterMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type JellyfinSession = {
|
type JellyfinSession = {
|
||||||
|
|||||||
@@ -103,12 +103,16 @@ test('jellyfin remote stopped main deps builder maps callbacks', () => {
|
|||||||
getActivePlayback: () => ({ itemId: 'abc', playMethod: 'DirectPlay' }),
|
getActivePlayback: () => ({ itemId: 'abc', playMethod: 'DirectPlay' }),
|
||||||
clearActivePlayback: () => calls.push('clear'),
|
clearActivePlayback: () => calls.push('clear'),
|
||||||
getSession: () => session as never,
|
getSession: () => session as never,
|
||||||
|
getMpvClient: () => ({ id: 2, currentTimePos: 4 }) as never,
|
||||||
|
ticksPerSecond: 10_000_000,
|
||||||
logDebug: (message) => calls.push(`debug:${message}`),
|
logDebug: (message) => calls.push(`debug:${message}`),
|
||||||
})();
|
})();
|
||||||
|
|
||||||
assert.deepEqual(deps.getActivePlayback(), { itemId: 'abc', playMethod: 'DirectPlay' });
|
assert.deepEqual(deps.getActivePlayback(), { itemId: 'abc', playMethod: 'DirectPlay' });
|
||||||
deps.clearActivePlayback();
|
deps.clearActivePlayback();
|
||||||
assert.equal(deps.getSession(), session);
|
assert.equal(deps.getSession(), session);
|
||||||
|
assert.deepEqual(deps.getMpvClient(), { id: 2, currentTimePos: 4 });
|
||||||
|
assert.equal(deps.ticksPerSecond, 10_000_000);
|
||||||
deps.logDebug('stopped', null);
|
deps.logDebug('stopped', null);
|
||||||
assert.deepEqual(calls, ['clear', 'debug:stopped']);
|
assert.deepEqual(calls, ['clear', 'debug:stopped']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ export function createBuildReportJellyfinRemoteStoppedMainDepsHandler(
|
|||||||
getActivePlayback: () => deps.getActivePlayback(),
|
getActivePlayback: () => deps.getActivePlayback(),
|
||||||
clearActivePlayback: () => deps.clearActivePlayback(),
|
clearActivePlayback: () => deps.clearActivePlayback(),
|
||||||
getSession: () => deps.getSession(),
|
getSession: () => deps.getSession(),
|
||||||
|
getMpvClient: () => deps.getMpvClient(),
|
||||||
|
getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined,
|
||||||
|
ticksPerSecond: deps.ticksPerSecond,
|
||||||
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
|
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,9 +123,61 @@ test('createReportJellyfinRemoteProgressHandler respects debounce interval', asy
|
|||||||
assert.equal(called, false);
|
assert.equal(called, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createReportJellyfinRemoteProgressHandler reports mpv seek jumps during debounce', async () => {
|
||||||
|
let now = 5000;
|
||||||
|
let lastProgressAtMs = 0;
|
||||||
|
let position = 10;
|
||||||
|
const reportPayloads: Array<{ positionTicks: number; eventName: string }> = [];
|
||||||
|
|
||||||
|
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
||||||
|
getActivePlayback: () => ({
|
||||||
|
itemId: 'item-1',
|
||||||
|
playMethod: 'DirectPlay',
|
||||||
|
}),
|
||||||
|
clearActivePlayback: () => {},
|
||||||
|
getSession: () => ({
|
||||||
|
isConnected: () => true,
|
||||||
|
reportProgress: async (payload) => {
|
||||||
|
reportPayloads.push({
|
||||||
|
positionTicks: payload.positionTicks,
|
||||||
|
eventName: payload.eventName,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
reportStopped: async () => {},
|
||||||
|
}),
|
||||||
|
getMpvClient: () => ({
|
||||||
|
currentTimePos: position,
|
||||||
|
requestProperty: async (name: string) => (name === 'pause' ? false : position),
|
||||||
|
}),
|
||||||
|
getNow: () => now,
|
||||||
|
getLastProgressAtMs: () => lastProgressAtMs,
|
||||||
|
setLastProgressAtMs: (value) => {
|
||||||
|
lastProgressAtMs = value;
|
||||||
|
},
|
||||||
|
progressIntervalMs: 3000,
|
||||||
|
ticksPerSecond: 10_000_000,
|
||||||
|
logDebug: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await reportProgress(true);
|
||||||
|
now = 5500;
|
||||||
|
position = 90;
|
||||||
|
await reportProgress(false);
|
||||||
|
|
||||||
|
assert.deepEqual(reportPayloads, [
|
||||||
|
{ positionTicks: 100_000_000, eventName: 'TimeUpdate' },
|
||||||
|
{ positionTicks: 900_000_000, eventName: 'TimeUpdate' },
|
||||||
|
]);
|
||||||
|
assert.equal(lastProgressAtMs, 5500);
|
||||||
|
});
|
||||||
|
|
||||||
test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback', async () => {
|
test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback', async () => {
|
||||||
let cleared = false;
|
let cleared = false;
|
||||||
let stoppedItemId: string | null = null;
|
let stoppedPayload: {
|
||||||
|
itemId: string;
|
||||||
|
positionTicks?: number;
|
||||||
|
failed?: boolean;
|
||||||
|
} | null = null;
|
||||||
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
||||||
getActivePlayback: () => ({
|
getActivePlayback: () => ({
|
||||||
itemId: 'item-2',
|
itemId: 'item-2',
|
||||||
@@ -141,13 +193,96 @@ test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback'
|
|||||||
isConnected: () => true,
|
isConnected: () => true,
|
||||||
reportProgress: async () => {},
|
reportProgress: async () => {},
|
||||||
reportStopped: async (payload) => {
|
reportStopped: async (payload) => {
|
||||||
stoppedItemId = payload.itemId;
|
stoppedPayload = {
|
||||||
|
itemId: payload.itemId,
|
||||||
|
positionTicks: payload.positionTicks,
|
||||||
|
failed: payload.failed,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
getMpvClient: () => ({
|
||||||
|
currentTimePos: 12.5,
|
||||||
|
requestProperty: async () => {
|
||||||
|
throw new Error('unloaded');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ticksPerSecond: 10_000_000,
|
||||||
logDebug: () => {},
|
logDebug: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
await reportStopped();
|
await reportStopped();
|
||||||
assert.equal(stoppedItemId, 'item-2');
|
assert.deepEqual(stoppedPayload, {
|
||||||
|
itemId: 'item-2',
|
||||||
|
positionTicks: 125_000_000,
|
||||||
|
failed: false,
|
||||||
|
});
|
||||||
assert.equal(cleared, true);
|
assert.equal(cleared, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createReportJellyfinRemoteStoppedHandler ignores unloaded active playback', async () => {
|
||||||
|
let cleared = false;
|
||||||
|
let stopped = false;
|
||||||
|
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
||||||
|
getActivePlayback: () =>
|
||||||
|
({
|
||||||
|
itemId: 'item-2',
|
||||||
|
playMethod: 'Transcode',
|
||||||
|
loadedMediaPath: null,
|
||||||
|
}) as never,
|
||||||
|
clearActivePlayback: () => {
|
||||||
|
cleared = true;
|
||||||
|
},
|
||||||
|
getSession: () => ({
|
||||||
|
isConnected: () => true,
|
||||||
|
reportProgress: async () => {},
|
||||||
|
reportStopped: async () => {
|
||||||
|
stopped = true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getMpvClient: () => ({
|
||||||
|
currentTimePos: 0,
|
||||||
|
}),
|
||||||
|
ticksPerSecond: 10_000_000,
|
||||||
|
logDebug: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await reportStopped();
|
||||||
|
|
||||||
|
assert.equal(stopped, false);
|
||||||
|
assert.equal(cleared, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before grace expires', async () => {
|
||||||
|
let cleared = false;
|
||||||
|
let stopped = false;
|
||||||
|
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
||||||
|
getActivePlayback: () =>
|
||||||
|
({
|
||||||
|
itemId: 'item-2',
|
||||||
|
playMethod: 'DirectPlay',
|
||||||
|
loadedMediaPath: 'https://stream.example/video.m3u8',
|
||||||
|
stopReportsAfterMs: 20_000,
|
||||||
|
}) as never,
|
||||||
|
clearActivePlayback: () => {
|
||||||
|
cleared = true;
|
||||||
|
},
|
||||||
|
getSession: () => ({
|
||||||
|
isConnected: () => true,
|
||||||
|
reportProgress: async () => {},
|
||||||
|
reportStopped: async () => {
|
||||||
|
stopped = true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getMpvClient: () => ({
|
||||||
|
currentTimePos: 0,
|
||||||
|
}),
|
||||||
|
getNow: () => 12_000,
|
||||||
|
ticksPerSecond: 10_000_000,
|
||||||
|
logDebug: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await reportStopped();
|
||||||
|
|
||||||
|
assert.equal(stopped, false);
|
||||||
|
assert.equal(cleared, false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ type JellyfinRemoteSessionLike = {
|
|||||||
playMethod: 'DirectPlay' | 'Transcode';
|
playMethod: 'DirectPlay' | 'Transcode';
|
||||||
audioStreamIndex?: number | null;
|
audioStreamIndex?: number | null;
|
||||||
subtitleStreamIndex?: number | null;
|
subtitleStreamIndex?: number | null;
|
||||||
eventName: 'timeupdate';
|
eventName: 'TimeUpdate';
|
||||||
}) => Promise<unknown>;
|
}) => Promise<unknown>;
|
||||||
reportStopped: (payload: {
|
reportStopped: (payload: {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
mediaSourceId?: string;
|
mediaSourceId?: string;
|
||||||
|
positionTicks?: number;
|
||||||
|
failed?: boolean;
|
||||||
playMethod: 'DirectPlay' | 'Transcode';
|
playMethod: 'DirectPlay' | 'Transcode';
|
||||||
audioStreamIndex?: number | null;
|
audioStreamIndex?: number | null;
|
||||||
subtitleStreamIndex?: number | null;
|
subtitleStreamIndex?: number | null;
|
||||||
@@ -23,7 +25,8 @@ type JellyfinRemoteSessionLike = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type MpvClientLike = {
|
type MpvClientLike = {
|
||||||
requestProperty: (name: string) => Promise<unknown>;
|
currentTimePos?: number;
|
||||||
|
requestProperty?: (name: string) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number): number {
|
export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number): number {
|
||||||
@@ -44,6 +47,45 @@ function isMpvPauseEnabled(value: unknown): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMpvPositionSeconds(value: unknown): number {
|
||||||
|
const seconds = Number(value);
|
||||||
|
if (!Number.isFinite(seconds)) return 0;
|
||||||
|
return Math.max(0, seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedMpvPositionSeconds(client: MpvClientLike | null): number | null {
|
||||||
|
if (!client) return null;
|
||||||
|
const seconds = Number(client.currentTimePos);
|
||||||
|
return Number.isFinite(seconds) ? Math.max(0, seconds) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readMpvPositionSeconds(client: MpvClientLike | null): Promise<number> {
|
||||||
|
const cached = getCachedMpvPositionSeconds(client);
|
||||||
|
if (cached !== null) return cached;
|
||||||
|
const position = await client?.requestProperty?.('time-pos');
|
||||||
|
return normalizeMpvPositionSeconds(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readMpvPositionSecondsOrFallback(
|
||||||
|
client: MpvClientLike | null,
|
||||||
|
fallback = 0,
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
return await readMpvPositionSeconds(client);
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSeekLikePositionJump(
|
||||||
|
previousPositionSeconds: number | null,
|
||||||
|
nextPositionSeconds: number,
|
||||||
|
thresholdSeconds: number,
|
||||||
|
): boolean {
|
||||||
|
if (previousPositionSeconds === null) return false;
|
||||||
|
return Math.abs(nextPositionSeconds - previousPositionSeconds) >= thresholdSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
export type JellyfinRemoteProgressReporterDeps = {
|
export type JellyfinRemoteProgressReporterDeps = {
|
||||||
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
||||||
clearActivePlayback: () => void;
|
clearActivePlayback: () => void;
|
||||||
@@ -60,29 +102,41 @@ export type JellyfinRemoteProgressReporterDeps = {
|
|||||||
export function createReportJellyfinRemoteProgressHandler(
|
export function createReportJellyfinRemoteProgressHandler(
|
||||||
deps: JellyfinRemoteProgressReporterDeps,
|
deps: JellyfinRemoteProgressReporterDeps,
|
||||||
) {
|
) {
|
||||||
|
let lastReportedPositionSeconds: number | null = null;
|
||||||
|
|
||||||
return async (force = false): Promise<void> => {
|
return async (force = false): Promise<void> => {
|
||||||
const playback = deps.getActivePlayback();
|
const playback = deps.getActivePlayback();
|
||||||
if (!playback) return;
|
if (!playback) return;
|
||||||
const session = deps.getSession();
|
const session = deps.getSession();
|
||||||
if (!session || !session.isConnected()) return;
|
if (!session || !session.isConnected()) return;
|
||||||
const now = deps.getNow();
|
const now = deps.getNow();
|
||||||
if (!force && now - deps.getLastProgressAtMs() < deps.progressIntervalMs) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const mpvClient = deps.getMpvClient();
|
const mpvClient = deps.getMpvClient();
|
||||||
const position = await mpvClient?.requestProperty('time-pos');
|
const positionSeconds = await readMpvPositionSeconds(mpvClient);
|
||||||
const paused = await mpvClient?.requestProperty('pause');
|
const forceForSeekJump = isSeekLikePositionJump(
|
||||||
|
lastReportedPositionSeconds,
|
||||||
|
positionSeconds,
|
||||||
|
Math.max(2, deps.progressIntervalMs / 1000),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!force &&
|
||||||
|
!forceForSeekJump &&
|
||||||
|
now - deps.getLastProgressAtMs() < deps.progressIntervalMs
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const paused = await mpvClient?.requestProperty?.('pause');
|
||||||
await session.reportProgress({
|
await session.reportProgress({
|
||||||
itemId: playback.itemId,
|
itemId: playback.itemId,
|
||||||
mediaSourceId: playback.mediaSourceId,
|
mediaSourceId: playback.mediaSourceId,
|
||||||
positionTicks: secondsToJellyfinTicks(Number(position) || 0, deps.ticksPerSecond),
|
positionTicks: secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond),
|
||||||
isPaused: isMpvPauseEnabled(paused),
|
isPaused: isMpvPauseEnabled(paused),
|
||||||
playMethod: playback.playMethod,
|
playMethod: playback.playMethod,
|
||||||
audioStreamIndex: playback.audioStreamIndex,
|
audioStreamIndex: playback.audioStreamIndex,
|
||||||
subtitleStreamIndex: playback.subtitleStreamIndex,
|
subtitleStreamIndex: playback.subtitleStreamIndex,
|
||||||
eventName: 'timeupdate',
|
eventName: 'TimeUpdate',
|
||||||
});
|
});
|
||||||
|
lastReportedPositionSeconds = positionSeconds;
|
||||||
deps.setLastProgressAtMs(now);
|
deps.setLastProgressAtMs(now);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
deps.logDebug('Failed to report Jellyfin remote progress', error);
|
deps.logDebug('Failed to report Jellyfin remote progress', error);
|
||||||
@@ -94,6 +148,9 @@ export type JellyfinRemoteStoppedReporterDeps = {
|
|||||||
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
||||||
clearActivePlayback: () => void;
|
clearActivePlayback: () => void;
|
||||||
getSession: () => JellyfinRemoteSessionLike | null;
|
getSession: () => JellyfinRemoteSessionLike | null;
|
||||||
|
getMpvClient: () => MpvClientLike | null;
|
||||||
|
getNow?: () => number;
|
||||||
|
ticksPerSecond: number;
|
||||||
logDebug: (message: string, error: unknown) => void;
|
logDebug: (message: string, error: unknown) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,15 +158,26 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
|||||||
return async (): Promise<void> => {
|
return async (): Promise<void> => {
|
||||||
const playback = deps.getActivePlayback();
|
const playback = deps.getActivePlayback();
|
||||||
if (!playback) return;
|
if (!playback) return;
|
||||||
|
if (playback.loadedMediaPath === null) return;
|
||||||
|
if (
|
||||||
|
typeof playback.stopReportsAfterMs === 'number' &&
|
||||||
|
Number.isFinite(playback.stopReportsAfterMs) &&
|
||||||
|
(deps.getNow?.() ?? Date.now()) < playback.stopReportsAfterMs
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const session = deps.getSession();
|
const session = deps.getSession();
|
||||||
if (!session || !session.isConnected()) {
|
if (!session || !session.isConnected()) {
|
||||||
deps.clearActivePlayback();
|
deps.clearActivePlayback();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
const positionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient());
|
||||||
await session.reportStopped({
|
await session.reportStopped({
|
||||||
itemId: playback.itemId,
|
itemId: playback.itemId,
|
||||||
mediaSourceId: playback.mediaSourceId,
|
mediaSourceId: playback.mediaSourceId,
|
||||||
|
positionTicks: secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond),
|
||||||
|
failed: false,
|
||||||
playMethod: playback.playMethod,
|
playMethod: playback.playMethod,
|
||||||
audioStreamIndex: playback.audioStreamIndex,
|
audioStreamIndex: playback.audioStreamIndex,
|
||||||
subtitleStreamIndex: playback.subtitleStreamIndex,
|
subtitleStreamIndex: playback.subtitleStreamIndex,
|
||||||
|
|||||||
@@ -168,6 +168,28 @@ test('media path change handler signals autoplay readiness from warm media path'
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('media path change handler marks Jellyfin remote playback loaded from media path', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const handler = createHandleMpvMediaPathChangeHandler({
|
||||||
|
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||||
|
reportJellyfinRemoteStopped: () => calls.push('stopped'),
|
||||||
|
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||||
|
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||||
|
getCurrentAnilistMediaKey: () => null,
|
||||||
|
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
|
||||||
|
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||||
|
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
||||||
|
syncImmersionMediaState: () => calls.push('sync'),
|
||||||
|
markJellyfinRemotePlaybackLoaded: (path) => calls.push(`jellyfin-loaded:${path}`),
|
||||||
|
refreshDiscordPresence: () => calls.push('presence'),
|
||||||
|
});
|
||||||
|
|
||||||
|
handler({ path: 'https://stream.example/video.m3u8' });
|
||||||
|
|
||||||
|
assert.ok(calls.includes('jellyfin-loaded:https://stream.example/video.m3u8'));
|
||||||
|
assert.equal(calls.includes('stopped'), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('media title change handler clears guess state without re-scheduling character dictionary sync', () => {
|
test('media title change handler clears guess state without re-scheduling character dictionary sync', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const deps: Parameters<typeof createHandleMpvMediaTitleChangeHandler>[0] & {
|
const deps: Parameters<typeof createHandleMpvMediaTitleChangeHandler>[0] & {
|
||||||
@@ -222,6 +244,36 @@ test('time-pos and pause handlers report progress with correct urgency', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('time-pos handler forces Jellyfin progress when mpv position jumps', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const timeHandler = createHandleMpvTimePosChangeHandler({
|
||||||
|
recordPlaybackPosition: (time) => calls.push(`time:${time}`),
|
||||||
|
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
|
||||||
|
refreshDiscordPresence: () => calls.push('presence'),
|
||||||
|
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
timeHandler({ time: 10 });
|
||||||
|
timeHandler({ time: 11 });
|
||||||
|
timeHandler({ time: 90 });
|
||||||
|
timeHandler({ time: 30 });
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'time:10',
|
||||||
|
'progress:normal',
|
||||||
|
'presence',
|
||||||
|
'time:11',
|
||||||
|
'progress:normal',
|
||||||
|
'presence',
|
||||||
|
'time:90',
|
||||||
|
'progress:force',
|
||||||
|
'presence',
|
||||||
|
'time:30',
|
||||||
|
'progress:force',
|
||||||
|
'presence',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('time-pos handler passes fresh playback time to AniList post-watch', async () => {
|
test('time-pos handler passes fresh playback time to AniList post-watch', async () => {
|
||||||
const watchedSeconds: unknown[] = [];
|
const watchedSeconds: unknown[] = [];
|
||||||
const timeHandler = createHandleMpvTimePosChangeHandler({
|
const timeHandler = createHandleMpvTimePosChangeHandler({
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ type AnilistPostWatchRunOptions = {
|
|||||||
watchedSeconds?: number;
|
watchedSeconds?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5;
|
||||||
|
|
||||||
|
function isSeekLikeTimeChange(previousTime: number | null, nextTime: number): boolean {
|
||||||
|
if (previousTime === null || !Number.isFinite(previousTime) || !Number.isFinite(nextTime)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Math.abs(nextTime - previousTime) >= SEEK_LIKE_TIME_DELTA_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
export function createHandleMpvSubtitleChangeHandler(deps: {
|
export function createHandleMpvSubtitleChangeHandler(deps: {
|
||||||
setCurrentSubText: (text: string) => void;
|
setCurrentSubText: (text: string) => void;
|
||||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||||
@@ -59,6 +68,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
|
|||||||
syncImmersionMediaState: () => void;
|
syncImmersionMediaState: () => void;
|
||||||
scheduleCharacterDictionarySync?: () => void;
|
scheduleCharacterDictionarySync?: () => void;
|
||||||
signalAutoplayReadyIfWarm?: (path: string) => void;
|
signalAutoplayReadyIfWarm?: (path: string) => void;
|
||||||
|
markJellyfinRemotePlaybackLoaded?: (path: string) => void;
|
||||||
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
|
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
|
||||||
refreshDiscordPresence: () => void;
|
refreshDiscordPresence: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -81,6 +91,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
|
|||||||
}
|
}
|
||||||
deps.syncImmersionMediaState();
|
deps.syncImmersionMediaState();
|
||||||
if (normalizedPath.trim().length > 0) {
|
if (normalizedPath.trim().length > 0) {
|
||||||
|
deps.markJellyfinRemotePlaybackLoaded?.(normalizedPath);
|
||||||
deps.scheduleCharacterDictionarySync?.();
|
deps.scheduleCharacterDictionarySync?.();
|
||||||
deps.signalAutoplayReadyIfWarm?.(normalizedPath);
|
deps.signalAutoplayReadyIfWarm?.(normalizedPath);
|
||||||
}
|
}
|
||||||
@@ -113,9 +124,15 @@ export function createHandleMpvTimePosChangeHandler(deps: {
|
|||||||
logError?: (message: string, error: unknown) => void;
|
logError?: (message: string, error: unknown) => void;
|
||||||
onTimePosUpdate?: (time: number) => void;
|
onTimePosUpdate?: (time: number) => void;
|
||||||
}) {
|
}) {
|
||||||
|
let lastObservedTime: number | null = null;
|
||||||
|
|
||||||
return ({ time }: { time: number }): void => {
|
return ({ time }: { time: number }): void => {
|
||||||
|
const forceImmediate = isSeekLikeTimeChange(lastObservedTime, time);
|
||||||
|
if (Number.isFinite(time)) {
|
||||||
|
lastObservedTime = time;
|
||||||
|
}
|
||||||
deps.recordPlaybackPosition(time);
|
deps.recordPlaybackPosition(time);
|
||||||
deps.reportJellyfinRemoteProgress(false);
|
deps.reportJellyfinRemoteProgress(forceImmediate);
|
||||||
deps.refreshDiscordPresence();
|
deps.refreshDiscordPresence();
|
||||||
void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => {
|
void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => {
|
||||||
deps.logError?.('AniList post-watch update failed unexpectedly', error);
|
deps.logError?.('AniList post-watch update failed unexpectedly', error);
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
ensureAnilistMediaGuess: (mediaKey: string) => void;
|
ensureAnilistMediaGuess: (mediaKey: string) => void;
|
||||||
syncImmersionMediaState: () => void;
|
syncImmersionMediaState: () => void;
|
||||||
signalAutoplayReadyIfWarm?: (path: string) => void;
|
signalAutoplayReadyIfWarm?: (path: string) => void;
|
||||||
|
markJellyfinRemotePlaybackLoaded?: (path: string) => void;
|
||||||
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
|
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
|
||||||
|
|
||||||
updateCurrentMediaTitle: (title: string) => void;
|
updateCurrentMediaTitle: (title: string) => void;
|
||||||
@@ -142,6 +143,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
flushPlaybackPositionOnMediaPathClear: (mediaPath) =>
|
flushPlaybackPositionOnMediaPathClear: (mediaPath) =>
|
||||||
deps.flushPlaybackPositionOnMediaPathClear?.(mediaPath),
|
deps.flushPlaybackPositionOnMediaPathClear?.(mediaPath),
|
||||||
signalAutoplayReadyIfWarm: (path) => deps.signalAutoplayReadyIfWarm?.(path),
|
signalAutoplayReadyIfWarm: (path) => deps.signalAutoplayReadyIfWarm?.(path),
|
||||||
|
markJellyfinRemotePlaybackLoaded: (path) => deps.markJellyfinRemotePlaybackLoaded?.(path),
|
||||||
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
|
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
|
||||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
ensureAnilistMediaGuess: (mediaKey: string) => void;
|
ensureAnilistMediaGuess: (mediaKey: string) => void;
|
||||||
syncImmersionMediaState: () => void;
|
syncImmersionMediaState: () => void;
|
||||||
signalAutoplayReadyIfWarm?: (path: string) => void;
|
signalAutoplayReadyIfWarm?: (path: string) => void;
|
||||||
|
markJellyfinRemotePlaybackLoaded?: (path: string) => void;
|
||||||
scheduleCharacterDictionarySync?: () => void;
|
scheduleCharacterDictionarySync?: () => void;
|
||||||
updateCurrentMediaTitle: (title: string) => void;
|
updateCurrentMediaTitle: (title: string) => void;
|
||||||
resetAnilistMediaGuessState: () => void;
|
resetAnilistMediaGuessState: () => void;
|
||||||
@@ -178,6 +179,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
|
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
|
||||||
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
|
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
|
||||||
signalAutoplayReadyIfWarm: (path: string) => deps.signalAutoplayReadyIfWarm?.(path),
|
signalAutoplayReadyIfWarm: (path: string) => deps.signalAutoplayReadyIfWarm?.(path),
|
||||||
|
markJellyfinRemotePlaybackLoaded: (path: string) =>
|
||||||
|
deps.markJellyfinRemotePlaybackLoaded?.(path),
|
||||||
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
|
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
|
||||||
updateCurrentMediaTitle: (title: string) => deps.updateCurrentMediaTitle(title),
|
updateCurrentMediaTitle: (title: string) => deps.updateCurrentMediaTitle(title),
|
||||||
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
|
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
|
||||||
|
|||||||
Reference in New Issue
Block a user