From b24463f3d045783b8f4e72926054d53b908e150f Mon Sep 17 00:00:00 2001 From: navewindre Date: Sat, 5 Apr 2025 02:59:37 +0200 Subject: a --- .../mpv/scripts/subs2srsa/subtitles/observer.lua | 344 +++++++++++++++++++++ .../scripts/subs2srsa/subtitles/secondary_sid.lua | 196 ++++++++++++ .../mpv/scripts/subs2srsa/subtitles/sub_list.lua | 74 +++++ .../mpv/scripts/subs2srsa/subtitles/subtitle.lua | 56 ++++ 4 files changed, 670 insertions(+) create mode 100644 config/mpv/scripts/subs2srsa/subtitles/observer.lua create mode 100644 config/mpv/scripts/subs2srsa/subtitles/secondary_sid.lua create mode 100644 config/mpv/scripts/subs2srsa/subtitles/sub_list.lua create mode 100644 config/mpv/scripts/subs2srsa/subtitles/subtitle.lua (limited to 'config/mpv/scripts/subs2srsa/subtitles') diff --git a/config/mpv/scripts/subs2srsa/subtitles/observer.lua b/config/mpv/scripts/subs2srsa/subtitles/observer.lua new file mode 100644 index 0000000..d9284c5 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/subtitles/observer.lua @@ -0,0 +1,344 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Observer waits for subtitles to appear on the screen and adds them to a list. +]] + +local h = require('helpers') +local timings = require('utils.timings') +local sub_list = require('subtitles.sub_list') +local Subtitle = require('subtitles.subtitle') +local mp = require('mp') +local platform = require('platform.init') +local switch = require('utils.switch') + +local self = {} + +local dialogs = sub_list.new() +local secondary_dialogs = sub_list.new() +local all_dialogs = sub_list.new() +local all_secondary_dialogs = sub_list.new() +local user_timings = timings.new() + +local append_dialogue = false +local autoclip_enabled = false +local autoclip_method = {} + + +------------------------------------------------------------ +-- private + +local function copy_primary_sub() + if autoclip_enabled then + autoclip_method.call() + end +end + +local function append_primary_sub() + local current_sub = Subtitle:now() + all_dialogs.insert(current_sub) + if append_dialogue and dialogs.insert(current_sub) then + self.menu:update() + end +end + +local function append_secondary_sub() + local current_secondary = Subtitle:now('secondary') + all_secondary_dialogs.insert(current_secondary) + if append_dialogue and secondary_dialogs.insert(Subtitle:now('secondary')) then + self.menu:update() + end +end + +local function start_appending() + append_dialogue = true + append_primary_sub() + append_secondary_sub() +end + +local function handle_primary_sub() + append_primary_sub() + copy_primary_sub() +end + +local function handle_secondary_sub() + append_secondary_sub() +end + +local function on_external_finish(success, result, error) + if success ~= true or error ~= nil then + h.notify("Command failed: " .. table.concat(result)) + end +end + +local function external_command_args(cur_lines) + local args = {} + for arg in string.gmatch(self.config.autoclip_custom_args, "%S+") do + if arg == '%MPV_PRIMARY%' then + arg = cur_lines.primary + elseif arg == '%MPV_SECONDARY%' then + arg = cur_lines.secondary + end + table.insert(args, arg) + end + return args +end + +local function call_external_command(cur_lines) + if not h.is_empty(self.config.autoclip_custom_args) then + h.subprocess(external_command_args(cur_lines), on_external_finish) + end +end + +local function current_subtitle_lines() + local primary = dialogs.get_text() + + if h.is_empty(primary) then + primary = mp.get_property("sub-text") + end + + if h.is_empty(primary) then + return nil + end + + local secondary = secondary_dialogs.get_text() + + if h.is_empty(secondary) then + secondary = mp.get_property("secondary-sub-text") or "" + end + + return { primary = self.clipboard_prepare(primary), secondary = secondary } +end + +local function ensure_goldendict_running() + --- Ensure that goldendict is running and is disowned by mpv. + --- Avoid goldendict getting killed when mpv exits. + if autoclip_enabled and self.autocopy_current_method() == "goldendict" then + os.execute("setsid -f goldendict") + end +end + +------------------------------------------------------------ +-- autoclip methods + +autoclip_method = (function() + local methods = { 'clipboard', 'goldendict', 'custom_command', } + local current_method = switch.new(methods) + + local function call() + local cur_lines = current_subtitle_lines() + if h.is_empty(cur_lines) then + return + end + + if current_method.get() == 'clipboard' then + self.copy_to_clipboard("autocopy action", cur_lines.primary) + elseif current_method.get() == 'goldendict' then + h.subprocess_detached({ 'goldendict', cur_lines.primary }, on_external_finish) + elseif current_method.get() == 'custom_command' then + call_external_command(cur_lines) + end + end + + return { + call = call, + get = current_method.get, + bump = current_method.bump, + set = current_method.set, + } +end)() + +local function copy_subtitle(subtitle_id) + self.copy_to_clipboard("copy-on-demand", mp.get_property(subtitle_id)) +end + +------------------------------------------------------------ +-- public + +self.copy_to_clipboard = function(_, text) + if platform.healthy == false then + h.notify(platform.clip_util .. " is not installed.", "error", 5) + end + if not h.is_empty(text) then + platform.copy_to_clipboard(self.clipboard_prepare(text)) + end +end + +self.clipboard_prepare = function(text) + text = self.config.clipboard_trim_enabled and h.trim(text) or h.remove_newlines(text) + text = self.maybe_remove_all_spaces(text) + return text +end + +self.maybe_remove_all_spaces = function(str) + if self.config.nuke_spaces == true and h.contains_non_latin_letters(str) then + return h.remove_all_spaces(str) + else + return str + end +end + +self.copy_current_primary_to_clipboard = function() + copy_subtitle("sub-text") +end + +self.copy_current_secondary_to_clipboard = function() + copy_subtitle("secondary-sub-text") +end + +self.user_altered = function() + --- Return true if the user manually set at least start or end. + return user_timings.is_set('start') or user_timings.is_set('end') +end + +self.get_timing = function(position) + if user_timings.is_set(position) then + return user_timings.get(position) + elseif not dialogs.is_empty() then + return dialogs.get_time(position) + end + return -1 +end + +self.collect_from_all_dialogues = function(n_lines) + local current_sub = Subtitle:now() + local current_secondary_sub = Subtitle:now('secondary') + all_dialogs.insert(current_sub) + all_secondary_dialogs.insert(current_secondary_sub) + if current_sub == nil then + return Subtitle:new() -- return a default empty new Subtitle to let consumer handle + end + local text, end_sub = all_dialogs.get_n_text(current_sub, n_lines) + local secondary_text, _ + if current_secondary_sub == nil then + secondary_text = '' + else + secondary_text, _ = all_secondary_dialogs.get_n_text(current_secondary_sub, n_lines) -- we'll use main sub's timing + end + return Subtitle:new { + ['text'] = text, + ['secondary'] = secondary_text, + ['start'] = current_sub['start'], + ['end'] = end_sub['end'], + } +end + +self.collect_from_current = function() + --- Return all recorded subtitle lines as one subtitle object. + --- The caller has to call subs_observer.clear() afterwards. + if dialogs.is_empty() then + dialogs.insert(Subtitle:now()) + end + if secondary_dialogs.is_empty() then + secondary_dialogs.insert(Subtitle:now('secondary')) + end + return Subtitle:new { + ['text'] = dialogs.get_text(), + ['secondary'] = secondary_dialogs.get_text(), + ['start'] = self.get_timing('start'), + ['end'] = self.get_timing('end'), + } +end + +self.set_manual_timing = function(position) + user_timings.set(position, mp.get_property_number('time-pos') - mp.get_property("audio-delay")) + h.notify(h.capitalize_first_letter(position) .. " time has been set.") + start_appending() +end + +self.set_manual_timing_to_sub = function(position) + local sub = Subtitle:now() + if sub then + user_timings.set(position, sub[position] - mp.get_property("audio-delay")) + h.notify(h.capitalize_first_letter(position) .. " time has been set.") + start_appending() + else + h.notify("There's no visible subtitle.", "info", 2) + end +end + +self.set_to_current_sub = function() + self.clear() + if Subtitle:now() then + start_appending() + h.notify("Timings have been set to the current sub.", "info", 2) + else + h.notify("There's no visible subtitle.", "info", 2) + end +end + +self.clear = function() + append_dialogue = false + dialogs = sub_list.new() + secondary_dialogs = sub_list.new() + user_timings = timings.new() +end + +self.clear_all_dialogs = function() + all_dialogs = sub_list.new() + all_secondary_dialogs = sub_list.new() +end + +self.clear_and_notify = function() + --- Clear then notify the user. + --- Called by the OSD menu when the user presses a button to drop recorded subtitles. + self.clear() + h.notify("Timings have been reset.", "info", 2) +end + +self.is_appending = function() + return append_dialogue +end + +self.recorded_subs = function() + return dialogs.get_subs_list() +end + +self.recorded_secondary_subs = function() + return secondary_dialogs.get_subs_list() +end + +self.autocopy_status_str = function() + return string.format( + "%s (%s)", + (autoclip_enabled and 'enabled' or 'disabled'), + autoclip_method.get():gsub('_', ' ') + ) +end + +self.autocopy_current_method = function() + return autoclip_method.get() +end + +local function notify_autocopy() + if autoclip_enabled then + copy_primary_sub() + end + h.notify(string.format("Clipboard autocopy has been %s.", self.autocopy_status_str())) +end + +self.toggle_autocopy = function() + autoclip_enabled = not autoclip_enabled + notify_autocopy() +end + +self.next_autoclip_method = function() + autoclip_method.bump() + notify_autocopy() +end + +self.init = function(menu, config) + self.menu = menu + self.config = config + + -- The autoclip state is copied as a local value + -- to prevent it from being reset when the user reloads the config file. + autoclip_enabled = self.config.autoclip + autoclip_method.set(self.config.autoclip_method) + + mp.observe_property("sub-text", "string", handle_primary_sub) + mp.observe_property("secondary-sub-text", "string", handle_secondary_sub) +end + +return self diff --git a/config/mpv/scripts/subs2srsa/subtitles/secondary_sid.lua b/config/mpv/scripts/subs2srsa/subtitles/secondary_sid.lua new file mode 100644 index 0000000..21576e8 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/subtitles/secondary_sid.lua @@ -0,0 +1,196 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +This module automatically finds and sets secondary sid if it's not already set. +Secondary sid will be shown when mouse is moved to the top part of the mpv window. +]] + +local mp = require('mp') +local h = require('helpers') + +local self = { + visibility = 'auto', + visibility_states = { auto = true, never = true, always = true, }, +} + +local function is_accepted_language(sub_lang) + -- for missing keys compares nil to true + return self.accepted_languages[sub_lang] == true +end + +local function is_selected_language(track, active_track) + return track.id == mp.get_property_native('sid') or (active_track and active_track.lang == track.lang) +end + +local function is_full(track) + return h.str_contains(track.title, 'full') +end + +local function is_garbage(track) + for _, keyword in pairs({ 'song', 'sign', 'caption', 'commentary' }) do + if h.str_contains(track.title, keyword) then + return true + end + end + return false +end + +local function prioritize_full_subs(tracks_list) + return table.sort(tracks_list, function(first, second) + return (is_full(first) and not is_full(second)) or (is_garbage(second) and not is_garbage(first)) + end) +end + +local function find_best_secondary_sid() + local active_track = h.get_active_track('sub') + local sub_tracks = h.get_loaded_tracks('sub') + prioritize_full_subs(sub_tracks) + for _, track in ipairs(sub_tracks) do + if is_accepted_language(track.lang) and not is_selected_language(track, active_track) then + return track.id + end + end + return nil +end + +local function window_height() + return mp.get_property_native('osd-dimensions/h') +end + +local function get_accepted_sub_langs() + local languages = {} + for lang in self.config.secondary_sub_lang:gmatch('[a-zA-Z-]+') do + languages[lang] = true + end + return languages +end + +local function on_mouse_move(_, state) + -- state = {x=int,y=int, hover=true|false, } + if mp.get_property_native('secondary-sid') and self.visibility == 'auto' and state ~= nil then + mp.set_property_bool( + 'secondary-sub-visibility', + state.hover and (state.y / window_height()) < self.config.secondary_sub_area + ) + end +end + +local function on_file_loaded() + -- If secondary sid is not already set, try to find and set it. + local secondary_sid = mp.get_property_native('secondary-sid') + if secondary_sid == false and self.config.secondary_sub_auto_load == true then + secondary_sid = find_best_secondary_sid() + if secondary_sid ~= nil then + mp.set_property_native('secondary-sid', secondary_sid) + end + end +end + +local function update_visibility() + mp.set_property_bool('secondary-sub-visibility', self.visibility == 'always') +end + +local function init(config) + self.config = config + self.visibility = config.secondary_sub_visibility + self.accepted_languages = get_accepted_sub_langs() + mp.register_event('file-loaded', on_file_loaded) + if config.secondary_sub_area > 0 then + mp.observe_property('mouse-pos', 'native', on_mouse_move) + end + update_visibility() +end + +local function change_visibility() + while true do + self.visibility = next(self.visibility_states, self.visibility) + if self.visibility ~= nil then + break + end + end + update_visibility() + h.notify("Secondary sid visibility: " .. self.visibility) +end + +local function compare_by_preference_then_id(track1, track2) + if is_accepted_language(track1.lang) and not is_accepted_language(track2.lang) then + return true + elseif not is_accepted_language(track1.lang) and is_accepted_language(track2.lang) then + return false + else + return (track1.id < track2.id) + end +end + +local function split_before_after(previous_tracks, next_tracks, all_tracks, current_track_id) + -- works like take_while() and drop_while() combined + local prev = true + for _, track in ipairs(all_tracks) do + if prev == true and track.id == current_track_id then + prev = false + end + if track.id ~= current_track_id then + if prev then + table.insert(previous_tracks, track) + else + table.insert(next_tracks, track) + end + end + end +end + +local function not_primary_sid(track) + return mp.get_property_native('sid') ~= track.id +end + +local function find_new_secondary_sub(direction) + local subtitle_tracks = h.filter(h.get_loaded_tracks('sub'), not_primary_sid) + table.sort(subtitle_tracks, compare_by_preference_then_id) + + local secondary_sid = mp.get_property_native('secondary-sid') + local new_secondary_sub = { id = false, title = "removed" } + + if #subtitle_tracks > 0 then + if not secondary_sid then + new_secondary_sub = (direction == 'prev') and subtitle_tracks[#subtitle_tracks] or subtitle_tracks[1] + else + local previous_tracks = {} + local next_tracks = {} + split_before_after(previous_tracks, next_tracks, subtitle_tracks, secondary_sid) + if direction == 'prev' and #previous_tracks > 0 then + new_secondary_sub = previous_tracks[#previous_tracks] + elseif direction == 'next' and #next_tracks > 0 then + new_secondary_sub = next_tracks[1] + end + end + end + return new_secondary_sub +end + +local function switch_secondary_sid(direction) + local new_secondary_sub = find_new_secondary_sub(direction) + + mp.set_property_native('secondary-sid', new_secondary_sub.id) + if new_secondary_sub.id == false then + h.notify("Removed secondary sid.") + else + h.notify(string.format( + "Secondary #%d: %s (%s)", + new_secondary_sub.id, + new_secondary_sub.title or "No title", + new_secondary_sub.lang or "Unknown" + )) + end +end + +return { + init = init, + change_visibility = change_visibility, + select_previous = function() + switch_secondary_sid('prev') + end, + select_next = function() + switch_secondary_sid('next') + end, +} diff --git a/config/mpv/scripts/subs2srsa/subtitles/sub_list.lua b/config/mpv/scripts/subs2srsa/subtitles/sub_list.lua new file mode 100644 index 0000000..de7e1c2 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/subtitles/sub_list.lua @@ -0,0 +1,74 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Subtitle list remembers selected subtitle lines. +]] + +local h = require('helpers') + +local new_sub_list = function() + local subs_list = {} + + local find_i = function(sub) + for i, v in ipairs(subs_list) do + if sub < v then + return i + end + end + return #subs_list + 1 + end + local get_time = function(position) + local i = position == 'start' and 1 or #subs_list + return subs_list[i][position] + end + local get_text = function() + local speech = {} + for _, sub in ipairs(subs_list) do + table.insert(speech, sub['text']) + end + return table.concat(speech, ' ') + end + local get_n_text = function(sub, n_lines) + local speech = {} + local end_sub = sub + for _, v in ipairs(subs_list) do + if v['start'] - end_sub['end'] >= 20 then + break + end + if v >= sub and #speech < n_lines then + table.insert(speech, v['text']) + end_sub = v + end + end + return table.concat(speech, ' '), end_sub + end + local insert = function(sub) + if sub ~= nil and not h.contains(subs_list, sub) then + table.insert(subs_list, find_i(sub), sub) + return true + end + return false + end + local get_subs_list = function() + local copy = {} + for key, value in pairs(subs_list) do + copy[key] = value + end + return copy + end + return { + get_subs_list = get_subs_list, + get_time = get_time, + get_text = get_text, + get_n_text = get_n_text, + insert = insert, + is_empty = function() + return h.is_empty(subs_list) + end, + } +end + +return { + new = new_sub_list, +} diff --git a/config/mpv/scripts/subs2srsa/subtitles/subtitle.lua b/config/mpv/scripts/subs2srsa/subtitles/subtitle.lua new file mode 100644 index 0000000..4e8df41 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/subtitles/subtitle.lua @@ -0,0 +1,56 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Subtitle class provides methods for storing and comparing subtitle lines. +]] + +local mp = require('mp') + +local Subtitle = { + ['text'] = '', + ['secondary'] = '', + ['start'] = -1, + ['end'] = -1, +} + +function Subtitle:new(o) + o = o or {} + setmetatable(o, self) + self.__index = self + return o +end + +function Subtitle:now(secondary) + local prefix = secondary and "secondary-" or "" + local this = self:new { + ['text'] = mp.get_property(prefix .. "sub-text"), + ['start'] = mp.get_property_number(prefix .. "sub-start"), + ['end'] = mp.get_property_number(prefix .. "sub-end"), + } + if this:is_valid() then + return this:delay(mp.get_property_native("sub-delay") - mp.get_property_native("audio-delay")) + else + return nil + end +end + +function Subtitle:delay(delay) + self['start'] = self['start'] + delay + self['end'] = self['end'] + delay + return self +end + +function Subtitle:is_valid() + return self['start'] and self['end'] and self['start'] >= 0 and self['end'] > self['start'] +end + +Subtitle.__eq = function(lhs, rhs) + return lhs['text'] == rhs['text'] +end + +Subtitle.__lt = function(lhs, rhs) + return lhs['start'] < rhs['start'] +end + +return Subtitle -- cgit v1.2.3