fix(jellyfin): show overlay, inject plugin, and fix stats title on playback (#77)

* fix(jellyfin): show overlay, inject plugin, and fix stats title on playb

- Show visible overlay automatically during Jellyfin playback so subtitleStyle applies
- Inject bundled mpv plugin on auto-launch so keybindings work without overlay focus
- Group Jellyfin playback stats under item metadata (jellyfin://host/item/id) instead of stream URLs so episodes merge with matching local titles
- Mark ffsubsync unavailable in subsync modal for remote media paths
- Drain queued second-instance commands even when onReady throws

* fix(overlay): stabilize macOS focus handoff and sidebar Yomitan pause

- Keep overlay visible during macOS foreground probe after overlay blur
- Hold sidebar hover-pause while a Yomitan lookup popup remains open

* fix(jellyfin): fix discovery loop, device identity, tray state, and Disc

- Derive device identity from OS hostname; remove legacy configurable client/device fields
- Prevent discovery playback from reloading active item, misreporting pause state, and duplicate overlay restores
- Restart stale tray discovery sessions without re-login when server drops SubMiner cast target
- Sync tray discovery checkbox state on Linux after CLI/startup/remote-session changes
- Stop Discord presence falling back to stream URLs; prime title before tokenized stream loads
- Fix picker library discovery when log level is above info
- Fix config.example.jsonc trailing commas and array formatting

* docs(release): trim and consolidate prerelease notes for 0.15.0

- Remove breaking changes section and several redundant bullet points
- Consolidate per-platform updater notes into a single entry
- Normalize em-dash separators to hyphens in section headers

* fix(config): remove trailing commas from config.example.jsonc

- Strip trailing commas throughout both config.example.jsonc copies
- Reformat inline arrays to multi-line for JSON strictness
- Update Jellyfin subtitle preload and playback launch tests and impl

* fix(tokenizer): preserve known-word highlight when POS filters suppress

- Known-word cache matches now set isKnown=true even for tokens excluded by POS filters
- POS exclusion gate suppresses N+1, frequency, and JLPT only; known status is computed before the gate
- Jellyfin subtitle preload continues after cleanup failures instead of aborting
- Update config docs and option description to document the known-word bypass behavior

* fix(jellyfin): send explicit hide/show overlay instead of toggle

- Track overlay visibility in plugin state; y-t uses explicit hide/show commands when state is known
- Prevent paused Jellyfin playback from resuming on overlay hide
- Fix subtitle cache cleanup to only remove dirs after successful cleanup

* 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

* fix(jellyfin): fix overlay toggle sync, redirect reload, and AppImage bi

- Sync visible-overlay state back to plugin via script messages to avoid toggle/hide drift
- Collapse duplicate toggle events within 250ms to prevent hide-then-show on single keypress
- Preserve manual hide across Jellyfin path-changing redirects even when media-title drops
- Rearm managed subtitle defaults on path-changing redirects
- Route toggleVisibleOverlay session binding through plugin toggle instead of app-side IPC
- Show Linux/Hyprland overlay passively (showInactive) to avoid stealing mpv keyboard focus
- Fix AppImage binary resolution to prefer $APPIMAGE env over mounted inner binary
- Add stats window layer management so delete/update dialogs appear above stats window
- Fix Jellyfin remote progress sync during Linux websocket reconnect windows

* Fix CodeRabbit review feedback

* fix(jellyfin): subtitle timing, resume progress, and overlay sync

- Add per-stream subtitle delay persistence and auto timeline-offset correction
- Strip server-selected subtitle stream from mpv load URL; suppress plugin subtitle rearm and auto-start during app-managed preload
- Fix resume position lost when mpv resets on stop; use last known position for final progress/stopped reports
- Keep Play vs Resume distinct to avoid early seek race on normal play
- Fix discovery resume when remote play sends StartPositionTicks=0 despite saved progress
- Deduplicate show/hide overlay commands using recorded visibility state
- Rewrite docs-site Jellyfin page around cast-to-device UX

* test: update lifecycle cleanup assertion

* fix: clear aborted playback state, fix overlay passthrough, and guard du

- Reset app_managed_playback_pending on lifecycle cleanup to prevent state leak into next item
- Record visible overlay action only after command succeeds, not before
- Non-native passive overlay now always click-through on re-show (fix isNonNativePassiveOverlay ordering)
- Defer activeParsedSubtitleMediaPath assignment until after prefetch completes
- Move autoplay gate release into the hide branch of toggleVisibleOverlay
- Clear active Jellyfin playback when stopping media that never loaded
- Reset managed subtitle delay and delay key when no external tracks are available
- Await async removeDir in subtitle cache cleanup
- Guard duplicate delete clicks in MediaDetailView and SessionsTab with refs
- Escape key in DeleteConfirmDialog now calls stopPropagation and stopImmediatePropagation
This commit is contained in:
2026-05-24 18:40:56 -07:00
committed by GitHub
parent da3c971ee6
commit b1bdeabca8
193 changed files with 7975 additions and 771 deletions
+32
View File
@@ -1008,6 +1008,38 @@ test('visible-layer y-t dispatches mpv plugin toggle while overlay owns focus',
}
});
test('visible-layer configured overlay toggle dispatches mpv plugin toggle', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.toggleVisibleOverlayGlobal',
originalKey: 'Alt+Shift+O',
key: { code: 'KeyO', modifiers: ['alt', 'shift'] },
actionType: 'session-action',
actionId: 'toggleVisibleOverlay',
},
] as never);
testGlobals.dispatchKeydown({ key: 'O', code: 'KeyO', altKey: true, shiftKey: true });
assert.equal(
testGlobals.mpvCommands.some(
(command) => command[0] === 'script-message' && command[1] === 'subminer-toggle',
),
true,
);
assert.equal(
testGlobals.sessionActions.some((action) => action.actionId === 'toggleVisibleOverlay'),
false,
);
} finally {
testGlobals.restore();
}
});
test('refreshConfiguredShortcuts updates hot-reloaded stats and watched keys', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
+5
View File
@@ -207,6 +207,11 @@ export function createKeyboardHandlers(
return;
}
if (binding.actionType === 'session-action' && binding.actionId === 'toggleVisibleOverlay') {
window.electronAPI.sendMpvCommand(['script-message', 'subminer-toggle']);
return;
}
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerSelect') {
options.openControllerSelectModal?.();
return;
+46
View File
@@ -83,6 +83,7 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
const subsyncEngineFfsubsync = {
checked: false,
disabled: false,
addEventListener: engineFfsubsyncEvents.addEventListener,
dispatch: engineFfsubsyncEvents.dispatch,
};
@@ -194,6 +195,7 @@ test('manual subsync failure closes during run, then reopens modal with error',
harness.modal.wireDomEvents();
harness.modal.openSubsyncModal({
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
ffsubsyncAvailable: true,
});
harness.runButton.dispatch('click');
@@ -224,3 +226,47 @@ test('manual subsync failure closes during run, then reopens modal with error',
harness.restoreGlobals();
}
});
test('subsync modal disables ffsubsync when payload marks it unavailable', () => {
const harness = createTestHarness(async () => ({ ok: true, message: 'ok' }));
try {
harness.modal.openSubsyncModal({
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
ffsubsyncAvailable: false,
});
assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true);
assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.checked, false);
assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.disabled, true);
assert.equal(harness.ctx.dom.subsyncStatus.textContent, 'Choose alass source, then run.');
} finally {
harness.restoreGlobals();
}
});
test('subsync modal ignores enter submission when no sync engine is available', async () => {
let runCalls = 0;
const harness = createTestHarness(async () => {
runCalls += 1;
return { ok: true, message: 'ok' };
});
try {
harness.modal.openSubsyncModal({
sourceTracks: [],
ffsubsyncAvailable: false,
});
harness.modal.handleSubsyncKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent);
await flushMicrotasks();
assert.equal(runCalls, 0);
assert.equal(harness.ctx.state.subsyncModalOpen, true);
} finally {
harness.restoreGlobals();
}
});
+24 -8
View File
@@ -8,6 +8,8 @@ export function createSubsyncModal(
syncSettingsModalSubtitleSuppression: () => void;
},
) {
let ffsubsyncAvailable = true;
function setSubsyncStatus(message: string, isError = false): void {
ctx.dom.subsyncStatus.textContent = message;
ctx.dom.subsyncStatus.classList.toggle('error', isError);
@@ -46,20 +48,26 @@ export function createSubsyncModal(
function openSubsyncModal(payload: SubsyncManualPayload): void {
ctx.state.subsyncSubmitting = false;
ctx.dom.subsyncRunButton.disabled = false;
ctx.state.subsyncSourceTracks = payload.sourceTracks;
ffsubsyncAvailable = payload.ffsubsyncAvailable;
const hasSources = ctx.state.subsyncSourceTracks.length > 0;
ctx.dom.subsyncEngineAlass.checked = hasSources;
ctx.dom.subsyncEngineFfsubsync.checked = !hasSources;
ctx.dom.subsyncEngineFfsubsync.checked = !hasSources && ffsubsyncAvailable;
ctx.dom.subsyncEngineFfsubsync.disabled = !ffsubsyncAvailable;
ctx.dom.subsyncRunButton.disabled = !hasSources && !ffsubsyncAvailable;
renderSubsyncSourceTracks();
updateSubsyncSourceVisibility();
setSubsyncStatus(
hasSources
? 'Choose engine and source, then run.'
: 'No source subtitles available for alass. Use ffsubsync.',
!ffsubsyncAvailable && hasSources
? 'Choose alass source, then run.'
: !ffsubsyncAvailable
? 'No source subtitles available for alass.'
: hasSources
? 'Choose engine and source, then run.'
: 'No source subtitles available for alass. Use ffsubsync.',
false,
);
@@ -77,7 +85,7 @@ export function createSubsyncModal(
sourceTrackId: number | null,
message: string,
): void {
openSubsyncModal({ sourceTracks });
openSubsyncModal({ sourceTracks, ffsubsyncAvailable });
if (engine === 'alass' && sourceTracks.length > 0) {
ctx.dom.subsyncEngineAlass.checked = true;
@@ -85,7 +93,7 @@ export function createSubsyncModal(
if (Number.isFinite(sourceTrackId)) {
ctx.dom.subsyncSourceSelect.value = String(sourceTrackId);
}
} else {
} else if (ffsubsyncAvailable) {
ctx.dom.subsyncEngineAlass.checked = false;
ctx.dom.subsyncEngineFfsubsync.checked = true;
}
@@ -97,8 +105,16 @@ export function createSubsyncModal(
async function runSubsyncManualFromModal(): Promise<void> {
if (ctx.state.subsyncSubmitting) return;
if (ctx.dom.subsyncRunButton.disabled) return;
const engine = ctx.dom.subsyncEngineAlass.checked ? 'alass' : 'ffsubsync';
const useAlass = ctx.dom.subsyncEngineAlass.checked;
const useFfsubsync = ctx.dom.subsyncEngineFfsubsync.checked;
if (!useAlass && !useFfsubsync) {
setSubsyncStatus('No sync engine available for current media.', true);
return;
}
const engine = useAlass ? 'alass' : 'ffsubsync';
const sourceTrackId =
engine === 'alass' && ctx.dom.subsyncSourceSelect.value
? Number.parseInt(ctx.dom.subsyncSourceSelect.value, 10)
@@ -8,6 +8,7 @@ import {
createSubtitleSidebarModal,
findActiveSubtitleCueIndex,
} from './subtitle-sidebar.js';
import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
@@ -1542,6 +1543,137 @@ test('subtitle sidebar hover pause ignores playback-state IPC failures', async (
}
});
test('subtitle sidebar keeps hover pause while a Yomitan lookup popup remains open', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const mpvCommands: Array<Array<string | number>> = [];
const contentListeners = new Map<string, Array<() => Promise<void> | void>>();
const windowListeners = new Map<string, Array<() => Promise<void> | void>>();
const snapshot: SubtitleSidebarSnapshot = {
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
startTime: 1,
endTime: 2,
},
currentTimeSec: 1.1,
config: {
enabled: true,
autoOpen: false,
layout: 'overlay',
toggleKey: 'Backslash',
pauseVideoOnHover: true,
autoScroll: true,
maxWidth: 420,
opacity: 0.92,
backgroundColor: 'rgba(54, 58, 79, 0.88)',
textColor: '#cad3f5',
fontFamily: '"Iosevka Aile", sans-serif',
fontSize: 17,
timestampColor: '#a5adcb',
activeLineColor: '#f5bde6',
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
},
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
addEventListener: (type: string, listener: () => Promise<void> | void) => {
const bucket = windowListeners.get(type) ?? [];
bucket.push(listener);
windowListeners.set(type, bucket);
},
removeEventListener: () => {},
electronAPI: {
getSubtitleSidebarSnapshot: async () => snapshot,
getPlaybackPaused: async () => false,
sendMpvCommand: (command: Array<string | number>) => {
mpvCommands.push(command);
},
} as unknown as ElectronAPI,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createCueRow(),
body: {
classList: createClassList(),
},
documentElement: {
style: {
setProperty: () => {},
},
},
},
});
try {
const state = createRendererState();
state.autoPauseVideoOnYomitanPopup = true;
const ctx = {
dom: {
overlay: { classList: createClassList() },
subtitleSidebarModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
style: { setProperty: () => {} },
addEventListener: () => {},
},
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 420 }),
addEventListener: (type: string, listener: () => Promise<void> | void) => {
const bucket = contentListeners.get(type) ?? [];
bucket.push(listener);
contentListeners.set(type, bucket);
},
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
subtitleSidebarList: createListStub(),
},
state,
};
const modal = createSubtitleSidebarModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
});
modal.wireDomEvents();
await modal.openSubtitleSidebarModal();
mpvCommands.length = 0;
await contentListeners.get('mouseenter')?.[0]?.();
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) {
await listener();
}
await contentListeners.get('mouseleave')?.[0]?.();
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
assert.equal(state.subtitleSidebarPausedByHover, true);
for (const listener of windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) {
await listener();
}
assert.deepEqual(mpvCommands, [
['set_property', 'pause', 'yes'],
['set_property', 'pause', 'no'],
]);
assert.equal(state.subtitleSidebarPausedByHover, false);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('subtitle sidebar embedded layout reserves and releases mpv right margin', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
+58
View File
@@ -1,6 +1,11 @@
import type { SubtitleCue, SubtitleData, SubtitleSidebarSnapshot } from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
import {
YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_SHOWN_EVENT,
isYomitanPopupVisible,
} from '../yomitan-popup.js';
const MANUAL_SCROLL_HOLD_MS = 1500;
const ACTIVE_CUE_LOOKAHEAD_SEC = 0.18;
@@ -194,6 +199,8 @@ export function createSubtitleSidebarModal(
let disposeDomEvents: (() => void) | null = null;
let subtitleSidebarHovered = false;
let subtitleSidebarFocusedWithin = false;
let subtitleSidebarYomitanPopupVisible = false;
let subtitleSidebarPauseHeldByYomitanPopup = false;
function restoreEmbeddedSidebarPassthrough(): void {
syncOverlayMouseIgnoreState(ctx);
@@ -323,18 +330,65 @@ export function createSubtitleSidebarModal(
return `Jump to subtitle at ${formatCueTimestamp(cue.startTime)}`;
}
function isYomitanPopupVisibleForSidebar(): boolean {
if (subtitleSidebarYomitanPopupVisible || ctx.state.yomitanPopupVisible) {
return true;
}
if (typeof document === 'undefined') {
return false;
}
return isYomitanPopupVisible(document);
}
function shouldHoldSidebarPauseForYomitanPopup(): boolean {
return (
ctx.state.autoPauseVideoOnYomitanPopup &&
ctx.state.subtitleSidebarPausedByHover &&
isYomitanPopupVisibleForSidebar()
);
}
function resumeSubtitleSidebarHoverPause(): void {
subtitleSidebarHoverRequestId += 1;
if (!ctx.state.subtitleSidebarPausedByHover) {
subtitleSidebarPauseHeldByYomitanPopup = false;
restoreEmbeddedSidebarPassthrough();
return;
}
if (shouldHoldSidebarPauseForYomitanPopup()) {
subtitleSidebarPauseHeldByYomitanPopup = true;
restoreEmbeddedSidebarPassthrough();
return;
}
subtitleSidebarPauseHeldByYomitanPopup = false;
ctx.state.subtitleSidebarPausedByHover = false;
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'no']);
restoreEmbeddedSidebarPassthrough();
}
function handleYomitanPopupShown(): void {
subtitleSidebarYomitanPopupVisible = true;
if (ctx.state.autoPauseVideoOnYomitanPopup && ctx.state.subtitleSidebarPausedByHover) {
subtitleSidebarPauseHeldByYomitanPopup = true;
}
}
function handleYomitanPopupHidden(): void {
subtitleSidebarYomitanPopupVisible = false;
if (!subtitleSidebarPauseHeldByYomitanPopup) {
return;
}
subtitleSidebarPauseHeldByYomitanPopup = false;
if (ctx.state.isOverSubtitleSidebar) {
restoreEmbeddedSidebarPassthrough();
return;
}
resumeSubtitleSidebarHoverPause();
}
function maybeAutoScrollActiveCue(
previousActiveCueIndex: number,
behavior: ScrollBehavior = 'smooth',
@@ -660,8 +714,12 @@ export function createSubtitleSidebarModal(
syncEmbeddedSidebarLayout();
};
window.addEventListener('resize', resizeHandler);
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, handleYomitanPopupShown);
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, handleYomitanPopupHidden);
disposeDomEvents = () => {
window.removeEventListener('resize', resizeHandler);
window.removeEventListener(YOMITAN_POPUP_SHOWN_EVENT, handleYomitanPopupShown);
window.removeEventListener(YOMITAN_POPUP_HIDDEN_EVENT, handleYomitanPopupHidden);
disposeDomEvents = null;
};
}