dsf
This commit is contained in:
520
MPV/LUA/main.lua
520
MPV/LUA/main.lua
@@ -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()
|
||||
|
||||
150
MPV/lyric.py
150
MPV/lyric.py
@@ -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
BIN
MPV/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Reference in New Issue
Block a user