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
This commit is contained in:
2026-05-22 22:07:35 -07:00
parent 8de2613e4b
commit d1998797e9
6 changed files with 116 additions and 9 deletions
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Fixed Jellyfin `y-t` overlay hide so the plugin sends an explicit hide command when it knows the overlay is visible, avoiding overlay reloads and paused playback resumes.
+36 -2
View File
@@ -77,6 +77,20 @@ function M.create(ctx)
return DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS
end
local function record_visible_overlay_action(action)
if action == "show-visible-overlay" then
state.visible_overlay_requested = true
state.suppress_ready_overlay_restore = false
elseif action == "hide-visible-overlay" then
state.visible_overlay_requested = false
elseif action == "toggle-visible-overlay" and state.visible_overlay_requested ~= nil then
state.visible_overlay_requested = not state.visible_overlay_requested
if state.visible_overlay_requested then
state.suppress_ready_overlay_restore = false
end
end
end
local function normalize_socket_path(path)
if type(path) ~= "string" then
return nil
@@ -317,6 +331,7 @@ function M.create(ctx)
end
run_control_command_async = function(action, overrides, callback)
record_visible_overlay_action(action)
local args = build_command_args(action, overrides)
local command = build_subprocess_command(args)
subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
@@ -557,7 +572,8 @@ function M.create(ctx)
show_osd("Stopped")
end
local function hide_visible_overlay()
local function hide_visible_overlay(options)
options = options or {}
if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found")
return
@@ -577,7 +593,9 @@ function M.create(ctx)
end
end)
disarm_auto_play_ready_gate()
disarm_auto_play_ready_gate({
resume_playback = options.resume_playback ~= false,
})
end
local function toggle_overlay()
@@ -586,6 +604,22 @@ function M.create(ctx)
show_osd("Error: binary not found")
return
end
if state.visible_overlay_requested == true then
state.suppress_ready_overlay_restore = true
hide_visible_overlay({ resume_playback = false })
return
end
if state.visible_overlay_requested == false then
state.suppress_ready_overlay_restore = false
disarm_auto_play_ready_gate({ resume_playback = false })
run_control_command_async("show-visible-overlay", nil, function(ok)
if not ok then
subminer_log("warn", "process", "Show-visible-overlay command failed")
show_osd("Toggle failed")
end
end)
return
end
state.suppress_ready_overlay_restore = true
disarm_auto_play_ready_gate({ resume_playback = false })
+1
View File
@@ -35,6 +35,7 @@ function M.new()
auto_play_ready_osd_timer = nil,
suppress_ready_overlay_restore = false,
force_ready_overlay_restore = false,
visible_overlay_requested = nil,
current_media_identity = nil,
pending_reload_media_identity = nil,
auto_start_retry_generation = 0,
+55 -2
View File
@@ -683,6 +683,55 @@ do
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/jellyfin-stream.m3u8",
media_title = "Jellyfin Episode",
paused = true,
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for y-t hide visible overlay scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
local toggle_binding = nil
for _, candidate in ipairs(recorded.key_bindings) do
if candidate.name == "subminer-toggle" then
toggle_binding = candidate
break
end
end
assert_true(toggle_binding ~= nil, "y-t toggle binding should be registered")
toggle_binding.fn()
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1,
"y-t should hide the known visible overlay explicitly instead of app-side toggle"
)
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0,
"y-t should avoid app-side toggle when plugin knows the overlay is visible"
)
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"manual y-t hide should suppress duplicate auto-start and ready-time visible overlay reassertion"
)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"manual y-t hide should not resume paused Jellyfin playback"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
@@ -1550,8 +1599,12 @@ do
assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered")
recorded.script_messages["subminer-toggle"]()
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 1,
"manual toggle should use explicit visible-overlay toggle command"
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1,
"manual toggle-off should hide a known visible overlay explicitly"
)
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0,
"manual toggle-off should avoid app-side toggle when plugin knows the overlay is visible"
)
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
@@ -333,12 +333,18 @@ test('preload jellyfin subtitles cleans previous cached subtitles before a new p
test('preload jellyfin subtitles continues after cleanup failures', async () => {
const commands: Array<Array<string | number>> = [];
const cleanupCalls: string[][] = [];
const logs: string[] = [];
let cleanupShouldFail = false;
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'eng', title: 'English', deliveryUrl: 'https://sub/a.srt' },
listJellyfinSubtitleTracks: async (_session, _clientInfo, itemId) => [
{
index: itemId === 'item-1' ? 0 : 1,
language: 'eng',
title: 'English',
deliveryUrl: `https://sub/${itemId}.srt`,
},
],
getMpvClient: () => ({ requestProperty: async () => [] }),
cacheSubtitleTrack: async (track) => ({
@@ -346,7 +352,8 @@ test('preload jellyfin subtitles continues after cleanup failures', async () =>
cleanupDir: `/tmp/subminer-jellyfin-subtitles-${track.index}`,
}),
sendMpvCommand: (command) => commands.push(command),
cleanupCachedSubtitles: () => {
cleanupCachedSubtitles: (dirs) => {
cleanupCalls.push(dirs);
if (cleanupShouldFail) {
throw new Error('cleanup failed');
}
@@ -358,13 +365,19 @@ test('preload jellyfin subtitles continues after cleanup failures', async () =>
await preload({ session, clientInfo, itemId: 'item-1' });
cleanupShouldFail = true;
await assert.doesNotReject(() => preload({ session, clientInfo, itemId: 'item-2' }));
cleanupShouldFail = false;
preload.cleanupCachedSubtitles();
assert.deepEqual(logs, ['Failed to cleanup Jellyfin cached subtitles']);
assert.deepEqual(cleanupCalls, [
['/tmp/subminer-jellyfin-subtitles-0'],
['/tmp/subminer-jellyfin-subtitles-0', '/tmp/subminer-jellyfin-subtitles-1'],
]);
assert.deepEqual(
commands.filter((command) => command[0] === 'sub-add'),
[
['sub-add', '/tmp/subminer-jellyfin-subtitles-0/track.srt', 'auto', 'English', 'eng'],
['sub-add', '/tmp/subminer-jellyfin-subtitles-0/track.srt', 'auto', 'English', 'eng'],
['sub-add', '/tmp/subminer-jellyfin-subtitles-1/track.srt', 'auto', 'English', 'eng'],
],
);
});
@@ -235,9 +235,11 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
function cleanupActiveCache(): void {
const dirs = [...activeCacheDirs];
activeCacheDirs.clear();
if (dirs.length === 0) return;
deps.cleanupCachedSubtitles(dirs);
for (const dir of dirs) {
activeCacheDirs.delete(dir);
}
}
const runPreload = async (params: {