diff --git a/README.md b/README.md index b1543ba..ade7d27 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,11 @@ If both `menu.dll` and `menu.lua` exists in scripts folder, one of it may be nam - `max_title_length=80`: Limits the title length in dynamic submenu, set to 0 to disable. -## Updating menu from script +## Scripting -The menu data is stored in `user-data/menu/items` property with the following structure: +### Properties + +#### `user-data/menu/items` ``` MPV_FORMAT_NODE_ARRAY @@ -92,10 +94,44 @@ MPV_FORMAT_NODE_ARRAY "submenu" MPV_FORMAT_NODE_ARRAY[menu item] (required if type is submenu) ``` -Updating this property will trigger an update of the menu UI. +The menu data of the C plugin is stored in this property, updating it will trigger an update of the menu UI. To reduce update frequency, it's recommended to update this property in [mp.register_idle(fn)](https://mpv.io/manual/master/#lua-scripting-mp-register-idle(fn)). +Be aware that `dyn_menu.lua` is conflict with other scripts that also update the `user-data/menu/items` property, +you may use the messages below if you only want to update part of the menu. + +### Messages + +#### `menu-ready` + +Broadcasted when `dyn_menu.lua` has initilized itself. + +#### `get ` + +Get the menu item structure of `keyword`, and send a json reply to `src`. + +```json +{ + "keyword": "chapters" + "item": { + "title": "Chapters", + "type": "submenu", + "submenu": [] + } +} +``` + +The reply is sent via `srcript-message-to menu-get-reply `. + +If `keyword` is not found, the result json will contain an additional `error` field, and no `item` field. + +#### `update ` + +Update the menu item structure of `keyword` with `json`. + +As a convenience, if you don't want to override menu title and type, omit the corresponding field in `json`. + ## Credits This project contains code copied from [mpv](https://github.com/mpv-player/mpv). diff --git a/lua/dyn_menu.lua b/lua/dyn_menu.lua index 85e0a0a..154dbe5 100644 --- a/lua/dyn_menu.lua +++ b/lua/dyn_menu.lua @@ -1,26 +1,9 @@ -- Copyright (c) 2023 tsl0922. All rights reserved. -- SPDX-License-Identifier: GPL-2.0-only --- --- #@keyword support for dynamic menu --- --- supported keywords: --- --- #@tracks video/audio/sub tracks --- #@tracks/video video track list --- #@tracks/audio audio track list --- #@tracks/sub subtitle list --- #@tracks/sub-secondary subtitle list (secondary) --- #@chapters chapter list --- #@editions edition list --- #@audio-devices audio device list --- #@playlist playlist --- #@profiles profile list --- --- #@prop:check check menu item if property is true --- #@prop:check! check menu item if property is false local opts = require('mp.options') local utils = require('mp.utils') +local msg = require('mp.msg') -- user options local o = { @@ -31,6 +14,7 @@ opts.read_options(o) local menu_prop = 'user-data/menu/items' local menu_items = mp.get_property_native(menu_prop, {}) local menu_items_dirty = false +local dyn_menus = {} -- escape codec name to make it more readable local function escape_codec(str) @@ -157,11 +141,16 @@ local function to_submenu(item) return item.submenu end +local function observe_property(menu, prop, type, fn) + mp.observe_property(prop, type, fn) + menu.fns[#menu.fns + 1] = fn +end + -- handle #@tracks menu update -local function update_tracks_menu(item) - local submenu = to_submenu(item) +local function update_tracks_menu(menu) + local submenu = to_submenu(menu.item) - mp.observe_property('track-list', 'native', function(_, track_list) + local function track_list_cb(_, track_list) for i = #submenu, 1, -1 do table.remove(submenu, i) end menu_items_dirty = true if not track_list or #track_list == 0 then return end @@ -176,28 +165,32 @@ local function update_tracks_menu(item) for _, item in ipairs(items_a) do table.insert(submenu, item) end if #submenu > 0 and #items_s > 0 then table.insert(submenu, { type = 'separator' }) end for _, item in ipairs(items_s) do table.insert(submenu, item) end - end) + end + + observe_property(menu, 'track-list', 'native', track_list_cb) end -- handle #@tracks/ menu update for given type -local function update_track_menu(item, type, prop) - local submenu = to_submenu(item) +local function update_track_menu(menu, type, prop) + local submenu = to_submenu(menu.item) - mp.observe_property('track-list', 'native', function(_, track_list) + local function track_list_cb(_, track_list) for i = #submenu, 1, -1 do table.remove(submenu, i) end menu_items_dirty = true if not track_list or #track_list == 0 then return end local items = build_track_items(track_list, type, prop, false) for _, item in ipairs(items) do table.insert(submenu, item) end - end) + end + + observe_property(menu, 'track-list', 'native', track_list_cb) end -- handle #@chapters menu update -local function update_chapters_menu(item) - local submenu = to_submenu(item) +local function update_chapters_menu(menu) + local submenu = to_submenu(menu.item) - mp.observe_property('chapter-list', 'native', function(_, chapter_list) + local function chapter_list_cb(_, chapter_list) for i = #submenu, 1, -1 do table.remove(submenu, i) end menu_items_dirty = true if not chapter_list or #chapter_list == 0 then return end @@ -214,22 +207,25 @@ local function update_chapters_menu(item) state = id == pos + 1 and { 'checked' } or {}, } end - end) + end - mp.observe_property('chapter', 'number', function(_, pos) + local function chapter_cb(_, pos) if not pos then pos = -1 end for id, item in ipairs(submenu) do item.state = id == pos + 1 and { 'checked' } or {} end menu_items_dirty = true - end) + end + + observe_property(menu, 'chapter-list', 'native', chapter_list_cb) + observe_property(menu, 'chapter', 'number', chapter_cb) end -- handle #@edition menu update -local function update_editions_menu(item) - local submenu = to_submenu(item) +local function update_editions_menu(menu) + local submenu = to_submenu(menu.item) - mp.observe_property('edition-list', 'native', function(_, edition_list) + local function edition_list_cb(_, edition_list) for i = #submenu, 1, -1 do table.remove(submenu, i) end menu_items_dirty = true if not edition_list or #edition_list == 0 then return end @@ -245,22 +241,25 @@ local function update_editions_menu(item) state = id == current + 1 and { 'checked' } or {}, } end - end) + end - mp.observe_property('current-edition', 'number', function(_, pos) + local function edition_cb(_, pos) if not pos then pos = -1 end for id, item in ipairs(submenu) do item.state = id == pos + 1 and { 'checked' } or {} end menu_items_dirty = true - end) + end + + observe_property(menu, 'edition-list', 'native', edition_list_cb) + observe_property(menu, 'current-edition', 'number', edition_cb) end -- handle #@audio-devices menu update -local function update_audio_devices_menu(item) - local submenu = to_submenu(item) +local function update_audio_devices_menu(menu) + local submenu = to_submenu(menu.item) - mp.observe_property('audio-device-list', 'native', function(_, device_list) + local function device_list_cb(_, device_list) for i = #submenu, 1, -1 do table.remove(submenu, i) end menu_items_dirty = true if not device_list or #device_list == 0 then return end @@ -273,15 +272,18 @@ local function update_audio_devices_menu(item) state = device.name == current and { 'checked' } or {}, } end - end) + end - mp.observe_property('audio-device', 'string', function(_, name) - if not name then name = '' end + local function device_cb(_, device) + if not device then device = '' end for _, item in ipairs(submenu) do - item.state = item.cmd:match('%s*set audio%-device%s+(%S+)%s*$') == name and { 'checked' } or {} + item.state = item.cmd:match('%s*set audio%-device%s+(%S+)%s*$') == device and { 'checked' } or {} end menu_items_dirty = true - end) + end + + observe_property(menu, 'audio-device-list', 'native', device_list_cb) + observe_property(menu, 'audio-device', 'string', device_cb) end -- build playlist item title @@ -299,10 +301,10 @@ local function build_playlist_title(item, id) end -- handle #@playlist menu update -local function update_playlist_menu(item) - local submenu = to_submenu(item) +local function update_playlist_menu(menu) + local submenu = to_submenu(menu.item) - mp.observe_property('playlist', 'native', function(_, playlist) + local function playlist_cb(_, playlist) for i = #submenu, 1, -1 do table.remove(submenu, i) end menu_items_dirty = true if not playlist or #playlist == 0 then return end @@ -314,14 +316,16 @@ local function update_playlist_menu(item) state = item.current and { 'checked' } or {}, } end - end) + end + + observe_property(menu, 'playlist', 'native', playlist_cb) end -- handle #@profiles menu update -local function update_profiles_menu(item) - local submenu = to_submenu(item) +local function update_profiles_menu(menu) + local submenu = to_submenu(menu.item) - mp.observe_property('profile-list', 'native', function(_, profile_list) + local function profile_list_cb(_, profile_list) for i = #submenu, 1, -1 do table.remove(submenu, i) end menu_items_dirty = true if not profile_list or #profile_list == 0 then return end @@ -335,11 +339,14 @@ local function update_profiles_menu(item) } end end - end) + end + + observe_property(menu, 'profile-list', 'native', profile_list_cb) end -- handle #@prop:check -function update_check_status(item, keyword) +function update_check_status(menu, keyword) + local item = menu.item local prop, e = keyword:match('^([%w-]+):check(!?)$') if not prop then return false end @@ -351,40 +358,49 @@ function update_check_status(item, keyword) if tp == 'table' then return #v > 0 end return v ~= nil end - mp.observe_property(prop, 'native', function(name, value) + + local function prop_cb(_, value) local ok = check(value) if e == '!' then ok = not ok end item.state = ok and { 'checked' } or {} menu_items_dirty = true - end) + end + + observe_property(menu, prop, 'native', prop_cb) return true end -- update dynamic menu item and handle update local function dyn_menu_update(item, keyword) - if update_check_status(item, keyword) then return end + local menu = { + item = item, + fns = {}, + } + dyn_menus[keyword] = menu + + if update_check_status(menu, keyword) then return end if keyword == 'tracks' then - update_tracks_menu(item) + update_tracks_menu(menu) elseif keyword == 'tracks/video' then - update_track_menu(item, "video", "vid") + update_track_menu(menu, "video", "vid") elseif keyword == 'tracks/audio' then - update_track_menu(item, "audio", "aid") + update_track_menu(menu, "audio", "aid") elseif keyword == 'tracks/sub' then - update_track_menu(item, "sub", "sid") + update_track_menu(menu, "sub", "sid") elseif keyword == 'tracks/sub-secondary' then - update_track_menu(item, "sub", "secondary-sid") + update_track_menu(menu, "sub", "secondary-sid") elseif keyword == 'chapters' then - update_chapters_menu(item) + update_chapters_menu(menu) elseif keyword == 'editions' then - update_editions_menu(item) + update_editions_menu(menu) elseif keyword == 'audio-devices' then - update_audio_devices_menu(item) + update_audio_devices_menu(menu) elseif keyword == 'playlist' then - update_playlist_menu(item) + update_playlist_menu(menu) elseif keyword == 'profiles' then - update_profiles_menu(item) + update_profiles_menu(menu) end end @@ -409,12 +425,13 @@ local function dyn_menu_check(items) end -- menu data update callback -local function update_menu(name, items) +local function menu_data_cb(name, items) if not items or #items == 0 then return end - mp.unobserve_property(update_menu) + mp.unobserve_property(menu_data_cb) menu_items = items dyn_menu_check(menu_items) + mp.commandv('script-message', 'menu-ready') end -- commit menu items if changed @@ -425,6 +442,45 @@ local function update_menu_items() end end +-- script message: get +mp.register_script_message('get', function(keyword, src) + if not src or src == '' then + msg.warn('get: ignored message with empty src') + return + end + + local menu = dyn_menus[keyword] + local reply = { keyword = keyword } + if menu then reply.item = menu.item else reply.error = 'keyword not found' end + mp.commandv('script-message-to', src, 'menu-get-reply', utils.format_json(reply)) +end) + +-- script message: update +mp.register_script_message('update', function(keyword, json) + local menu = dyn_menus[keyword] + if not menu then return end + local item = menu.item + + local data, err = utils.parse_json(json) + if not data and err then + msg.error('update: failed to parse json: ' .. err) + return + end + if not data.title or data.title == '' then data.title = item.title end + if not data.type or data.type == '' then data.type = item.type end + + -- remove old property observers to avoid conflicts + if #menu.fns > 0 then + for _, fn in ipairs(menu.fns) do mp.unobserve_property(fn) end + menu.fns = {} + end + + for k, _ in pairs(item) do item[k] = nil end + for k, v in pairs(data) do item[k] = v end + + menu_items_dirty = false +end) + -- update menu items when idle, this reduces the update frequency mp.register_idle(update_menu_items) @@ -435,6 +491,7 @@ mp.register_idle(update_menu_items) -- scripts that also update the menu data property. if #menu_items > 0 then dyn_menu_check(menu_items) + mp.commandv('script-message', 'menu-ready') else - mp.observe_property(menu_prop, 'native', update_menu) + mp.observe_property(menu_prop, 'native', menu_data_cb) end