This commit is contained in:
nose
2025-12-23 16:36:39 -08:00
parent 16316bb3fd
commit 8bf04c6b71
25 changed files with 3165 additions and 234 deletions

View File

@@ -40,6 +40,10 @@ local LOAD_URL_MENU_TYPE = 'medios_load_url'
local DOWNLOAD_FORMAT_MENU_TYPE = 'medios_download_pick_format'
local DOWNLOAD_STORE_MENU_TYPE = 'medios_download_pick_store'
-- Menu types for the command submenu and trim prompt
local CMD_MENU_TYPE = 'medios_cmd_menu'
local TRIM_PROMPT_MENU_TYPE = 'medios_trim_prompt'
local PIPELINE_REQ_PROP = 'user-data/medeia-pipeline-request'
local PIPELINE_RESP_PROP = 'user-data/medeia-pipeline-response'
local PIPELINE_READY_PROP = 'user-data/medeia-pipeline-ready'
@@ -399,6 +403,373 @@ local function _current_target()
return path
end
local ImageControl = {
enabled = false,
binding_names = {},
pan_step = 0.05,
pan_step_slow = 0.02,
zoom_step = 0.45,
zoom_step_slow = 0.15,
}
local MAX_IMAGE_ZOOM = 4.5
local function _install_q_block()
pcall(mp.commandv, 'keybind', 'q', 'script-message', 'medeia-image-quit-block')
end
local function _restore_q_default()
pcall(mp.commandv, 'keybind', 'q', 'quit')
end
local function _enable_image_section()
pcall(mp.commandv, 'enable-section', 'image', 'allow-hide-cursor')
end
local function _disable_image_section()
pcall(mp.commandv, 'disable-section', 'image')
end
mp.register_script_message('medeia-image-quit-block', function()
if ImageControl.enabled then
mp.osd_message('Press ESC if you really want to quit', 0.7)
return
end
mp.commandv('quit')
end)
local ImageExtensions = {
jpg = true,
jpeg = true,
png = true,
gif = true,
webp = true,
bmp = true,
tif = true,
tiff = true,
heic = true,
heif = true,
avif = true,
ico = true,
}
local function _clean_path_for_extension(path)
if type(path) ~= 'string' then
return nil
end
local clean = path:match('([^?]+)') or path
clean = clean:match('([^#]+)') or clean
local last = clean:match('([^/\\]+)$') or ''
local ext = last:match('%.([A-Za-z0-9]+)$')
if not ext then
return nil
end
return ext:lower()
end
local function _is_image_path(path)
local ext = _clean_path_for_extension(path)
return ext and ImageExtensions[ext]
end
local function _get_current_item_is_image()
local video_info = mp.get_property_native('current-tracks/video')
if type(video_info) == 'table' then
if video_info.image and not video_info.albumart then
return true
end
if video_info.image == false and video_info.albumart == true then
return false
end
end
local target = _current_target()
if target then
return _is_image_path(target)
end
return false
end
-- Cover art / splash support disabled (removed per user request)
local function _set_image_property(value)
pcall(mp.set_property_native, 'user-data/mpv/image', value and true or false)
end
local function _show_image_status(message)
local zoom = mp.get_property_number('video-zoom') or 0
local pan_x = mp.get_property_number('video-pan-x') or 0
local pan_y = mp.get_property_number('video-pan-y') or 0
local zoom_percent = math.floor((1 + zoom) * 100 + 0.5)
local text = string.format('Image: zoom %d%% pan %+.2f %+.2f', zoom_percent, pan_x, pan_y)
if message and message ~= '' then
text = message .. ' | ' .. text
end
mp.osd_message(text, 0.7)
end
local function _change_pan(dx, dy)
local pan_x = mp.get_property_number('video-pan-x') or 0
local pan_y = mp.get_property_number('video-pan-y') or 0
mp.set_property_number('video-pan-x', pan_x + dx)
mp.set_property_number('video-pan-y', pan_y + dy)
_show_image_status()
end
local function _change_zoom(delta)
local current = mp.get_property_number('video-zoom') or 0
local target = current + delta
if target > MAX_IMAGE_ZOOM then
target = MAX_IMAGE_ZOOM
end
if target < -1.0 then
target = -1.0
end
mp.set_property_number('video-zoom', target)
mp.set_property('video-unscaled', 'no')
if target >= MAX_IMAGE_ZOOM then
mp.osd_message('Image zoom maxed at 450%', 0.7)
else
_show_image_status()
end
end
local function _reset_pan_zoom()
mp.set_property_number('video-pan-x', 0)
mp.set_property_number('video-pan-y', 0)
mp.set_property_number('video-zoom', 0)
mp.set_property('video-align-x', '0')
mp.set_property('video-align-y', '0')
mp.set_property('panscan', 0)
mp.set_property('video-unscaled', 'no')
_show_image_status('Zoom reset')
end
local function _capture_screenshot()
mp.commandv('screenshot')
mp.osd_message('Screenshot captured', 0.7)
end
mp.register_script_message('medeia-image-screenshot', function()
_capture_screenshot()
end)
local CLIP_MARKER_SLOT_COUNT = 2
local clip_markers = {}
local initial_chapters = nil
local function _format_clip_marker_label(time)
if type(time) ~= 'number' then
return '0s'
end
local total = math.max(0, math.floor(time))
local hours = math.floor(total / 3600)
local minutes = math.floor(total / 60) % 60
local seconds = total % 60
local parts = {}
if hours > 0 then
table.insert(parts, ('%dh'):format(hours))
end
if minutes > 0 or hours > 0 then
table.insert(parts, ('%dm'):format(minutes))
end
table.insert(parts, ('%ds'):format(seconds))
return table.concat(parts)
end
local function _apply_clip_chapters()
local chapters = {}
if initial_chapters then
for _, chapter in ipairs(initial_chapters) do table.insert(chapters, chapter) end
end
for idx = 1, CLIP_MARKER_SLOT_COUNT do
local time = clip_markers[idx]
if time and type(time) == 'number' then
table.insert(chapters, {
time = time,
title = _format_clip_marker_label(time),
})
end
end
table.sort(chapters, function(a, b) return (a.time or 0) < (b.time or 0) end)
mp.set_property_native('chapter-list', chapters)
end
local function _reset_clip_markers()
for idx = 1, CLIP_MARKER_SLOT_COUNT do
clip_markers[idx] = nil
end
_apply_clip_chapters()
end
local function _capture_clip()
local time = mp.get_property_number('time-pos') or mp.get_property_number('time')
if not time then
mp.osd_message('Cannot capture clip; no time available', 0.7)
return
end
local slot = nil
for idx = 1, CLIP_MARKER_SLOT_COUNT do
if not clip_markers[idx] then
slot = idx
break
end
end
if not slot then
local best = math.huge
for idx = 1, CLIP_MARKER_SLOT_COUNT do
local existing = clip_markers[idx]
local distance = math.abs((existing or 0) - time)
if distance < best then
best = distance
slot = idx
end
end
slot = slot or 1
end
clip_markers[slot] = time
_apply_clip_chapters()
mp.commandv('screenshot-to-file', ('clip-%s-%.0f.png'):format(os.date('%Y%m%d-%H%M%S'), time))
local label = _format_clip_marker_label(time)
mp.osd_message(('Clip marker %d set at %s'):format(slot, label), 0.7)
end
mp.register_event('file-loaded', function()
initial_chapters = mp.get_property_native('chapter-list') or {}
_reset_clip_markers()
end)
mp.register_script_message('medeia-image-clip', function()
_capture_clip()
end)
local function _get_trim_range_from_clip_markers()
local times = {}
for idx = 1, CLIP_MARKER_SLOT_COUNT do
local t = clip_markers[idx]
if type(t) == 'number' then
table.insert(times, t)
end
end
table.sort(times, function(a, b) return a < b end)
if #times < 2 then
return nil
end
local start_t = times[1]
local end_t = times[2]
if type(start_t) ~= 'number' or type(end_t) ~= 'number' then
return nil
end
if end_t <= start_t then
return nil
end
return _format_clip_marker_label(start_t) .. '-' .. _format_clip_marker_label(end_t)
end
local function _audio_only()
mp.commandv('set', 'vid', 'no')
mp.osd_message('Audio-only playback enabled', 1)
end
mp.register_script_message('medeia-audio-only', function()
_audio_only()
end)
local function _bind_image_key(key, name, fn, opts)
opts = opts or {}
if ImageControl.binding_names[name] then
pcall(mp.remove_key_binding, name)
ImageControl.binding_names[name] = nil
end
local ok, err = pcall(mp.add_forced_key_binding, key, name, fn, opts)
if ok then
ImageControl.binding_names[name] = true
else
mp.msg.warn('Failed to add image binding ' .. tostring(key) .. ': ' .. tostring(err))
end
end
local function _unbind_image_keys()
for name in pairs(ImageControl.binding_names) do
pcall(mp.remove_key_binding, name)
ImageControl.binding_names[name] = nil
end
end
local function _activate_image_controls()
if ImageControl.enabled then
return
end
ImageControl.enabled = true
_set_image_property(true)
_enable_image_section()
mp.osd_message('Image viewer controls enabled', 1.2)
_bind_image_key('LEFT', 'image-pan-left', function() _change_pan(-ImageControl.pan_step, 0) end, {repeatable=true})
_bind_image_key('RIGHT', 'image-pan-right', function() _change_pan(ImageControl.pan_step, 0) end, {repeatable=true})
_bind_image_key('s', 'image-pan-s', function() _change_pan(0, ImageControl.pan_step) end, {repeatable=true})
_bind_image_key('a', 'image-pan-a', function() _change_pan(ImageControl.pan_step, 0) end, {repeatable=true})
_bind_image_key('d', 'image-pan-d', function() _change_pan(-ImageControl.pan_step, 0) end, {repeatable=true})
_bind_image_key('Shift+RIGHT', 'image-pan-right-fine', function() _change_pan(ImageControl.pan_step_slow, 0) end, {repeatable=true})
_bind_image_key('Shift+UP', 'image-pan-up-fine', function() _change_pan(0, -ImageControl.pan_step_slow) end, {repeatable=true})
_bind_image_key('Shift+DOWN', 'image-pan-down-fine', function() _change_pan(0, ImageControl.pan_step_slow) end, {repeatable=true})
_bind_image_key('h', 'image-pan-h', function() _change_pan(-ImageControl.pan_step, 0) end, {repeatable=true})
_bind_image_key('l', 'image-pan-l', function() _change_pan(ImageControl.pan_step, 0) end, {repeatable=true})
_bind_image_key('j', 'image-pan-j', function() _change_pan(0, ImageControl.pan_step) end, {repeatable=true})
_bind_image_key('k', 'image-pan-k', function() _change_pan(0, -ImageControl.pan_step) end, {repeatable=true})
_bind_image_key('w', 'image-pan-w', function() _change_pan(0, -ImageControl.pan_step) end, {repeatable=true})
_bind_image_key('s', 'image-pan-s', function() _change_pan(0, ImageControl.pan_step) end, {repeatable=true})
_bind_image_key('a', 'image-pan-a', function() _change_pan(ImageControl.pan_step, 0) end, {repeatable=true})
_bind_image_key('d', 'image-pan-d', function() _change_pan(-ImageControl.pan_step, 0) end, {repeatable=true})
_bind_image_key('=', 'image-zoom-in', function() _change_zoom(ImageControl.zoom_step) end, {repeatable=true})
_disable_image_section()
_bind_image_key('-', 'image-zoom-out', function() _change_zoom(-ImageControl.zoom_step) end, {repeatable=true})
_bind_image_key('+', 'image-zoom-in-fine', function() _change_zoom(ImageControl.zoom_step_slow) end, {repeatable=true})
_bind_image_key('_', 'image-zoom-out-fine', function() _change_zoom(-ImageControl.zoom_step_slow) end, {repeatable=true})
_bind_image_key('0', 'image-zoom-reset', _reset_pan_zoom)
_bind_image_key('Space', 'image-status', function() _show_image_status('Image status') end)
_bind_image_key('f', 'image-screenshot', _capture_screenshot)
_install_q_block()
end
local function _deactivate_image_controls()
if not ImageControl.enabled then
return
end
ImageControl.enabled = false
_set_image_property(false)
_restore_q_default()
_unbind_image_keys()
mp.osd_message('Image viewer controls disabled', 1.0)
mp.set_property('panscan', 0)
mp.set_property('video-zoom', 0)
mp.set_property_number('video-pan-x', 0)
mp.set_property_number('video-pan-y', 0)
mp.set_property('video-align-x', '0')
mp.set_property('video-align-y', '0')
end
local function _update_image_mode()
local should_image = _get_current_item_is_image()
if should_image then
_activate_image_controls()
else
_deactivate_image_controls()
end
end
mp.register_event('file-loaded', function()
_update_image_mode()
end)
mp.register_event('shutdown', function()
_restore_q_default()
end)
_update_image_mode()
local function _extract_store_hash(target)
if type(target) ~= 'string' or target == '' then
return nil
@@ -1554,6 +1925,152 @@ function M.open_load_url_prompt()
end
end
-- Open the command submenu with tailored cmdlets (screenshot, clip, trim prompt)
function M.open_cmd_menu()
local items = {
{
title = 'Screenshot',
hint = 'Capture a screenshot',
value = { 'script-message-to', mp.get_script_name(), 'medios-cmd-exec', utils.format_json({ cmd = 'screenshot' }) },
},
{
title = 'Capture clip marker',
hint = 'Place a clip marker at current time',
value = { 'script-message-to', mp.get_script_name(), 'medios-cmd-exec', utils.format_json({ cmd = 'clip' }) },
},
{
title = 'Trim file',
hint = 'Trim current file (prompt for range)',
value = { 'script-message-to', mp.get_script_name(), 'medios-cmd-exec', utils.format_json({ cmd = 'trim' }) },
},
}
local menu_data = {
type = CMD_MENU_TYPE,
title = 'Cmd',
search_style = 'palette',
search_debounce = 'submit',
footnote = 'Type to filter or pick a command',
items = items,
}
local json = utils.format_json(menu_data)
if ensure_uosc_loaded() then
mp.commandv('script-message-to', 'uosc', 'open-menu', json)
else
_lua_log('menu: uosc not available; cannot open cmd menu')
end
end
-- Prompt for trim range via an input box and callback
local function _start_trim_with_range(range)
range = trim(tostring(range or ''))
if range == '' then
mp.osd_message('Trim cancelled (no range provided)', 3)
return
end
local target = _current_target()
if not target or target == '' then
mp.osd_message('No file to trim', 3)
return
end
local store_hash = _extract_store_hash(target)
-- Prefer the resolved stream URL/filename so trimming can avoid full downloads where possible.
local stream = trim(tostring(mp.get_property('stream-open-filename') or ''))
if stream == '' then
stream = tostring(target)
end
local pipeline_cmd
if store_hash then
pipeline_cmd =
'get-tag -emit -store ' .. quote_pipeline_arg(store_hash.store) ..
' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) ..
' | trim-file -input ' .. quote_pipeline_arg(stream) ..
' -range ' .. quote_pipeline_arg(range) ..
' | add-file -store ' .. quote_pipeline_arg(store_hash.store)
else
if utils.file_info(tostring(target)) then
pipeline_cmd = 'trim-file -path ' .. quote_pipeline_arg(target) .. ' -range ' .. quote_pipeline_arg(range)
else
pipeline_cmd = 'trim-file -input ' .. quote_pipeline_arg(stream) .. ' -range ' .. quote_pipeline_arg(range)
end
end
if not _run_pipeline_detached(pipeline_cmd) then
M.run_pipeline(pipeline_cmd)
end
mp.osd_message('Trim started', 3)
end
function M.open_trim_prompt()
local marker_range = _get_trim_range_from_clip_markers()
if marker_range then
_start_trim_with_range(marker_range)
return
end
local menu_data = {
type = TRIM_PROMPT_MENU_TYPE,
title = 'Trim file',
search_style = 'palette',
search_debounce = 'submit',
on_search = 'callback',
footnote = "Enter time range (e.g. '00:03:45-00:03:55' or '1h3m-1h10m30s') and press Enter",
callback = { mp.get_script_name(), 'medios-trim-run' },
items = {
{
title = 'Enter range...',
hint = 'Type range and press Enter',
value = { 'script-message-to', mp.get_script_name(), 'medios-trim-run' },
}
}
}
local json = utils.format_json(menu_data)
if ensure_uosc_loaded() then
mp.commandv('script-message-to', 'uosc', 'open-menu', json)
else
_lua_log('menu: uosc not available; cannot open trim prompt')
end
end
-- Handlers for the command submenu
mp.register_script_message('medios-open-cmd', function()
M.open_cmd_menu()
end)
mp.register_script_message('medios-cmd-exec', function(json)
local ok, ev = pcall(utils.parse_json, json)
if not ok or type(ev) ~= 'table' then
return
end
local cmd = trim(tostring(ev.cmd or ''))
if cmd == 'screenshot' then
_capture_screenshot()
elseif cmd == 'clip' then
_capture_clip()
elseif cmd == 'trim' then
M.open_trim_prompt()
else
mp.osd_message('Unknown cmd ' .. tostring(cmd), 2)
end
end)
mp.register_script_message('medios-trim-run', function(json)
local ok, ev = pcall(utils.parse_json, json)
local range = nil
if ok and type(ev) == 'table' then
if ev.type == 'search' then
range = trim(tostring(ev.query or ''))
end
end
_start_trim_with_range(range)
end)
mp.register_script_message('medios-load-url', function()
M.open_load_url_prompt()
end)
@@ -1591,6 +2108,7 @@ function M.show_menu()
{ title = "Get Metadata", value = "script-binding medios-info", hint = "Ctrl+i" },
{ title = "Delete File", value = "script-binding medios-delete", hint = "Ctrl+Del" },
{ title = "Load URL", value = {"script-message-to", mp.get_script_name(), "medios-load-url"} },
{ title = "Cmd", value = {"script-message-to", mp.get_script_name(), "medios-open-cmd"}, hint = "Run quick commands (screenshot, trim, etc)" },
{ title = "Download", value = {"script-message-to", mp.get_script_name(), "medios-download-current"} },
{ title = "Change Format", value = {"script-message-to", mp.get_script_name(), "medios-change-format-current"} },
}
@@ -1614,6 +2132,8 @@ mp.add_key_binding("ctrl+del", "medios-delete", M.delete_current_file)
mp.add_key_binding("l", "medeia-lyric-toggle", lyric_toggle)
mp.add_key_binding("L", "medeia-lyric-toggle-shift", lyric_toggle)
-- Cover art observers removed (disabled per user request)
-- Start the persistent pipeline helper eagerly at launch.
-- This avoids spawning Python per command and works cross-platform via MPV IPC.
mp.add_timeout(0, function()

View File

@@ -467,6 +467,66 @@ def _extract_lrc_from_notes(notes: Dict[str, str]) -> Optional[str]:
return text if text.strip() else None
def _extract_sub_from_notes(notes: Dict[str, str]) -> Optional[str]:
"""Return raw subtitle text from the note named 'sub'."""
if not isinstance(notes, dict) or not notes:
return None
raw = None
for k, v in notes.items():
if not isinstance(k, str):
continue
if k.strip() == "sub":
raw = v
break
if not isinstance(raw, str):
return None
text = raw.strip("\ufeff\r\n")
return text if text.strip() else None
def _infer_sub_extension(text: str) -> str:
# Best-effort: mpv generally understands SRT/VTT; choose based on content.
t = (text or "").lstrip("\ufeff\r\n").lstrip()
if t.upper().startswith("WEBVTT"):
return ".vtt"
if "-->" in t:
# SRT typically uses commas for milliseconds, VTT uses dots.
if re.search(r"\d\d:\d\d:\d\d,\d\d\d\s*-->\s*\d\d:\d\d:\d\d,\d\d\d", t):
return ".srt"
return ".vtt"
return ".vtt"
def _write_temp_sub_file(*, key: str, text: str) -> Path:
# Write to a content-addressed temp path so updates force mpv reload.
tmp_dir = Path(tempfile.gettempdir()) / "medeia-mpv-notes"
tmp_dir.mkdir(parents=True, exist_ok=True)
ext = _infer_sub_extension(text)
digest = hashlib.sha1((key + "\n" + (text or "")).encode("utf-8", errors="ignore")).hexdigest()[:16]
safe_key = hashlib.sha1((key or "").encode("utf-8", errors="ignore")).hexdigest()[:12]
path = (tmp_dir / f"sub-{safe_key}-{digest}{ext}").resolve()
path.write_text(text or "", encoding="utf-8", errors="replace")
return path
def _try_remove_selected_external_sub(client: MPVIPCClient) -> None:
try:
client.send_command({"command": ["sub-remove"]})
except Exception:
return
def _try_add_external_sub(client: MPVIPCClient, path: Path) -> None:
try:
client.send_command({"command": ["sub-add", str(path), "select", "medeia-sub"]})
except Exception:
return
def _is_stream_target(target: str) -> bool:
"""Return True when mpv's 'path' is not a local filesystem file.
@@ -726,7 +786,7 @@ def _infer_hash_for_target(target: str) -> Optional[str]:
def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] = None) -> int:
"""Auto mode: track mpv's current file and render lyrics from store notes (note name: 'lyric')."""
"""Auto mode: track mpv's current file and render lyrics (note: 'lyric') or load subtitles (note: 'sub')."""
cfg = config or {}
client = mpv.client()
@@ -742,6 +802,8 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
current_key: Optional[str] = None
current_backend: Optional[Any] = None
last_loaded_key: Optional[str] = None
last_loaded_mode: Optional[str] = None # 'lyric' | 'sub'
last_loaded_sub_path: Optional[Path] = None
last_fetch_attempt_key: Optional[str] = None
last_fetch_attempt_at: float = 0.0
@@ -808,6 +870,9 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
_osd_overlay_clear(client)
except Exception:
pass
if last_loaded_sub_path is not None:
_try_remove_selected_external_sub(client)
last_loaded_sub_path = None
last_target = target
current_store_name = None
current_file_hash = None
@@ -816,6 +881,7 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
entries = []
times = []
last_loaded_key = None
last_loaded_mode = None
time.sleep(poll_s)
continue
@@ -833,6 +899,10 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
if last_loaded_key is not None:
_osd_overlay_clear(client)
last_loaded_key = None
last_loaded_mode = None
if last_loaded_sub_path is not None:
_try_remove_selected_external_sub(client)
last_loaded_sub_path = None
time.sleep(poll_s)
continue
@@ -850,6 +920,10 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
if last_loaded_key is not None:
_osd_overlay_clear(client)
last_loaded_key = None
last_loaded_mode = None
if last_loaded_sub_path is not None:
_try_remove_selected_external_sub(client)
last_loaded_sub_path = None
time.sleep(poll_s)
continue
@@ -869,6 +943,10 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
if last_loaded_key is not None:
_osd_overlay_clear(client)
last_loaded_key = None
last_loaded_mode = None
if last_loaded_sub_path is not None:
_try_remove_selected_external_sub(client)
last_loaded_sub_path = None
time.sleep(poll_s)
continue
@@ -887,6 +965,10 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
if last_loaded_key is not None:
_osd_overlay_clear(client)
last_loaded_key = None
last_loaded_mode = None
if last_loaded_sub_path is not None:
_try_remove_selected_external_sub(client)
last_loaded_sub_path = None
time.sleep(poll_s)
continue
@@ -913,6 +995,10 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
if last_loaded_key is not None:
_osd_overlay_clear(client)
last_loaded_key = None
last_loaded_mode = None
if last_loaded_sub_path is not None:
_try_remove_selected_external_sub(client)
last_loaded_sub_path = None
time.sleep(poll_s)
continue
@@ -930,9 +1016,41 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
except Exception:
_log("Loaded notes keys: <error>")
lrc_text = _extract_lrc_from_notes(notes)
if not lrc_text:
_log("No lyric note found (note name: 'lyric')")
sub_text = _extract_sub_from_notes(notes)
if sub_text:
# Treat subtitles as an alternative to lyrics; do not show the lyric overlay.
try:
_osd_overlay_clear(client)
except Exception:
pass
try:
sub_path = _write_temp_sub_file(key=current_key, text=sub_text)
except Exception as exc:
_log(f"Failed to write sub note temp file: {exc}")
sub_path = None
if sub_path is not None:
# If we previously loaded a sub, remove it first to avoid stacking.
if last_loaded_sub_path is not None:
_try_remove_selected_external_sub(client)
_try_add_external_sub(client, sub_path)
last_loaded_sub_path = sub_path
entries = []
times = []
last_loaded_key = current_key
last_loaded_mode = "sub"
else:
# Switching away from sub-note mode: best-effort unload the selected external subtitle.
if last_loaded_mode == "sub" and last_loaded_sub_path is not None:
_try_remove_selected_external_sub(client)
last_loaded_sub_path = None
lrc_text = _extract_lrc_from_notes(notes)
if not lrc_text:
_log("No lyric note found (note name: 'lyric')")
# Auto-fetch path: fetch and persist lyrics into the note named 'lyric'.
# Throttle attempts per key to avoid hammering APIs.
@@ -981,18 +1099,20 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
else:
_log("Autofetch: no lyrics found")
entries = []
times = []
if last_loaded_key is not None:
_osd_overlay_clear(client)
last_loaded_key = None
else:
_log(f"Loaded lyric note ({len(lrc_text)} chars)")
entries = []
times = []
if last_loaded_key is not None:
_osd_overlay_clear(client)
last_loaded_key = None
last_loaded_mode = None
else:
_log(f"Loaded lyric note ({len(lrc_text)} chars)")
parsed = parse_lrc(lrc_text)
entries = parsed
times = [e.time_s for e in entries]
last_loaded_key = current_key
parsed = parse_lrc(lrc_text)
entries = parsed
times = [e.time_s for e in entries]
last_loaded_key = current_key
last_loaded_mode = "lyric"
try:
# mpv returns None when idle/no file.

BIN
MPV/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB