feat(jellyfin): add remote playback and config plumbing

This commit is contained in:
2026-02-17 19:00:18 -08:00
parent a6a28f52f3
commit e38a1c945e
42 changed files with 5608 additions and 1013 deletions

View File

@@ -24,14 +24,18 @@ export function createRuntimeOptionsModal(
ctx.dom.runtimeOptionsStatus.classList.toggle("error", isError);
}
function getRuntimeOptionDisplayValue(option: RuntimeOptionState): RuntimeOptionValue {
function getRuntimeOptionDisplayValue(
option: RuntimeOptionState,
): RuntimeOptionValue {
return ctx.state.runtimeOptionDraftValues.get(option.id) ?? option.value;
}
function getSelectedRuntimeOption(): RuntimeOptionState | null {
if (ctx.state.runtimeOptions.length === 0) return null;
if (ctx.state.runtimeOptionSelectedIndex < 0) return null;
if (ctx.state.runtimeOptionSelectedIndex >= ctx.state.runtimeOptions.length) {
if (
ctx.state.runtimeOptionSelectedIndex >= ctx.state.runtimeOptions.length
) {
return null;
}
return ctx.state.runtimeOptions[ctx.state.runtimeOptionSelectedIndex];
@@ -42,7 +46,10 @@ export function createRuntimeOptionsModal(
ctx.state.runtimeOptions.forEach((option, index) => {
const li = document.createElement("li");
li.className = "runtime-options-item";
li.classList.toggle("active", index === ctx.state.runtimeOptionSelectedIndex);
li.classList.toggle(
"active",
index === ctx.state.runtimeOptionSelectedIndex,
);
const label = document.createElement("div");
label.className = "runtime-options-label";
@@ -113,14 +120,20 @@ export function createRuntimeOptionsModal(
if (!option || option.allowedValues.length === 0) return;
const currentValue = getRuntimeOptionDisplayValue(option);
const currentIndex = option.allowedValues.findIndex((value) => value === currentValue);
const currentIndex = option.allowedValues.findIndex(
(value) => value === currentValue,
);
const safeIndex = currentIndex >= 0 ? currentIndex : 0;
const nextIndex =
direction === 1
? (safeIndex + 1) % option.allowedValues.length
: (safeIndex - 1 + option.allowedValues.length) % option.allowedValues.length;
: (safeIndex - 1 + option.allowedValues.length) %
option.allowedValues.length;
ctx.state.runtimeOptionDraftValues.set(option.id, option.allowedValues[nextIndex]);
ctx.state.runtimeOptionDraftValues.set(
option.id,
option.allowedValues[nextIndex],
);
renderRuntimeOptionsList();
setRuntimeOptionsStatus(
`Selected ${option.label}: ${formatRuntimeOptionValue(option.allowedValues[nextIndex])}`,
@@ -140,7 +153,10 @@ export function createRuntimeOptionsModal(
}
if (result.option) {
ctx.state.runtimeOptionDraftValues.set(result.option.id, result.option.value);
ctx.state.runtimeOptionDraftValues.set(
result.option.id,
result.option.value,
);
}
const latest = await window.electronAPI.getRuntimeOptions();
@@ -160,7 +176,10 @@ export function createRuntimeOptionsModal(
setRuntimeOptionsStatus("");
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
if (
!ctx.state.isOverSubtitle &&
!options.modalStateReader.isAnyModalOpen()
) {
ctx.dom.overlay.classList.remove("interactive");
}
}

View File

@@ -19,7 +19,10 @@ type SessionHelpSection = {
title: string;
rows: SessionHelpItem[];
};
type RuntimeShortcutConfig = Omit<Required<ShortcutsConfig>, "multiCopyTimeoutMs">;
type RuntimeShortcutConfig = Omit<
Required<ShortcutsConfig>,
"multiCopyTimeoutMs"
>;
const HEX_COLOR_RE =
/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
@@ -84,7 +87,10 @@ const OVERLAY_SHORTCUTS: Array<{
}> = [
{ key: "copySubtitle", label: "Copy subtitle" },
{ key: "copySubtitleMultiple", label: "Copy subtitle (multi)" },
{ key: "updateLastCardFromClipboard", label: "Update last card from clipboard" },
{
key: "updateLastCardFromClipboard",
label: "Update last card from clipboard",
},
{ key: "triggerFieldGrouping", label: "Trigger field grouping" },
{ key: "triggerSubsync", label: "Open subtitle sync controls" },
{ key: "mineSentence", label: "Mine sentence" },
@@ -128,10 +134,14 @@ function describeCommand(command: (string | number)[]): string {
if (first === "sub-seek" && typeof command[1] === "number") {
return `Shift subtitle by ${command[1]} ms`;
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return "Open subtitle sync controls";
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return "Open runtime options";
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return "Replay current subtitle";
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return "Play next subtitle";
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER)
return "Open subtitle sync controls";
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN)
return "Open runtime options";
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE)
return "Replay current subtitle";
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE)
return "Play next subtitle";
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
const [, rawId, rawDirection] = first.split(":");
return `Cycle runtime option ${rawId || "option"} ${rawDirection === "prev" ? "previous" : "next"}`;
@@ -154,7 +164,11 @@ function sectionForCommand(command: (string | number)[]): string {
return "Playback and navigation";
}
if (first === "show-text" || first === "show-progress" || first.startsWith("osd")) {
if (
first === "show-text" ||
first === "show-progress" ||
first.startsWith("osd")
) {
return "Visual feedback";
}
@@ -221,38 +235,80 @@ function buildColorSection(style: {
rows: [
{
shortcut: "Known words",
action: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
color: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
action: normalizeColor(
style.knownWordColor,
FALLBACK_COLORS.knownWordColor,
),
color: normalizeColor(
style.knownWordColor,
FALLBACK_COLORS.knownWordColor,
),
},
{
shortcut: "N+1 words",
action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
color: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
action: normalizeColor(
style.nPlusOneColor,
FALLBACK_COLORS.nPlusOneColor,
),
color: normalizeColor(
style.nPlusOneColor,
FALLBACK_COLORS.nPlusOneColor,
),
},
{
shortcut: "JLPT N1",
action: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
color: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
action: normalizeColor(
style.jlptColors?.N1,
FALLBACK_COLORS.jlptN1Color,
),
color: normalizeColor(
style.jlptColors?.N1,
FALLBACK_COLORS.jlptN1Color,
),
},
{
shortcut: "JLPT N2",
action: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
color: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
action: normalizeColor(
style.jlptColors?.N2,
FALLBACK_COLORS.jlptN2Color,
),
color: normalizeColor(
style.jlptColors?.N2,
FALLBACK_COLORS.jlptN2Color,
),
},
{
shortcut: "JLPT N3",
action: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
color: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
action: normalizeColor(
style.jlptColors?.N3,
FALLBACK_COLORS.jlptN3Color,
),
color: normalizeColor(
style.jlptColors?.N3,
FALLBACK_COLORS.jlptN3Color,
),
},
{
shortcut: "JLPT N4",
action: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
color: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
action: normalizeColor(
style.jlptColors?.N4,
FALLBACK_COLORS.jlptN4Color,
),
color: normalizeColor(
style.jlptColors?.N4,
FALLBACK_COLORS.jlptN4Color,
),
},
{
shortcut: "JLPT N5",
action: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
color: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
action: normalizeColor(
style.jlptColors?.N5,
FALLBACK_COLORS.jlptN5Color,
),
color: normalizeColor(
style.jlptColors?.N5,
FALLBACK_COLORS.jlptN5Color,
),
},
],
};
@@ -423,8 +479,7 @@ export function createSessionHelpModal(
function isSessionHelpModalFocusTarget(target: EventTarget | null): boolean {
return (
target instanceof Element &&
ctx.dom.sessionHelpModal.contains(target)
target instanceof Element && ctx.dom.sessionHelpModal.contains(target)
);
}
@@ -493,7 +548,9 @@ export function createSessionHelpModal(
});
if (getItems().length === 0) {
ctx.dom.sessionHelpContent.classList.add("session-help-content-no-results");
ctx.dom.sessionHelpContent.classList.add(
"session-help-content-no-results",
);
ctx.dom.sessionHelpContent.textContent = helpFilterValue
? "No matching shortcuts found."
: "No active session shortcuts found.";
@@ -501,7 +558,9 @@ export function createSessionHelpModal(
return;
}
ctx.dom.sessionHelpContent.classList.remove("session-help-content-no-results");
ctx.dom.sessionHelpContent.classList.remove(
"session-help-content-no-results",
);
if (isFilterInputFocused()) return;
@@ -519,14 +578,23 @@ export function createSessionHelpModal(
requestOverlayFocus();
enforceModalFocus();
};
ctx.dom.sessionHelpModal.addEventListener("pointerdown", modalPointerFocusGuard);
ctx.dom.sessionHelpModal.addEventListener(
"pointerdown",
modalPointerFocusGuard,
);
ctx.dom.sessionHelpModal.addEventListener("click", modalPointerFocusGuard);
}
function removePointerFocusListener(): void {
if (!modalPointerFocusGuard) return;
ctx.dom.sessionHelpModal.removeEventListener("pointerdown", modalPointerFocusGuard);
ctx.dom.sessionHelpModal.removeEventListener("click", modalPointerFocusGuard);
ctx.dom.sessionHelpModal.removeEventListener(
"pointerdown",
modalPointerFocusGuard,
);
ctx.dom.sessionHelpModal.removeEventListener(
"click",
modalPointerFocusGuard,
);
modalPointerFocusGuard = null;
}
@@ -593,7 +661,9 @@ export function createSessionHelpModal(
}
}
async function openSessionHelpModal(opening: SessionHelpBindingInfo): Promise<void> {
async function openSessionHelpModal(
opening: SessionHelpBindingInfo,
): Promise<void> {
openBinding = opening;
priorFocus = document.activeElement;
@@ -604,7 +674,8 @@ export function createSessionHelpModal(
ctx.dom.sessionHelpWarning.textContent =
"Both Y-H and Y-K are bound; Y-K remains the fallback for this session.";
} else if (openBinding.fallbackUsed) {
ctx.dom.sessionHelpWarning.textContent = "Y-H is already bound; using Y-K as fallback.";
ctx.dom.sessionHelpWarning.textContent =
"Y-H is already bound; using Y-K as fallback.";
} else {
ctx.dom.sessionHelpWarning.textContent = "";
}
@@ -655,7 +726,10 @@ export function createSessionHelpModal(
options.syncSettingsModalSubtitleSuppression();
ctx.dom.sessionHelpModal.classList.add("hidden");
ctx.dom.sessionHelpModal.setAttribute("aria-hidden", "true");
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
if (
!ctx.state.isOverSubtitle &&
!options.modalStateReader.isAnyModalOpen()
) {
ctx.dom.overlay.classList.remove("interactive");
}
@@ -676,7 +750,10 @@ export function createSessionHelpModal(
ctx.dom.overlay.focus({ preventScroll: true });
}
if (ctx.platform.shouldToggleMouseIgnore) {
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
if (
!ctx.state.isOverSubtitle &&
!options.modalStateReader.isAnyModalOpen()
) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
} else {
window.electronAPI.setIgnoreMouseEvents(false);
@@ -716,13 +793,7 @@ export function createSessionHelpModal(
const items = getItems();
if (items.length === 0) return true;
if (
e.key === "/" &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
!e.shiftKey
) {
if (e.key === "/" && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
e.preventDefault();
focusFilterInput();
return true;
@@ -730,21 +801,13 @@ export function createSessionHelpModal(
const key = e.key.toLowerCase();
if (
key === "arrowdown" ||
key === "j" ||
key === "l"
) {
if (key === "arrowdown" || key === "j" || key === "l") {
e.preventDefault();
setSelected(ctx.state.sessionHelpSelectedIndex + 1);
return true;
}
if (
key === "arrowup" ||
key === "k" ||
key === "h"
) {
if (key === "arrowup" || key === "k" || key === "h") {
e.preventDefault();
setSelected(ctx.state.sessionHelpSelectedIndex - 1);
return true;
@@ -759,22 +822,28 @@ export function createSessionHelpModal(
applyFilterAndRender();
});
ctx.dom.sessionHelpFilter.addEventListener("keydown", (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
focusFallbackTarget();
}
});
ctx.dom.sessionHelpFilter.addEventListener(
"keydown",
(event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
focusFallbackTarget();
}
},
);
ctx.dom.sessionHelpContent.addEventListener("click", (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
const row = target.closest(".session-help-item") as HTMLElement | null;
if (!row) return;
const index = Number.parseInt(row.dataset.sessionHelpIndex ?? "", 10);
if (!Number.isFinite(index)) return;
setSelected(index);
});
ctx.dom.sessionHelpContent.addEventListener(
"click",
(event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
const row = target.closest(".session-help-item") as HTMLElement | null;
if (!row) return;
const index = Number.parseInt(row.dataset.sessionHelpIndex ?? "", 10);
if (!Number.isFinite(index)) return;
setSelected(index);
},
);
ctx.dom.sessionHelpClose.addEventListener("click", () => {
closeSessionHelpModal();