summaryrefslogtreecommitdiff
path: root/config/mpv/scripts/subs2srsa/subtitles
diff options
context:
space:
mode:
authornavewindre <boneyaard@gmail.com>2025-04-05 02:59:37 +0200
committernavewindre <boneyaard@gmail.com>2025-04-05 02:59:37 +0200
commitb24463f3d045783b8f4e72926054d53b908e150f (patch)
tree036f976e217128b9e4acf3854f72908c27dec17b /config/mpv/scripts/subs2srsa/subtitles
parent398e41be4daf339bd55862520c528a7d93b83fb6 (diff)
a
Diffstat (limited to 'config/mpv/scripts/subs2srsa/subtitles')
-rw-r--r--config/mpv/scripts/subs2srsa/subtitles/observer.lua344
-rw-r--r--config/mpv/scripts/subs2srsa/subtitles/secondary_sid.lua196
-rw-r--r--config/mpv/scripts/subs2srsa/subtitles/sub_list.lua74
-rw-r--r--config/mpv/scripts/subs2srsa/subtitles/subtitle.lua56
4 files changed, 670 insertions, 0 deletions
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