diff --git a/.config/jellyfin-mpv-shim/conf.json b/.config/jellyfin-mpv-shim/conf.json new file mode 100644 index 0000000..57f426f --- /dev/null +++ b/.config/jellyfin-mpv-shim/conf.json @@ -0,0 +1,114 @@ +{ + "allow_transcode_to_h265": true, + "always_transcode": false, + "audio_output": "hdmi", + "auto_play": false, + "check_updates": true, + "client_uuid": "a39904c0-f818-41d4-ab53-d72cb01bee16", + "connect_retry_mins": 0, + "direct_paths": false, + "discord_presence": false, + "display_mirroring": false, + "enable_gui": true, + "enable_osc": true, + "force_audio_codec": null, + "force_set_played": false, + "force_video_codec": "['av1', 'h265', 'h264']", + "fullscreen": true, + "health_check_interval": 300, + "idle_cmd": null, + "idle_cmd_delay": 60, + "idle_ended_cmd": null, + "idle_when_paused": false, + "ignore_ssl_cert": false, + "kb_debug": "~", + "kb_fullscreen": "f", + "kb_kill_shader": "k", + "kb_menu": "c", + "kb_menu_down": "down", + "kb_menu_esc": "esc", + "kb_menu_left": "left", + "kb_menu_ok": "enter", + "kb_menu_right": "right", + "kb_menu_up": "up", + "kb_next": ">", + "kb_pause": "space", + "kb_prev": "<", + "kb_stop": "q", + "kb_unwatched": "u", + "kb_watched": "w", + "lang": null, + "lang_filter": "und,eng,jpn,mis,mul,zxx", + "lang_filter_audio": false, + "lang_filter_sub": false, + "local_kbps": 2147483, + "log_decisions": false, + "media_ended_cmd": null, + "media_key_seek": false, + "media_keys": true, + "menu_mouse": true, + "mpv_ext": false, + "mpv_ext_ipc": null, + "mpv_ext_no_ovr": false, + "mpv_ext_path": null, + "mpv_ext_start": true, + "mpv_log_level": "info", + "notify_updates": true, + "play_cmd": null, + "playback_timeout": 30, + "player_name": "cn-arch", + "pre_media_cmd": null, + "prefer_transcode_to_h265": true, + "raise_mpv": true, + "remote_direct_paths": false, + "remote_kbps": 25000, + "sanitize_output": true, + "screenshot_dir": null, + "screenshot_menu": true, + "seek_down": -60, + "seek_h_exact": false, + "seek_left": -5, + "seek_right": 5, + "seek_up": 60, + "seek_v_exact": false, + "shader_pack_custom": false, + "shader_pack_enable": true, + "shader_pack_profile": null, + "shader_pack_remember": true, + "shader_pack_subtype": "lq", + "skip_credits_always": false, + "skip_credits_enable": true, + "skip_intro_always": true, + "skip_intro_enable": true, + "stop_cmd": null, + "stop_idle": false, + "subtitle_color": "#FFFFFFFF", + "subtitle_position": "bottom", + "subtitle_size": 100, + "svp_enable": false, + "svp_socket": null, + "svp_url": "http://127.0.0.1:9901/", + "sync_attempts": 5, + "sync_max_delay_skip": 300, + "sync_max_delay_speed": 50, + "sync_method_thresh": 2000, + "sync_osd_message": true, + "sync_revert_seek": true, + "sync_speed_attempts": 3, + "sync_speed_time": 1000, + "thumbnail_enable": true, + "thumbnail_osc_builtin": true, + "thumbnail_preferred_size": 320, + "tls_client_cert": null, + "tls_client_key": null, + "tls_server_ca": null, + "transcode_4k": false, + "transcode_av1": false, + "transcode_dolby_vision": true, + "transcode_hdr": true, + "transcode_hevc": false, + "transcode_hi10p": false, + "transcode_warning": true, + "use_web_seek": false, + "write_logs": false +} \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/cred.json b/.config/jellyfin-mpv-shim/cred.json new file mode 100644 index 0000000..a612971 --- /dev/null +++ b/.config/jellyfin-mpv-shim/cred.json @@ -0,0 +1 @@ +[{"address": "https://j.someho.me", "Name": "IdleJelly", "Id": "cef99817a4124734937e2fb674ce2065", "Version": "10.10.7", "DateLastAccessed": "2025-05-10T01:31:14Z", "UserId": "0eb92b7cdbeb489b9ea54be1757579b8", "AccessToken": "1ebec612c3a64a61889fc4926a057cfd", "Users": [{"Id": "0eb92b7cdbeb489b9ea54be1757579b8", "IsSignedInOffline": true}], "uuid": "72aeed57-a0eb-4cf3-bf1f-3601b70036ce", "username": "vikingowl", "connected": true}] \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/fonts/uosc_icons.otf b/.config/jellyfin-mpv-shim/fonts/uosc_icons.otf new file mode 120000 index 0000000..cf7dac5 --- /dev/null +++ b/.config/jellyfin-mpv-shim/fonts/uosc_icons.otf @@ -0,0 +1 @@ +/usr/share/mpv/fonts/uosc_icons.otf \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/fonts/uosc_textures.ttf b/.config/jellyfin-mpv-shim/fonts/uosc_textures.ttf new file mode 120000 index 0000000..9cb096a --- /dev/null +++ b/.config/jellyfin-mpv-shim/fonts/uosc_textures.ttf @@ -0,0 +1 @@ +/usr/share/mpv/fonts/uosc_textures.ttf \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/input.conf b/.config/jellyfin-mpv-shim/input.conf new file mode 100644 index 0000000..a322dce --- /dev/null +++ b/.config/jellyfin-mpv-shim/input.conf @@ -0,0 +1,4 @@ +# Press 'n' to toggle the default audio filter on/off +n af toggle "dynaudnorm=f=150:g=15:p=0.9" +# Press 'Shift+N' to toggle the default compressor/normalizer chain on/off +N af toggle "lavfi=[acompressor=threshold=-18dB:ratio=5:attack=20:release=250,loudnorm=I=-16:LRA=11:TP=-1.5]" diff --git a/.config/jellyfin-mpv-shim/mpv.conf b/.config/jellyfin-mpv-shim/mpv.conf new file mode 100644 index 0000000..741af5b --- /dev/null +++ b/.config/jellyfin-mpv-shim/mpv.conf @@ -0,0 +1,50 @@ +### Allgemeine Video-Einstellungen +volume=80 +profile=gpu-hq +fs +video-sync=display-resample +scale=ewa_lanczossharp +cscale=ewa_lanczossharp + +# Vollständiges Deaktivieren aller mpv Standard-UI +osc=no +osd-level=0 +osd-bar=no +border=no + +# Keine OSD-Nachrichten +osd-playing-msg= +osd-status-msg= +osd-msg1= +osd-msg2= +osd-msg3= + +# Terminal-Output minimieren +quiet +msg-level=all=no + +# uosc Konfiguration +script-opts=uosc-scroll-text=no + +### Rest deiner Konfiguration... +hwdec=auto-copy +gpu-context=wayland +gpu-api=auto + +# Streaming & Netzwerk... +stream-lavf-o-append=reconnect_on_http_error=4xx,5xx +stream-lavf-o-append=reconnect_on_network_error=yes +stream-lavf-o-append=reconnect_streamed=yes +stream-lavf-o-append=reconnect_delay_max=30 + +# Farbprofile... +#[HDR] +#profile-cond=get("video-params/primaries") == "bt.2020" and get("video-params/gamma") == "pq" +#profile-restore=copy +#target-colorspace-hint=yes + +#[SDR] +#profile-cond=get("video-params/primaries") == "bt.709" +#profile-restore=copy +#target-trc=srgb + diff --git a/.config/jellyfin-mpv-shim/script-opts b/.config/jellyfin-mpv-shim/script-opts new file mode 120000 index 0000000..0df4bcb --- /dev/null +++ b/.config/jellyfin-mpv-shim/script-opts @@ -0,0 +1 @@ +/usr/share/mpv/script-opts \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/README.md b/.config/jellyfin-mpv-shim/scripts/README.md new file mode 120000 index 0000000..b6b1cce --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/README.md @@ -0,0 +1 @@ +/usr/share/mpv/scripts/README.md \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/acompressor.lua b/.config/jellyfin-mpv-shim/scripts/acompressor.lua new file mode 120000 index 0000000..c3ab129 --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/acompressor.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/acompressor.lua \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/ao-null-reload.lua b/.config/jellyfin-mpv-shim/scripts/ao-null-reload.lua new file mode 120000 index 0000000..d20980b --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/ao-null-reload.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/ao-null-reload.lua \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/autocrop.lua b/.config/jellyfin-mpv-shim/scripts/autocrop.lua new file mode 120000 index 0000000..0364c44 --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/autocrop.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/autocrop.lua \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/autodeint.lua b/.config/jellyfin-mpv-shim/scripts/autodeint.lua new file mode 120000 index 0000000..870b172 --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/autodeint.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/autodeint.lua \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/autoload.lua b/.config/jellyfin-mpv-shim/scripts/autoload.lua new file mode 120000 index 0000000..d6de290 --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/autoload.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/autoload.lua \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/cycle-deinterlace-pullup.lua b/.config/jellyfin-mpv-shim/scripts/cycle-deinterlace-pullup.lua new file mode 120000 index 0000000..dedb5bc --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/cycle-deinterlace-pullup.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/cycle-deinterlace-pullup.lua \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/dynamic-crop.lua b/.config/jellyfin-mpv-shim/scripts/dynamic-crop.lua new file mode 100644 index 0000000..8cbffec --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/dynamic-crop.lua @@ -0,0 +1,837 @@ +--[[ +This script uses the lavfi cropdetect filter to automatically insert a crop filter with appropriate parameters + for the currently playing video, the script run continuously by default (mode 4). + +To use this script, "hwdec=no" (mpv default/recommended) or any "-copy" variant like "hwdec=auto-copy" is required, + consider editing "mpv.conf" to an appropriate value. + +The workflow is as follows: We observe ffmpeg log to collect metadata and process it. + Collected metadata are stored sequentially in s.buffer, then process to check and + store trusted values to speed up future change for the current video. + It will automatically crop the video as soon as a change is validated. + +The default options can be overridden by adding a line into "mpv.conf" with: + script-opts-append=-= + script-opts-append=dynamic_crop-mode=0 + script-opts-append=dynamic_crop-ratios=2.4 2.39 2 4/3 (quotes aren't needed like below) + +Extended descriptions for some parameters (For default values, see ): + +mode: [0-4] 0 disable, 1 on-demand, 2 one-shot, 3 dynamic-manual, 4 dynamic-auto + Mode 1 and 3 requires using the shortcut to start, 2 and 4 have an automatic start. + +Shortcut "C" (shift+c) to control the script. +Cycle between ENABLE / DISABLE_WITH_CROP / DISABLE + +prevent_change_mode: [0-3] 0 disable 1 keep-largest, 2 keep-lowest, 3 keep-latest + The prevent_change_timer is trigger after a change. + +fix_windowed_behavior: [0-3] Avoid the default behavior that resizes the window to the source size + when the crop filter changes in windowed/maximized mode by adjusting geometry. + +limit_timer: Only used if the cropdetect filter doesn't handle limit changes with a command (patch 01/2023). + Extend the time between each limit change to reduce the impact on performance caused by re-initializing the + full filter. + +read_ahead_mode: Linked to the associated timer and tells how much time in advance to collect the metadata. + This feature is useful for videos with multiple aspect ratio changes for "fast_change_timer". + Note: because this function is in sync with the playback, a delay equivalent to the timer used is + added/reset every time you seek before you get a reaction, so setting 1 is recommanded. + Required at least https://github.com/FFmpeg/FFmpeg/commit/69c060bea21d3b4ce63b5fff40d37e98c70ab88f + and optionally https://github.com/mpv-player/mpv/pull/11182, until mpv patch is being merged to master, + considered this feature experimental because of the errors generated in logs/console by vf-command and + the filter used to sync the filter chain (psnr). + +read_ahead_sync: Compensates for the delay when applying the crop filter and the visible result. + Must be adjusted to your tastes and each MPV client depending on their reaction time. + Note: Perfect adjustment is not really possible but generally <= 1 frame, sometimes more in + dark/ambiguous scenes. + +segmentation: e.g. 0.5 for 50% - Extra time to allow new metadata to be segmented instead of being continuous. + This is used with ratio_timer, offset_timer and fallback_timer. + e.g. ratio_timer is validated with 5 sec accumulated over 7.5 sec elapsed. +]] -- +require "mp.options" + +-- options +local options = { + -- behavior + mode = 4, -- [0-4] more details above + start_delay = 0, -- delay in seconds used to skip intro (usefull with mode 2) + prevent_change_timer = 30, -- seconds + prevent_change_mode = 0, -- [0-3], more details above + fix_windowed_behavior = 1, -- [0-3], 0 no-fix, 1 fix-no-resize, 2 fix-keep-width, 3 fix-keep-height + limit_timer = 0.5, -- seconds, 0 disable, more details above + fast_change_timer = 0.2, -- seconds, recommanded to keep default or > 0 if read_ahead is supported by mpv + ratio_timer = 2, -- seconds, meta in ratios list + offset_timer = 20, -- seconds, >= 'ratio_timer', new offset for asymmetric video + fallback_timer = 40, -- seconds, >= 'offset_timer', not in ratios list and possibly with new offset + linked_tolerance = 2, -- int, scale with detect_round to match against source width/height + ratios = "2.76 2.55 24/9 2.4 2.39 2.35 2.2 2.1 2 1.9 1.85 16/9 5/3 1.5 1.43 4/3 1.25 9/16", -- list + ratio_tolerance = 2, -- int (even number), adjust in order to match more easly the ratios list + read_ahead_mode = 0, -- [0-2], 0 disable, 1 fast_change_timer, 2 ratio_timer, more details above + read_ahead_sync = 0, -- int/frame, increase for advance, more details above + segmentation = 0.5, -- [0.0-1] %, 0 will approved only a continuous metadata (strict) + crop_method = 1, -- 0 lavfi-crop (ffmpeg/filter), 1 video-crop (mpv/VO) + -- filter, see https://ffmpeg.org/ffmpeg-filters.html#cropdetect for details + detect_limit = 26, -- is the maximum use, increase it slowly if lighter black are present + detect_round = 2, -- even number + -- verbose + debug = false +} +read_options(options) + +if options.mode == 0 then + mp.msg.info("mode = 0, disable script.") + return +end + +-- forward declarations +local cleanup, on_toggle +local s = {} + +-- labels +local label_prefix = mp.get_script_name() +local labels = { + crop = string.format("%s-crop", label_prefix), cropdetect = string.format("%s-cropdetect", label_prefix) +} + +-- shifting decimal to +local LEFT, RIGHT = true, false +local function shifting_to(left, value) + local shift = 1e3 + return left and (value / shift) or value >= 1 and math.ceil(value * shift) or value * shift +end + +-- options: compute timer and other stuff +for k, v in pairs(options) do + local timer = string.match(tostring(k), "_timer") + if timer then options[k] = shifting_to(RIGHT, v) end +end +options.read_ahead_timer = + options.read_ahead_mode == 1 and options.fast_change_timer or options.read_ahead_mode == 2 and options.ratio_timer * + (1 + options.segmentation) or nil +options.read_ahead_cropdetect = options.read_ahead_timer and shifting_to(LEFT, options.read_ahead_timer) or nil +options.reverse_segmentation = 1 / (1 * (1 + options.segmentation)) +options.crop_method_sync = options.crop_method == 0 and 1 or 0 -- lavfi-crop is slower, so give it some advance for read_ahead + +local function print_debug(msg_type, meta, label) + if not options.debug then + return + elseif msg_type == "pre_format" then + mp.msg.info(meta) + elseif msg_type == "metadata" then + mp.msg.info(string.format("%s, %-29s | offX:%3s offY:%3s | limit:%-2s", label, meta.whxy, meta.offset.x, + meta.offset.y, s.limit.current)) + elseif msg_type == "buffer" and s.stats.buffer then + mp.msg.info("Buffer stats:") + for whxy, ref in pairs(s.stats.buffer) do + mp.msg.info(string.format( + "\\ %-29s | offX=%4s offY=%4s | time=%6ss linked_source=%-4s known_ratio=%-4s trusted_offsets=%s", whxy, + ref.offset.x, ref.offset.y, shifting_to(LEFT, ref.time.buffer), ref.is_linked_to_source or false, + ref.is_known_ratio or false, ref.is_trusted_offsets)) + end + mp.msg.info("Buffer list:") + for i, v in ipairs(s.buffer.indexed_list) do + local new_ref = v.new_ref and v.new_ref.whxy or "" + local pts = shifting_to(RIGHT, v.pts) + mp.msg.info(string.format("\\ %3s %-29s %4sms pts:%d new_ref:%s", i, v.ref.whxy, v.t_elapsed, pts, new_ref)) + end + mp.msg.info("i_fallback", s.candidate.i_fallback) + mp.msg.info("i_offset", s.candidate.i_offset) + elseif msg_type == "applied" and s.stats.indexed_applied then + mp.msg.info("Applied list:") + for i, v in ipairs(s.stats.indexed_applied) do + mp.msg.info(string.format("\\ %3s %-29s pts:%d", i, v.ref.whxy, shifting_to(RIGHT, v.pts))) + end + end +end + +local function print_stats() + if not s.stats and not s.stats.trusted then return end + mp.msg.info("Meta Stats:") + local offsets_list = {x = "", y = ""} + for axis, _ in pairs(offsets_list) do + for _, v in pairs(s.stats.trusted_offset[axis]) do offsets_list[axis] = offsets_list[axis] .. v .. " " end + end + mp.msg.info( + string.format("Limit - min/max: %s/%s | counter: %s", s.limit.min, options.detect_limit, s.limit.counter)) + mp.msg.info(string.format("Trusted - unique: %s | offset: X:%sY:%s", s.stats.trusted_unique, offsets_list.x, + offsets_list.y)) + for whxy, ref in pairs(s.stats.trusted) do + if s.stats.trusted[whxy] then + mp.msg.info(string.format("\\ %-29s | offX=%3s offY=%3s | applied=%s overall=%ss accumulated=%ss", whxy, + ref.offset.x, ref.offset.y, ref.applied, shifting_to(LEFT, ref.time.overall), + shifting_to(LEFT, ref.time.accumulated))) + end + end + mp.msg.info("Buffer - unique: " .. s.stats.buffer_unique .. " | total: " .. s.buffer.i_total, + shifting_to(LEFT, s.buffer.t_total) .. "s | known_ratio:", s.buffer.i_ratio, + shifting_to(LEFT, s.buffer.t_ratio) .. "s") +end + +local function is_trusted_offset(offset, axis) + local trusted_offset = s.stats.trusted_offset[axis] + for _, v in ipairs(trusted_offset) do if math.abs(offset - v) <= 1 then return true end end + return false +end + +local function is_cropable() + for _, track in pairs(mp.get_property_native('track-list')) do + if track.type == 'video' and track.selected then return not track.albumart end + end + return false +end + +local function filter_state(label, key, value) + local filters = mp.get_property_native("vf") + for _, filter in pairs(filters) do + if filter["label"] == label and + ((not key or key ~= "graph" and filter[key] == value or key == "graph" and + string.find(filter.params.graph, value))) then return true end + end + return false +end + +local function command_filter(label, command, argument, target) + if not s.f_vfcommand then + local res, reason = mp.commandv("vf-command", label, command, argument, target) + if not res and reason == "invalid parameter" then + s.f_vfcommand = true -- if mpv doesn't handle target parameter + end + end + if s.f_vfcommand then + -- fallback and send to all filters inside the graph + mp.commandv("vf-command", label, command, argument) + end +end + +local function insert_cropdetect_filter(limit, change) + if s.toggled > 1 or s.paused then return end + local function insert_filter() + local cropdetect = string.format("cropdetect@dyn_cd=limit=%d/255:round=%d:reset=1", limit, options.detect_round) + if s.f_limit_runtime and change then + command_filter(labels.cropdetect, "limit", string.format("%d/255", limit), "cropdetect") + return true + elseif s.f_limit_runtime and options.read_ahead_mode > 0 then + return mp.commandv("vf", "pre", + string.format("@%s:lavfi=[split[a][b];[b]setpts=PTS-%s/TB,%s[b];%s]", labels.cropdetect, + options.read_ahead_cropdetect, cropdetect, s.f_sync)) + else + return mp.commandv("vf", "pre", string.format("@%s:lavfi=[split[a][b];[b]%s,nullsink;[a]null]", + labels.cropdetect, cropdetect)) + end + end + if not insert_filter() then + mp.msg.error("Does vf=help as #1 line in mvp.conf return libavfilter list with crop/cropdetect in log?") + s.f_missing = true + cleanup() + return + end + if not s.f_limit_runtime then + s.f_inserted = true -- skip process and wait for new s.collected + end + s.f_limit_change = change -- filter is updated for limit change +end + +local function apply_crop(ref, pts) + -- osd size change + -- TODO add auto/smart mode + local prop_fullscreen = mp.get_property("fullscreen") + if prop_fullscreen ~= "yes" and options.fix_windowed_behavior ~= 0 then + local prop_maximized = mp.get_property("window-maximized") + local osd = mp.get_property_native("osd-dimensions") + local prop_auto_window_resize = mp.get_property("auto-window-resize") + if prop_auto_window_resize == "yes" and options.fix_windowed_behavior == 1 then + -- disable auto resize to avoid resizing at the original size of the video + mp.set_property("auto-window-resize", "no") + end + if prop_maximized ~= "yes" then + if options.fix_windowed_behavior == 2 then + mp.set_property("geometry", string.format("%s", osd.w)) + elseif options.fix_windowed_behavior == 3 then + mp.set_property("geometry", string.format("x%s", osd.h)) + end + end + end + + -- crop filter insertion/update + if s.f_video_crop then + mp.set_property("video-crop", string.format("%sx%s+%s+%s", ref.w, ref.h, ref.x, ref.y)) + elseif filter_state(labels.crop) and not s.seeking then + for _, axis in ipairs({"w", "x", "h", "y"}) do -- "w""x" then "h""y" to reduce visual glitch + if s.applied[axis] ~= ref[axis] then command_filter(labels.crop, axis, ref[axis], "crop") end + end + else + mp.commandv("vf", "append", string.format("@%s:lavfi-crop=%s", labels.crop, ref.whxy)) + end + ref.applied = ref.applied + 1 + s.applied = ref + + print_debug("pre_format", string.format("- Apply: %s", ref.whxy)) + if options.debug and pts then table.insert(s.stats.indexed_applied, {ref = ref, pts = pts}) end +end + +local function compute_metadata(meta) + meta.whxy = string.format("w=%s:h=%s:x=%s:y=%s", meta.w, meta.h, meta.x, meta.y) + meta.offset = {x = meta.x - (s.source.w - meta.w) / 2, y = meta.y - (s.source.h - meta.h) / 2} + meta.mt = meta.y + meta.mb = s.source.h - meta.h - meta.y + meta.ml = meta.x + meta.mr = s.source.w - meta.w - meta.x + meta.is_source = meta.whxy == s.source.whxy + meta.is_invalid = meta.h < 0 or meta.w < 0 + meta.is_trusted_offsets = is_trusted_offset(meta.offset.x, "x") and is_trusted_offset(meta.offset.y, "y") + meta.time = {buffer = 0, overall = 0} + if options.read_ahead_mode > 0 then meta.pts = {} end + local margin = options.detect_round * options.linked_tolerance + meta.is_linked_to_source = meta.mt <= margin and meta.mb <= margin or meta.ml <= margin and meta.mr <= margin + if meta.is_linked_to_source and not meta.is_invalid and s.ratios.w[meta.w] or s.ratios.h[meta.h] then + meta.is_known_ratio = true + end + return meta +end + +local function generate_ratios(list) + for ratio in string.gmatch(list, "%S+%s?") do + for a, b in string.gmatch(tostring(ratio), "(%d+)/(%d+)") do ratio = a / b end + local w, h = math.floor((s.source.h * ratio)), math.floor((s.source.w / ratio)) + local margin = options.ratio_tolerance + for k, v in pairs({w = w, h = h}) do + if v < s.source[k] - options.linked_tolerance then + if v % 2 == 1 then + s.ratios[k][v + 1], s.ratios[k][v - 1] = true, true + if margin > 0 then + s.ratios[k][v + 1 + margin], s.ratios[k][v - 1 - margin] = true, true + end + else + s.ratios[k][v] = true + if margin > 0 then s.ratios[k][v + margin], s.ratios[k][v - margin] = true, true end + end + end + end + end +end + +local function switch_hwdec(id, hwdec, error) + if hwdec ~= "no" and not string.match(hwdec, "-copy") then + local msg = "Switch to SW decoding or HW -copy variant." + mp.msg.info(msg) + mp.osd_message(string.format("%s: %s", label_prefix, msg), 5) + end + if s.hwdec and hwdec ~= s.hwdec and s.hwdec ~= "no" and not string.match(s.hwdec, "-copy") and + filter_state(labels.cropdetect) then mp.commandv("vf", "remove", string.format("@%s", labels.cropdetect)) end + s.hwdec = hwdec +end + +local function process_metadata(collected, timestamp, elapsed_time) + s.in_progress = true -- prevent event race + print_debug("metadata", collected, "Collected") + + local function cleanup_stat(whxy, ref, ref_i, index) + if ref[whxy] then + ref[whxy] = nil + ref_i[index] = ref_i[index] - 1 + end + end + + -- buffer: init + if not s.stats.buffer[collected.whxy] then + s.stats.buffer[collected.whxy] = collected + s.stats.buffer_unique = s.stats.buffer_unique + 1 + end + + -- buffer: add collected or increase it's timer + if s.buffer.i_total == 0 or s.buffer.indexed_list[s.buffer.i_total].ref ~= collected then + s.buffer.i_total = s.buffer.i_total + 1 + s.buffer.i_ratio = s.buffer.i_ratio + 1 + s.buffer.indexed_list[s.buffer.i_total] = {ref = collected, pts = timestamp, t_elapsed = elapsed_time} + if options.read_ahead_mode > 0 then table.insert(collected.pts, timestamp) end + elseif s.last_collected == collected then + s.buffer.indexed_list[s.buffer.i_total].t_elapsed = s.buffer.indexed_list[s.buffer.i_total].t_elapsed + + elapsed_time + end + collected.time.overall = collected.time.overall + elapsed_time + collected.time.buffer = collected.time.buffer + elapsed_time + s.buffer.t_total = s.buffer.t_total + elapsed_time + if s.buffer.i_ratio > 0 then s.buffer.t_ratio = s.buffer.t_ratio + elapsed_time end + + -- candidate offset/fallback to later extend buffer size + if not s.stats.trusted[collected.whxy] and collected.time.buffer > options.ratio_timer and + collected.is_linked_to_source then + if not s.candidate.offset[collected.whxy] and not collected.is_trusted_offsets and collected.is_known_ratio then + s.candidate.offset[collected.whxy] = collected + s.candidate.i_offset = s.candidate.i_offset + 1 + elseif not collected.is_known_ratio and not s.candidate.offset[collected.whxy] and + not s.candidate.fallback[collected.whxy] then + s.candidate.fallback[collected.whxy] = collected + s.candidate.i_fallback = s.candidate.i_fallback + 1 + end + end + + -- add new fallback ratio to the ratio list + if s.candidate.fallback[collected.whxy] and collected.time.buffer >= options.fallback_timer then + -- TODO eventually re-check the buffer list with new ratio + generate_ratios(collected.w .. "/" .. collected.h) + collected.is_known_ratio = true + cleanup_stat(collected.whxy, s.candidate.fallback, s.candidate, "i_fallback") + end + + -- add new offset to the trusted_offsets list + if s.candidate.offset[collected.whxy] and collected.is_known_ratio and collected.is_linked_to_source and + collected.time.buffer >= options.offset_timer then + for _, axis in ipairs({"x", "y"}) do + if not is_trusted_offset(collected.offset[axis], axis) then + table.insert(s.stats.trusted_offset[axis], collected.offset[axis]) + end + end + cleanup_stat(collected.whxy, s.candidate.offset, s.candidate, "i_offset") + collected.is_trusted_offsets = true + end + + -- add collected ready to the trusted list + local new_ready = + not s.stats.trusted[collected.whxy] and collected.is_trusted_offsets and not collected.is_invalid and + collected.is_linked_to_source and collected.is_known_ratio and collected.time.buffer >= options.ratio_timer + if new_ready then + s.stats.trusted[collected.whxy] = collected + s.stats.trusted_unique = s.stats.trusted_unique + 1 + collected.applied = 0 + collected.time.accumulated = collected.time.buffer + end + + -- use current as main metadata, override by corrected or stabilized if needed + local current = collected + + -- correction with trusted metadata for fast change in dark/ambiguous scene + local corrected = {} + if not current.is_invalid and s.stats.trusted_unique > 1 and not s.stats.trusted[current.whxy] then + -- is_bigger than applied meta + corrected.is_bigger = current.mt < s.approved.mt or current.mb < s.approved.mb or current.ml < s.approved.ml or + current.mr < s.approved.mr + -- find closest trusted metadata + local closest = {} + local margin = options.detect_round * options.linked_tolerance + for _, ref in pairs(s.stats.trusted) do + local diff = {ref = ref, vs_current = 0, vs_applied = 0, total = 0} + for _, side in ipairs({"mt", "mb", "ml", "mr"}) do + diff[side] = current[side] - ref[side] + diff.total = diff.total + math.abs(diff[side]) + if diff[side] > margin or diff[side] < -margin then diff.vs_current = diff.vs_current + 1 end + if ref[side] ~= s.approved[side] then diff.vs_applied = diff.vs_applied + 1 end + end + -- is_inside this trusted meta with tiny tolerance for being outside + diff.is_inside = not (diff.mt < -margin or diff.mb < -margin or diff.ml < -margin or diff.mr < -margin) + local pattern = diff.is_inside and + (diff.vs_current <= 1 or diff.vs_current == 2 and diff.vs_applied <= 2 or + diff.vs_current > 2 and corrected.is_bigger) + local set = closest.ref and + (diff.vs_current < closest.vs_current or diff.vs_current == closest.vs_current and + diff.vs_applied < closest.vs_applied or diff.vs_current == closest.vs_current and + diff.vs_applied == closest.vs_applied and diff.total < closest.total) + -- mp.msg.info(string.format("\\ %-5s %-29s curr:%s appl:%s | %-3s %-3s %-3s %-3s %-4s | is_in:%s ", + -- pattern and (not closest.ref or set), ref.whxy, diff.vs_current, diff.vs_applied, diff.mt, diff.mb, + -- diff.ml, diff.mr, diff.total, diff.is_inside)) + if pattern and (not closest.ref or set) then closest = diff end + end + -- replace current with corrected + if closest.ref then + current = closest.ref + corrected.ref = closest.ref + s.buffer.indexed_list[s.buffer.i_total].new_ref = current + print_debug("metadata", current, "\\ Corrected") + else + print_debug("pre_format", "\\ Uncorrected") + end + end + + -- stabilization of odd/unstable meta + local stabilized + if options.detect_round <= 4 and s.stats.trusted[current.whxy] then + local margin = options.detect_round * 4 + local applied_in_margin = math.abs(current.w - s.approved.w) <= margin and math.abs(current.h - s.approved.h) <= + margin + for _, ref in pairs(s.stats.trusted) do + local in_margin = math.abs(current.w - ref.w) <= margin and math.abs(current.h - ref.h) <= margin + if in_margin then + local gt_applied = applied_in_margin and ref ~= s.approved and ref.time.overall > + s.approved.time.overall * 2 + local applied_gt = applied_in_margin and ref == s.approved and ref.time.overall * 2 > + current.time.overall + local pattern = not applied_in_margin and ref.time.overall > current.time.overall or gt_applied or + applied_gt + local set = stabilized and ref.time.overall > stabilized.time.overall + -- mp.msg.info("\\", ref.whxy, ref.time.overall, current.time.overall, s.approved.time.overall) + if ref ~= current and pattern and (not stabilized or set) then stabilized = ref end + end + end + if stabilized then + current = stabilized + s.buffer.indexed_list[s.buffer.i_total].new_ref = current + print_debug("metadata", current, "\\ Stabilized") + end + end + + -- cycle time.accumulated for fast_change_timer (reset if uncorrected) + for whxy, ref in pairs(s.stats.trusted) do + ref.time.accumulated = whxy ~= current.whxy and 0 or ref.time.accumulated < 0 and 0 + elapsed_time or + not new_ready and ref.time.accumulated + elapsed_time or ref.time.accumulated + end + + -- crop: final validation then store or apply it + local detect_source = current == s.last_current and (current.is_source or collected.is_source) and s.limit.target >= + 0 + local confirmation = not current.is_source and s.stats.trusted[current.whxy] and current.time.accumulated >= + options.fast_change_timer and (not corrected.ref or current == s.last_current) + local crop_filter = s.approved ~= current and (confirmation or detect_source) + if crop_filter and (not s.timestamps.prevent or timestamp >= s.timestamps.prevent) then + s.approved = current -- reflect s.applied for read_head + if s.limit.current < s.limit.min then + s.limit.min = s.limit.current -- store minimum limit + end + if s.f_limit_runtime and options.read_ahead_mode > 0 then + local pts = current.time.accumulated < options.ratio_timer and timestamp - current.time.accumulated or + current.pts[1] + table.insert(s.indexed_read_ahead, {ref = current, pts = pts}) + s.timestamps.read_ahead = nil + else + apply_crop(current, timestamp) + end + if options.prevent_change_mode > 0 then + s.timestamps.prevent = nil + if (options.prevent_change_mode == 1 and (current.w > s.approved.w or current.h > s.approved.h) or + options.prevent_change_mode == 2 and (current.w < s.approved.w or current.h < s.approved.h) or + options.prevent_change_mode == 3) then + s.timestamps.prevent = timestamp + options.prevent_change_timer + end + end + if options.mode <= 2 then on_toggle(true) end + end + + local function is_time_to_cleanup_buffer(time, target_time) + return time > target_time * (1 + options.segmentation) + end + + -- buffer: reduce size of known ratio stats + while is_time_to_cleanup_buffer(s.buffer.t_ratio, options.ratio_timer) do + local i = (s.buffer.i_total + 1) - s.buffer.i_ratio + s.buffer.t_ratio = s.buffer.t_ratio - s.buffer.indexed_list[i].t_elapsed + s.buffer.i_ratio = s.buffer.i_ratio - 1 + end + + -- buffer: check for candidate to extend it + local buffer_timer = s.candidate.i_offset > 0 and options.offset_timer or s.candidate.i_fallback > 0 and + options.fallback_timer or options.ratio_timer + + -- buffer: cleanup fake candidate + local function is_proactive_cleanup_needed() + local test + if is_time_to_cleanup_buffer(s.buffer.t_total, options.ratio_timer) then + for _, cat in ipairs({"offset", "fallback"}) do + if s.candidate["i_" .. cat] > 0 then + test = true + for whxy, ref in pairs(s.candidate[cat]) do + if ref.time.buffer > s.buffer.t_total * options.reverse_segmentation then + return false -- if at least one is a proper candidate + end + end + end + end + end + return test + end + + -- buffer: reduce total size + while is_time_to_cleanup_buffer(s.buffer.t_total, buffer_timer) or is_proactive_cleanup_needed() do + s.buffer.i_to_shift = s.buffer.i_to_shift + 1 + local entry = s.buffer.indexed_list[s.buffer.i_to_shift] + entry.ref.time.buffer = entry.ref.time.buffer - entry.t_elapsed + if options.read_ahead_mode > 0 then table.remove(entry.ref.pts, 1) end + if s.stats.buffer[entry.ref.whxy] and entry.ref.time.buffer == 0 then + cleanup_stat(entry.ref.whxy, s.stats.buffer, s.stats, "buffer_unique") + cleanup_stat(entry.ref.whxy, s.candidate.offset, s.candidate, "i_offset") + cleanup_stat(entry.ref.whxy, s.candidate.fallback, s.candidate, "i_fallback") + end + s.buffer.t_total = s.buffer.t_total - entry.t_elapsed + end + + -- buffer: shift the list to overwrite unused data + if s.buffer.i_to_shift >= 20 or s.buffer.i_to_shift == s.buffer.i_total then + for i = s.buffer.i_to_shift + 1, s.buffer.i_total do + s.buffer.indexed_list[i - s.buffer.i_to_shift] = s.buffer.indexed_list[i] + end + for i = 0, s.buffer.i_to_shift - 1 do s.buffer.indexed_list[s.buffer.i_total - i] = nil end + s.buffer.i_total = s.buffer.i_total - s.buffer.i_to_shift + s.buffer.i_to_shift = 0 + collectgarbage("step") + end + + -- limit: automatic adjustment + s.last_limit = s.limit.current + if s.f_limit_runtime or timestamp >= s.limit.timer then + s.limit.last_target = s.limit.target + if collected.is_source or current.is_source or corrected.is_bigger then + -- increase limit + s.limit.target = 1 + if s.limit.current + s.limit.step * s.limit.up <= options.detect_limit then + s.limit.current = s.limit.current + s.limit.step * s.limit.up + else + s.limit.current = options.detect_limit + end + elseif not current.is_invalid and + (collected.is_trusted_offsets or collected == s.last_collected or current == s.last_current) then + -- stable limit + s.limit.target = 0 + -- reset limit to help with different dark color + if not current.is_trusted_offsets then s.limit.current = options.detect_limit end + elseif s.limit.current > 0 then + -- decrease limit + s.limit.target = -1 + if s.limit.min < s.limit.current and s.limit.last_target == -1 then + s.limit.current = s.limit.min + elseif s.limit.current - s.limit.step >= 0 then + s.limit.current = s.limit.current - s.limit.step + else + s.limit.current = 0 + end + end + end + + -- store for next process + s.last_current = current + s.last_collected = collected + s.last_timestamp = timestamp + + -- limit: apply change + if s.last_limit ~= s.limit.current then + if not s.f_limit_runtime and options.limit_timer > 0 then s.limit.timer = timestamp + options.limit_timer end + s.limit.counter = s.limit.counter + 1 + insert_cropdetect_filter(s.limit.current, true) + end + + s.in_progress = false +end + +local function time_pos(event, value, err) + if value and s.indexed_read_ahead[1] then + local time_pos = shifting_to(RIGHT, value) + local deviation = math.abs(time_pos - s.pts) + local crop_sync = s.frametime * (options.read_ahead_sync + options.crop_method_sync) + local time_pos_read_ahead = time_pos - (options.read_ahead_timer - deviation - crop_sync) + if time_pos_read_ahead >= s.indexed_read_ahead[1].pts then + apply_crop(s.indexed_read_ahead[1].ref, s.indexed_read_ahead[1].pts) + table.remove(s.indexed_read_ahead, 1) + end + end +end + +local function collect_metadata(event) + if event.prefix == "ffmpeg" and event.level == "v" and string.find(event.text, "^.*dyn_cd: ") and + not (s.seeking or s.paused or s.toggled > 1) then + local tmp = {} + for k, v in string.gmatch(event.text, "(%w+):(%-?%d+%.?%d* )") do tmp[k] = tonumber(v) end + tmp.whxy = string.format("w=%d:h=%d:x=%d:y=%d", tmp.w, tmp.h, tmp.x, tmp.y) + s.pts = shifting_to(LEFT, tmp.pts) + if tmp.whxy ~= s.collected.whxy then + s.collected = s.stats.trusted[tmp.whxy] or s.stats.buffer[tmp.whxy] or compute_metadata(tmp) + end + + s.limit.last_collect = s.limit.collect + s.limit.collect = tmp.limit or s.limit.collect + s.f_limit_runtime = tmp.limit ~= nil -- if ffmpeg is patch for limit change at runtime + + s.timestamps.previous = s.timestamps.current + s.timestamps.current = s.pts + + local wait_limit = s.f_limit_runtime and s.f_limit_change and s.limit.collect == s.limit.last_collect + if not wait_limit then s.f_limit_change = false end + + if s.in_progress or not s.timestamps.previous or wait_limit or s.f_inserted or s.timestamps.current < + options.start_delay then + s.f_inserted = false + return + end + + local elapsed_time = s.timestamps.current - s.timestamps.previous + if not s.frametime or elapsed_time < s.frametime and elapsed_time > 0 then s.frametime = elapsed_time end + + process_metadata(s.collected, s.timestamps.current, elapsed_time) + end +end + +local function seek(event) + if s.seek_done then return end + print_debug("pre_format", string.format("Stop by %s event.", event)) + if event == "seek" or event == "toggle" then + s.timestamps = {} + s.limit.timer = 0 + s.approved = s.applied -- re-sync + if event == "seek" then + if s.f_limit_runtime then insert_cropdetect_filter(s.limit.current) end + if not s.f_video_crop and + (filter_state(labels.crop, "enabled", true) or not filter_state(labels.crop) and s.applied ~= s.source) then + apply_crop(s.applied) + end + end + if s.f_limit_runtime then + s.indexed_read_ahead = {} + s.collected = {} + end + s.seek_done = true -- avoid seek() in loop until we resume() + end +end + +local function resume(event) + s.seek_done = false + print_debug("pre_format", string.format("Resume by %s event.", event)) + if event == "toggle" and s.f_limit_runtime or not filter_state(labels.cropdetect) then + insert_cropdetect_filter(s.limit.current) + end +end + +local function playback_events(t, id, error) + if t.event == "seek" then + s.seeking = true + seek(t.event) + else + if not s.paused then resume(t.event) end + s.seeking = false + end +end + +local ENABLE, DISABLE_WITH_CROP, DISABLE = 1, 2, 3 +function on_toggle(auto) + if s.f_missing then + mp.osd_message("Libavfilter cropdetect missing", 3) + return + end + local EVENT = "toggle" + if s.toggled == ENABLE then + s.toggled = DISABLE_WITH_CROP + if filter_state(labels.cropdetect, "enabled", true) then + mp.commandv("vf", EVENT, string.format("@%s", labels.cropdetect)) + end + seek(EVENT) + if not auto then mp.osd_message(string.format("%s: disabled, crop remains.", label_prefix), 3) end + elseif s.toggled == DISABLE_WITH_CROP then + s.toggled = DISABLE + if filter_state(labels.cropdetect, "enabled", false) then + if s.f_video_crop then + mp.set_property("video-crop", "") + elseif filter_state(labels.crop, "enabled", true) then + mp.commandv("vf", EVENT, string.format("@%s", labels.crop)) + end + end + if not auto then mp.osd_message(string.format("%s: crop removed.", label_prefix), 3) end + else -- s.toggled == DISABLE + s.toggled = ENABLE + if filter_state(labels.cropdetect, "enabled", false) then + mp.commandv("vf", EVENT, string.format("@%s", labels.cropdetect)) + end + if s.f_video_crop then + apply_crop(s.applied) + elseif filter_state(labels.crop, "enabled", false) then + mp.commandv("vf", EVENT, string.format("@%s", labels.crop)) + end + resume(EVENT) + if not auto then mp.osd_message(string.format("%s: enabled.", label_prefix), 3) end + end +end + +local function pause(event, is_paused) + s.paused = is_paused + if is_paused then + seek(event) + print_stats() + print_debug("buffer") + print_debug("applied") + print_debug("pre_format", "s.approved: " .. s.approved.whxy) + print_debug("pre_format", "s.applied: " .. s.applied.whxy) + if s.indexed_read_ahead[1] then + print_debug("pre_format", "s.indexed_read_ahead[1]: " .. s.indexed_read_ahead[1].ref.whxy) + end + else + if s.toggled == 1 then resume(event) end + end +end + +function cleanup() + if not s.started then return end + if not s.paused then print_stats() end + mp.msg.info("Cleanup...") + mp.set_property("auto-window-resize", s.user_auto_window_resize) + mp.unregister_event(playback_events) + mp.unregister_event(collect_metadata) + mp.unobserve_property(time_pos) + mp.unobserve_property(switch_hwdec) + mp.unobserve_property(pause) + for _, label in pairs(labels) do + if filter_state(label) then mp.commandv("vf", "remove", string.format("@%s", label)) end + end + if s.f_video_crop then mp.set_property("video-crop", "") end + mp.msg.info("Done.") + s.started = false +end + +local function on_start() + mp.msg.info("File loaded.") + if not is_cropable() then + mp.msg.warn("Exit, only works for videos.") + return + end + s.user_geometry = mp.get_property("geometry") + s.user_auto_window_resize = mp.get_property("auto-window-resize") + if options.fix_windowed_behavior == 1 and s.user_auto_window_resize == "yes" then + mp.set_property("auto-window-resize", "no") + end + -- init/re-init stored data + s.buffer = {i_to_shift = 0, i_total = 0, i_ratio = 0, indexed_list = {}, t_total = 0, t_ratio = 0} + s.candidate = {i_fallback = 0, i_offset = 0, fallback = {}, offset = {}} + s.collected = {} + s.indexed_read_ahead = {} + s.limit = { + counter = 0, current = options.detect_limit, min = options.detect_limit, step = 2, target = 0, timer = 0, up = 2 + } + s.stats = {applied = {}, buffer = {}, buffer_unique = 0, trusted = {}, trusted_offset = {}, trusted_unique = 1} + s.stats.indexed_applied = {} + s.source = {w_untouched = mp.get_property_number("width"), h_untouched = mp.get_property_number("height")} + s.source.w = math.floor(s.source.w_untouched / options.detect_round) * options.detect_round + s.source.h = math.floor(s.source.h_untouched / options.detect_round) * options.detect_round + s.source.x = math.floor((s.source.w_untouched - s.source.w) / 2) + s.source.y = math.floor((s.source.h_untouched - s.source.h) / 2) + s.stats.trusted_offset = {x = {s.source.x}, y = {s.source.y}} + s.ratios = {w = {}, h = {}} + generate_ratios(options.ratios) + s.source = compute_metadata(s.source) + s.stats.trusted[s.source.whxy] = s.source + s.source.applied = 1 + s.source.time.accumulated = 0 + s.applied = s.source + s.approved = s.source + s.timestamps = {} + if options.read_ahead_mode > 0 then + -- assume cropdetect is patch for command "limit", fallback at the first collected metadata otherwise. + s.f_limit_runtime = true + -- quick test for dummysync filter + s.f_sync = mp.commandv("vf", "add", string.format("@%s:lavfi=[split[a][b];[a][b]dummysync]", label_prefix)) and + mp.commandv("vf", "remove", string.format("@%s", label_prefix)) and "[a][b]dummysync" or + "[a][b]psnr=eof_action=pass" + end + s.f_video_crop = options.crop_method == 1 and mp.get_property("video-crop") ~= nil -- true if supported + -- register events + mp.register_event("seek", playback_events) + mp.register_event("playback-restart", playback_events) + mp.observe_property("time-pos", "number", time_pos) + mp.observe_property("hwdec", "string", switch_hwdec) + mp.observe_property("pause", "bool", pause) + mp.enable_messages('v') + mp.register_event("log-message", collect_metadata) + s.toggled = (options.mode % 2 == 1) and DISABLE or ENABLE + s.started = true -- everything ready +end + +mp.add_key_binding("C", "toggle_crop", on_toggle) +mp.register_event("end-file", cleanup) +mp.register_event("file-loaded", on_start) + + diff --git a/.config/jellyfin-mpv-shim/scripts/gamma-auto.lua b/.config/jellyfin-mpv-shim/scripts/gamma-auto.lua new file mode 120000 index 0000000..76d560a --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/gamma-auto.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/gamma-auto.lua \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/idet.sh b/.config/jellyfin-mpv-shim/scripts/idet.sh new file mode 120000 index 0000000..0f14412 --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/idet.sh @@ -0,0 +1 @@ +/usr/share/mpv/scripts/idet.sh \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/mpris.so b/.config/jellyfin-mpv-shim/scripts/mpris.so new file mode 120000 index 0000000..f85fd8b --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/mpris.so @@ -0,0 +1 @@ +/usr/lib/mpv-mpris/mpris.so \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/mpv_identify.sh b/.config/jellyfin-mpv-shim/scripts/mpv_identify.sh new file mode 120000 index 0000000..4d1dcc8 --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/mpv_identify.sh @@ -0,0 +1 @@ +/usr/share/mpv/scripts/mpv_identify.sh \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/observe-all.lua b/.config/jellyfin-mpv-shim/scripts/observe-all.lua new file mode 120000 index 0000000..fc1027d --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/observe-all.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/observe-all.lua \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/ontop-playback.lua b/.config/jellyfin-mpv-shim/scripts/ontop-playback.lua new file mode 120000 index 0000000..3862f7d --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/ontop-playback.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/ontop-playback.lua \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/pause-when-minimize.lua b/.config/jellyfin-mpv-shim/scripts/pause-when-minimize.lua new file mode 120000 index 0000000..a6fc9af --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/pause-when-minimize.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/pause-when-minimize.lua \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/skip-logo.lua b/.config/jellyfin-mpv-shim/scripts/skip-logo.lua new file mode 120000 index 0000000..9d895cd --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/skip-logo.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/skip-logo.lua \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/sponsorblock.lua b/.config/jellyfin-mpv-shim/scripts/sponsorblock.lua new file mode 100644 index 0000000..96bfb24 --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/sponsorblock.lua @@ -0,0 +1,569 @@ +-- sponsorblock.lua +-- +-- This script skips sponsored segments of YouTube videos +-- using data from https://github.com/ajayyy/SponsorBlock + +local ON_WINDOWS = package.config:sub(1,1) ~= "/" + +local options = { + server_address = "https://sponsor.ajay.app", + + python_path = ON_WINDOWS and "python" or "python3", + + -- Categories to fetch + categories = "sponsor,intro,outro,interaction,selfpromo,filler", + + -- Categories to skip automatically + skip_categories = "sponsor", + + -- If true, sponsored segments will only be skipped once + skip_once = true, + + -- Note that sponsored segments may ocasionally be inaccurate if this is turned off + -- see https://blog.ajay.app/voting-and-pseudo-randomness-or-sponsorblock-or-youtube-sponsorship-segment-blocker + local_database = false, + + -- Update database on first run, does nothing if local_database is false + auto_update = true, + + -- How long to wait between local database updates + -- Format: "X[d,h,m]", leave blank to update on every mpv run + auto_update_interval = "6h", + + -- User ID used to submit sponsored segments, leave blank for random + user_id = "", + + -- Name to display on the stats page https://sponsor.ajay.app/stats/ leave blank to keep current name + display_name = "", + + -- Tell the server when a skip happens + report_views = true, + + -- Auto upvote skipped sponsors + auto_upvote = false, + + -- Use sponsor times from server if they're more up to date than our local database + server_fallback = true, + + -- Create chapters at sponsor boundaries for OSC display and manual skipping + make_chapters = true, + + -- Minimum duration for sponsors (in seconds), segments under that threshold will be ignored + min_duration = 1, + + -- Fade audio for smoother transitions + audio_fade = false, + + -- Audio fade step, applied once every 100ms until cap is reached + audio_fade_step = 10, + + -- Audio fade cap + audio_fade_cap = 0, + + -- Fast forward through sponsors instead of skipping + fast_forward = false, + + -- Playback speed modifier when fast forwarding, applied once every second until cap is reached + fast_forward_increase = .2, + + -- Playback speed cap + fast_forward_cap = 2, + + -- Length of the sha256 prefix (3-32) when querying server, 0 to disable + sha256_length = 4, + + -- Pattern for video id in local files, ignored if blank + -- Recommended value for base youtube-dl is "-([%w-_]+)%.[mw][kpe][v4b]m?$" + local_pattern = "", + + -- Legacy option, use skip_categories instead + skip = true +} + +mp.options = require "mp.options" +mp.options.read_options(options, "sponsorblock") + +local legacy = mp.command_native_async == nil +--[[ +if legacy then + options.local_database = false +end +--]] +options.local_database = false + +local utils = require "mp.utils" +scripts_dir = mp.find_config_file("scripts") + +local sponsorblock = utils.join_path(scripts_dir, "sponsorblock_shared/sponsorblock.py") +local uid_path = utils.join_path(scripts_dir, "sponsorblock_shared/sponsorblock.txt") +local database_file = options.local_database and utils.join_path(scripts_dir, "sponsorblock_shared/sponsorblock.db") or "" +local youtube_id = nil +local ranges = {} +local init = false +local segment = {a = 0, b = 0, progress = 0, first = true} +local retrying = false +local last_skip = {uuid = "", dir = nil} +local speed_timer = nil +local fade_timer = nil +local fade_dir = nil +local volume_before = mp.get_property_number("volume") +local categories = {} +local all_categories = {"sponsor", "intro", "outro", "interaction", "selfpromo", "preview", "music_offtopic", "filler"} +local chapter_cache = {} + +for category in string.gmatch(options.skip_categories, "([^,]+)") do + categories[category] = true +end + +function file_exists(name) + local f = io.open(name,"r") + if f ~= nil then io.close(f) return true else return false end +end + +function t_count(t) + local count = 0 + for _ in pairs(t) do count = count + 1 end + return count +end + +function time_sort(a, b) + if a.time == b.time then + return string.match(a.title, "segment end") + end + return a.time < b.time +end + +function parse_update_interval() + local s = options.auto_update_interval + if s == "" then return 0 end -- Interval Disabled + + local num, mod = s:match "^(%d+)([hdm])$" + + if num == nil or mod == nil then + mp.osd_message("[sponsorblock] auto_update_interval " .. s .. " is invalid", 5) + return nil + end + + local time_table = { + m = 60, + h = 60 * 60, + d = 60 * 60 * 24, + } + + return num * time_table[mod] +end + +function clean_chapters() + local chapters = mp.get_property_native("chapter-list") + local new_chapters = {} + for _, chapter in pairs(chapters) do + if chapter.title ~= "Preview segment start" and chapter.title ~= "Preview segment end" then + table.insert(new_chapters, chapter) + end + end + mp.set_property_native("chapter-list", new_chapters) +end + +function create_chapter(chapter_title, chapter_time) + local chapters = mp.get_property_native("chapter-list") + local duration = mp.get_property_native("duration") + table.insert(chapters, {title=chapter_title, time=(duration == nil or duration > chapter_time) and chapter_time or duration - .001}) + table.sort(chapters, time_sort) + mp.set_property_native("chapter-list", chapters) +end + +function process(uuid, t, new_ranges) + start_time = tonumber(string.match(t, "[^,]+")) + end_time = tonumber(string.sub(string.match(t, ",[^,]+"), 2)) + for o_uuid, o_t in pairs(ranges) do + if (start_time >= o_t.start_time and start_time <= o_t.end_time) or (o_t.start_time >= start_time and o_t.start_time <= end_time) then + new_ranges[o_uuid] = o_t + return + end + end + category = string.match(t, "[^,]+$") + if categories[category] and end_time - start_time >= options.min_duration then + new_ranges[uuid] = { + start_time = start_time, + end_time = end_time, + category = category, + skipped = false + } + end + if options.make_chapters and not chapter_cache[uuid] then + chapter_cache[uuid] = true + local category_title = (category:gsub("^%l", string.upper):gsub("_", " ")) + create_chapter(category_title .. " segment start (" .. string.sub(uuid, 1, 6) .. ")", start_time) + create_chapter(category_title .. " segment end (" .. string.sub(uuid, 1, 6) .. ")", end_time) + end +end + +function getranges(_, exists, db, more) + if type(exists) == "table" and exists["status"] == "1" then + if options.server_fallback then + mp.add_timeout(0, function() getranges(true, true, "") end) + else + return mp.osd_message("[sponsorblock] database update failed, gave up") + end + end + if db ~= "" and db ~= database_file then db = database_file end + if exists ~= true and not file_exists(db) then + if not retrying then + mp.osd_message("[sponsorblock] database update failed, retrying...") + retrying = true + end + return update() + end + if retrying then + mp.osd_message("[sponsorblock] database update succeeded") + retrying = false + end + local sponsors + local args = { + options.python_path, + sponsorblock, + "ranges", + db, + options.server_address, + youtube_id, + options.categories, + tostring(options.sha256_length) + } + if not legacy then + sponsors = mp.command_native({name = "subprocess", capture_stdout = true, playback_only = false, args = args}) + else + sponsors = utils.subprocess({args = args}) + end + mp.msg.debug("Got: " .. string.gsub(sponsors.stdout, "[\n\r]", "")) + if not string.match(sponsors.stdout, "^%s*(.*%S)") then return end + if string.match(sponsors.stdout, "error") then return getranges(true, true) end + local new_ranges = {} + local r_count = 0 + if more then r_count = -1 end + for t in string.gmatch(sponsors.stdout, "[^:%s]+") do + uuid = string.match(t, "([^,]+),[^,]+$") + if ranges[uuid] then + new_ranges[uuid] = ranges[uuid] + else + process(uuid, t, new_ranges) + end + r_count = r_count + 1 + end + local c_count = t_count(ranges) + if c_count == 0 or r_count >= c_count then + ranges = new_ranges + end +end + +function fast_forward() + if options.fast_forward and options.fast_forward == true then + speed_timer = nil + mp.set_property("speed", 1) + end + local last_speed = mp.get_property_number("speed") + local new_speed = math.min(last_speed + options.fast_forward_increase, options.fast_forward_cap) + if new_speed <= last_speed then return end + mp.set_property("speed", new_speed) +end + +function fade_audio(step) + local last_volume = mp.get_property_number("volume") + local new_volume = math.max(options.audio_fade_cap, math.min(last_volume + step, volume_before)) + if new_volume == last_volume then + if step >= 0 then fade_dir = nil end + if fade_timer ~= nil then fade_timer:kill() end + fade_timer = nil + return + end + mp.set_property("volume", new_volume) +end + +function skip_ads(name, pos) + if pos == nil then return end + local sponsor_ahead = false + for uuid, t in pairs(ranges) do + if (options.fast_forward == uuid or not options.skip_once or not t.skipped) and t.start_time <= pos and t.end_time > pos then + if options.fast_forward == uuid then return end + if options.fast_forward == false then + mp.osd_message("[sponsorblock] " .. t.category .. " skipped") + mp.set_property("time-pos", t.end_time) + else + mp.osd_message("[sponsorblock] skipping " .. t.category) + end + t.skipped = true + last_skip = {uuid = uuid, dir = nil} + if options.report_views or options.auto_upvote then + local args = { + options.python_path, + sponsorblock, + "stats", + database_file, + options.server_address, + youtube_id, + uuid, + options.report_views and "1" or "", + uid_path, + options.user_id, + options.auto_upvote and "1" or "" + } + if not legacy then + mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end) + else + utils.subprocess_detached({args = args}) + end + end + if options.fast_forward ~= false then + options.fast_forward = uuid + if speed_timer ~= nil then speed_timer:kill() end + speed_timer = mp.add_periodic_timer(1, fast_forward) + end + return + elseif (not options.skip_once or not t.skipped) and t.start_time <= pos + 1 and t.end_time > pos + 1 then + sponsor_ahead = true + end + end + if options.audio_fade then + if sponsor_ahead then + if fade_dir ~= false then + if fade_dir == nil then volume_before = mp.get_property_number("volume") end + if fade_timer ~= nil then fade_timer:kill() end + fade_dir = false + fade_timer = mp.add_periodic_timer(.1, function() fade_audio(-options.audio_fade_step) end) + end + elseif fade_dir == false then + fade_dir = true + if fade_timer ~= nil then fade_timer:kill() end + fade_timer = mp.add_periodic_timer(.1, function() fade_audio(options.audio_fade_step) end) + end + end + if options.fast_forward and options.fast_forward ~= true then + options.fast_forward = true + speed_timer:kill() + speed_timer = nil + mp.set_property("speed", 1) + end +end + +function vote(dir) + if last_skip.uuid == "" then return mp.osd_message("[sponsorblock] no sponsors skipped, can't submit vote") end + local updown = dir == "1" and "up" or "down" + if last_skip.dir == dir then return mp.osd_message("[sponsorblock] " .. updown .. "vote already submitted") end + last_skip.dir = dir + local args = { + options.python_path, + sponsorblock, + "stats", + database_file, + options.server_address, + youtube_id, + last_skip.uuid, + "", + uid_path, + options.user_id, + dir + } + if not legacy then + mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end) + else + utils.subprocess({args = args}) + end + mp.osd_message("[sponsorblock] " .. updown .. "vote submitted") +end + +function update() + mp.command_native_async({name = "subprocess", playback_only = false, args = { + options.python_path, + sponsorblock, + "update", + database_file, + options.server_address + }}, getranges) +end + +function file_loaded() + local initialized = init + ranges = {} + segment = {a = 0, b = 0, progress = 0, first = true} + last_skip = {uuid = "", dir = nil} + chapter_cache = {} + local video_path = mp.get_property("path", "") + mp.msg.debug("Path: " .. video_path) + local video_referer = string.match(mp.get_property("http-header-fields", ""), "Referer:([^,]+)") or "" + mp.msg.debug("Referer: " .. video_referer) + + local urls = { + "ytdl://([%w-_]+).*", + "https?://youtu%.be/([%w-_]+).*", + "https?://w?w?w?%.?youtube%.com/v/([%w-_]+).*", + "/watch.*[?&]v=([%w-_]+).*", + "/embed/([%w-_]+).*" + } + youtube_id = nil + for i, url in ipairs(urls) do + youtube_id = youtube_id or string.match(video_path, url) or string.match(video_referer, url) + if youtube_id then break end + end + youtube_id = youtube_id or string.match(video_path, options.local_pattern) + + if not youtube_id or string.len(youtube_id) < 11 or (local_pattern and string.len(youtube_id) ~= 11) then return end + youtube_id = string.sub(youtube_id, 1, 11) + mp.msg.debug("Found YouTube ID: " .. youtube_id) + init = true + if not options.local_database then + getranges(true, true) + else + local exists = file_exists(database_file) + if exists and options.server_fallback then + getranges(true, true) + mp.add_timeout(0, function() getranges(true, true, "", true) end) + elseif exists then + getranges(true, true) + elseif options.server_fallback then + mp.add_timeout(0, function() getranges(true, true, "") end) + end + end + if initialized then return end + if options.skip then + mp.observe_property("time-pos", "native", skip_ads) + end + if options.display_name ~= "" then + local args = { + options.python_path, + sponsorblock, + "username", + database_file, + options.server_address, + youtube_id, + "", + "", + uid_path, + options.user_id, + options.display_name + } + if not legacy then + mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end) + else + utils.subprocess_detached({args = args}) + end + end + if not options.local_database or (not options.auto_update and file_exists(database_file)) then return end + + if file_exists(database_file) then + local db_info = utils.file_info(database_file) + local cur_time = os.time(os.date("*t")) + local upd_interval = parse_update_interval() + if upd_interval == nil or os.difftime(cur_time, db_info.mtime) < upd_interval then return end + end + + update() +end + +function set_segment() + if not youtube_id then return end + local pos = mp.get_property_number("time-pos") + if pos == nil then return end + if segment.progress > 1 then + segment.progress = segment.progress - 2 + end + if segment.progress == 1 then + segment.progress = 0 + segment.b = pos + mp.osd_message("[sponsorblock] segment boundary B set, press again for boundary A", 3) + else + segment.progress = 1 + segment.a = pos + mp.osd_message("[sponsorblock] segment boundary A set, press again for boundary B", 3) + end + if options.make_chapters and not segment.first then + local start_time = math.min(segment.a, segment.b) + local end_time = math.max(segment.a, segment.b) + if end_time - start_time ~= 0 and end_time ~= 0 then + clean_chapters() + create_chapter("Preview segment start", start_time) + create_chapter("Preview segment end", end_time) + end + end + segment.first = false +end + +function select_category(selected) + for category in string.gmatch(options.categories, "([^,]+)") do + mp.remove_key_binding("select_category_"..category) + mp.remove_key_binding("kp_select_category_"..category) + end + submit_segment(selected) +end + +function submit_segment(category) + if not youtube_id then return end + local start_time = math.min(segment.a, segment.b) + local end_time = math.max(segment.a, segment.b) + if end_time - start_time == 0 or end_time == 0 then + mp.osd_message("[sponsorblock] empty segment, not submitting") + elseif segment.progress <= 1 then + segment.progress = segment.progress + 2 + local category_list = "" + for category_id, category in pairs(all_categories) do + local category_title = (category:gsub("^%l", string.upper):gsub("_", " ")) + category_list = category_list .. category_id .. ": " .. category_title .. "\n" + mp.add_forced_key_binding(tostring(category_id), "select_category_"..category, function() select_category(category) end) + mp.add_forced_key_binding("KP"..tostring(category_id), "kp_select_category_"..category, function() select_category(category) end) + end + mp.osd_message(string.format("[sponsorblock] press a number to select category for segment: %.2d:%.2d:%.2d to %.2d:%.2d:%.2d\n\n" .. category_list .. "\nyou can press Shift+G again for default (Sponsor) or hide this message with g", math.floor(start_time/(60*60)), math.floor(start_time/60%60), math.floor(start_time%60), math.floor(end_time/(60*60)), math.floor(end_time/60%60), math.floor(end_time%60)), 30) + else + mp.osd_message("[sponsorblock] submitting segment...", 30) + local submit + local args = { + options.python_path, + sponsorblock, + "submit", + database_file, + options.server_address, + youtube_id, + tostring(start_time), + tostring(end_time), + uid_path, + options.user_id, + category or "sponsor" + } + if not legacy then + submit = mp.command_native({name = "subprocess", capture_stdout = true, playback_only = false, args = args}) + else + submit = utils.subprocess({args = args}) + end + if string.match(submit.stdout, "success") then + segment = {a = 0, b = 0, progress = 0, first = true} + mp.osd_message("[sponsorblock] segment submitted") + if options.make_chapters then + clean_chapters() + create_chapter("Submitted segment start", start_time) + create_chapter("Submitted segment end", end_time) + end + elseif string.match(submit.stdout, "error") then + mp.osd_message("[sponsorblock] segment submission failed, server may be down. try again", 5) + elseif string.match(submit.stdout, "502") then + mp.osd_message("[sponsorblock] segment submission failed, server is down. try again", 5) + elseif string.match(submit.stdout, "400") then + mp.osd_message("[sponsorblock] segment submission failed, impossible inputs", 5) + segment = {a = 0, b = 0, progress = 0, first = true} + elseif string.match(submit.stdout, "429") then + mp.osd_message("[sponsorblock] segment submission failed, rate limited. try again", 5) + elseif string.match(submit.stdout, "409") then + mp.osd_message("[sponsorblock] segment already submitted", 3) + segment = {a = 0, b = 0, progress = 0, first = true} + else + mp.osd_message("[sponsorblock] segment submission failed", 5) + end + end +end + +mp.register_event("file-loaded", file_loaded) +mp.add_key_binding("g", "set_segment", set_segment) +mp.add_key_binding("G", "submit_segment", submit_segment) +mp.add_key_binding("h", "upvote_segment", function() return vote("1") end) +mp.add_key_binding("H", "downvote_segment", function() return vote("0") end) +-- Bindings below are for backwards compatibility and could be removed at any time +mp.add_key_binding(nil, "sponsorblock_set_segment", set_segment) +mp.add_key_binding(nil, "sponsorblock_submit_segment", submit_segment) +mp.add_key_binding(nil, "sponsorblock_upvote", function() return vote("1") end) +mp.add_key_binding(nil, "sponsorblock_downvote", function() return vote("0") end) diff --git a/.config/jellyfin-mpv-shim/scripts/sponsorblock_shared/main.lua b/.config/jellyfin-mpv-shim/scripts/sponsorblock_shared/main.lua new file mode 100644 index 0000000..2bbe7a2 --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/sponsorblock_shared/main.lua @@ -0,0 +1,3 @@ +-- This is a dummy main.lua +-- required for mpv 0.33 +-- do not delete \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/sponsorblock_shared/sponsorblock.py b/.config/jellyfin-mpv-shim/scripts/sponsorblock_shared/sponsorblock.py new file mode 100644 index 0000000..8370a6a --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/sponsorblock_shared/sponsorblock.py @@ -0,0 +1,122 @@ +import urllib.request +import urllib.parse +import hashlib +import sqlite3 +import random +import string +import json +import sys +import os + +if sys.argv[1] in ["submit", "stats", "username"]: + if not sys.argv[8]: + if os.path.isfile(sys.argv[7]): + with open(sys.argv[7]) as f: + uid = f.read() + else: + uid = "".join(random.choices(string.ascii_letters + string.digits, k=36)) + with open(sys.argv[7], "w") as f: + f.write(uid) + else: + uid = sys.argv[8] + +opener = urllib.request.build_opener() +opener.addheaders = [("User-Agent", "mpv_sponsorblock/1.0 (https://github.com/po5/mpv_sponsorblock)")] +urllib.request.install_opener(opener) + +if sys.argv[1] == "ranges" and (not sys.argv[2] or not os.path.isfile(sys.argv[2])): + sha = None + if 3 <= int(sys.argv[6]) <= 32: + sha = hashlib.sha256(sys.argv[4].encode()).hexdigest()[:int(sys.argv[6])] + times = [] + try: + response = urllib.request.urlopen(sys.argv[3] + "/api/skipSegments" + ("/" + sha + "?" if sha else "?videoID=" + sys.argv[4] + "&") + urllib.parse.urlencode([("categories", json.dumps(sys.argv[5].split(",")))])) + segments = json.load(response) + for segment in segments: + if sha and sys.argv[4] != segment["videoID"]: + continue + if sha: + for s in segment["segments"]: + times.append(str(s["segment"][0]) + "," + str(s["segment"][1]) + "," + s["UUID"] + "," + s["category"]) + else: + times.append(str(segment["segment"][0]) + "," + str(segment["segment"][1]) + "," + segment["UUID"] + "," + segment["category"]) + print(":".join(times)) + except (TimeoutError, urllib.error.URLError) as e: + print("error") + except urllib.error.HTTPError as e: + if e.code == 404: + print("") + else: + print("error") +elif sys.argv[1] == "ranges": + conn = sqlite3.connect(sys.argv[2]) + conn.row_factory = sqlite3.Row + c = conn.cursor() + times = [] + for category in sys.argv[5].split(","): + c.execute("SELECT startTime, endTime, votes, UUID, category FROM sponsorTimes WHERE videoID = ? AND shadowHidden = 0 AND votes > -1 AND category = ?", (sys.argv[4], category)) + sponsors = c.fetchall() + best = list(sponsors) + dealtwith = [] + similar = [] + for sponsor_a in sponsors: + for sponsor_b in sponsors: + if sponsor_a is not sponsor_b and sponsor_a["startTime"] >= sponsor_b["startTime"] and sponsor_a["startTime"] <= sponsor_b["endTime"]: + similar.append([sponsor_a, sponsor_b]) + if sponsor_a in best: + best.remove(sponsor_a) + if sponsor_b in best: + best.remove(sponsor_b) + for sponsors_a in similar: + if sponsors_a in dealtwith: + continue + group = set(sponsors_a) + for sponsors_b in similar: + if sponsors_b[0] in group or sponsors_b[1] in group: + group.add(sponsors_b[0]) + group.add(sponsors_b[1]) + dealtwith.append(sponsors_b) + best.append(max(group, key=lambda x:x["votes"])) + for time in best: + times.append(str(time["startTime"]) + "," + str(time["endTime"]) + "," + time["UUID"] + "," + time["category"]) + print(":".join(times)) +elif sys.argv[1] == "update": + try: + urllib.request.urlretrieve(sys.argv[3] + "/database.db", sys.argv[2] + ".tmp") + os.replace(sys.argv[2] + ".tmp", sys.argv[2]) + except PermissionError: + print("database update failed, file currently in use", file=sys.stderr) + sys.exit(1) + except ConnectionResetError: + print("database update failed, connection reset", file=sys.stderr) + sys.exit(1) + except TimeoutError: + print("database update failed, timed out", file=sys.stderr) + sys.exit(1) + except urllib.error.URLError: + print("database update failed", file=sys.stderr) + sys.exit(1) +elif sys.argv[1] == "submit": + try: + req = urllib.request.Request(sys.argv[3] + "/api/skipSegments", data=json.dumps({"videoID": sys.argv[4], "segments": [{"segment": [float(sys.argv[5]), float(sys.argv[6])], "category": sys.argv[9]}], "userID": uid}).encode(), headers={"Content-Type": "application/json"}) + response = urllib.request.urlopen(req) + print("success") + except urllib.error.HTTPError as e: + print(e.code) + except: + print("error") +elif sys.argv[1] == "stats": + try: + if sys.argv[6]: + urllib.request.urlopen(sys.argv[3] + "/api/viewedVideoSponsorTime?UUID=" + sys.argv[5]) + if sys.argv[9]: + urllib.request.urlopen(sys.argv[3] + "/api/voteOnSponsorTime?UUID=" + sys.argv[5] + "&userID=" + uid + "&type=" + sys.argv[9]) + except: + pass +elif sys.argv[1] == "username": + try: + data = urllib.parse.urlencode({"userID": uid, "userName": sys.argv[9]}).encode() + req = urllib.request.Request(sys.argv[3] + "/api/setUsername", data=data) + urllib.request.urlopen(req) + except: + pass diff --git a/.config/jellyfin-mpv-shim/scripts/sponsorblock_shared/sponsorblock.txt b/.config/jellyfin-mpv-shim/scripts/sponsorblock_shared/sponsorblock.txt new file mode 100644 index 0000000..9b5e499 --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/sponsorblock_shared/sponsorblock.txt @@ -0,0 +1 @@ +2w0fKpa3ZP3LF1GOsREvGfMGUBYbyrJzf24U \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/stats-conv.py b/.config/jellyfin-mpv-shim/scripts/stats-conv.py new file mode 120000 index 0000000..a85b900 --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/stats-conv.py @@ -0,0 +1 @@ +/usr/share/mpv/scripts/stats-conv.py \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/status-line.lua b/.config/jellyfin-mpv-shim/scripts/status-line.lua new file mode 120000 index 0000000..905c3e4 --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/status-line.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/status-line.lua \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/umpv b/.config/jellyfin-mpv-shim/scripts/umpv new file mode 120000 index 0000000..b3e4c90 --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/umpv @@ -0,0 +1 @@ +/usr/share/mpv/scripts/umpv \ No newline at end of file diff --git a/.config/jellyfin-mpv-shim/scripts/uosc b/.config/jellyfin-mpv-shim/scripts/uosc new file mode 120000 index 0000000..b2066b1 --- /dev/null +++ b/.config/jellyfin-mpv-shim/scripts/uosc @@ -0,0 +1 @@ +/usr/share/mpv/scripts/uosc \ No newline at end of file diff --git a/.config/mpv/fonts b/.config/mpv/fonts new file mode 120000 index 0000000..a3777bc --- /dev/null +++ b/.config/mpv/fonts @@ -0,0 +1 @@ +/usr/share/mpv/fonts \ No newline at end of file diff --git a/.config/mpv/input.conf b/.config/mpv/input.conf new file mode 100644 index 0000000..a322dce --- /dev/null +++ b/.config/mpv/input.conf @@ -0,0 +1,4 @@ +# Press 'n' to toggle the default audio filter on/off +n af toggle "dynaudnorm=f=150:g=15:p=0.9" +# Press 'Shift+N' to toggle the default compressor/normalizer chain on/off +N af toggle "lavfi=[acompressor=threshold=-18dB:ratio=5:attack=20:release=250,loudnorm=I=-16:LRA=11:TP=-1.5]" diff --git a/.config/mpv/mpv.conf b/.config/mpv/mpv.conf new file mode 100644 index 0000000..a4d2eff --- /dev/null +++ b/.config/mpv/mpv.conf @@ -0,0 +1,50 @@ +### Allgemeine Video-Einstellungen +volume=80 +profile=gpu-hq +fs +video-sync=display-resample +scale=ewa_lanczossharp +cscale=ewa_lanczossharp + +# Vollständiges Deaktivieren aller mpv Standard-UI +osc=no +osd-level=0 +osd-bar=no +border=no + +# Keine OSD-Nachrichten +osd-playing-msg= +osd-status-msg= +osd-msg1= +osd-msg2= +osd-msg3= + +# Terminal-Output minimieren +quiet +msg-level=all=no + +# uosc Konfiguration +script-opts=uosc-scroll-text=no + +### Rest deiner Konfiguration... +hwdec=auto-copy +gpu-context=wayland +gpu-api=auto + +# Streaming & Netzwerk... +stream-lavf-o-append=reconnect_on_http_error=4xx,5xx +stream-lavf-o-append=reconnect_on_network_error=yes +stream-lavf-o-append=reconnect_streamed=yes +stream-lavf-o-append=reconnect_delay_max=30 + +# Farbprofile... +[HDR] +profile-cond=get("video-params/primaries") == "bt.2020" and get("video-params/gamma") == "pq" +profile-restore=copy +target-colorspace-hint=yes + +[SDR] +profile-cond=get("video-params/primaries") == "bt.709" +profile-restore=copy +target-trc=srgb + diff --git a/.config/mpv/script-opts b/.config/mpv/script-opts new file mode 120000 index 0000000..0df4bcb --- /dev/null +++ b/.config/mpv/script-opts @@ -0,0 +1 @@ +/usr/share/mpv/script-opts \ No newline at end of file diff --git a/.config/mpv/scripts/README.md b/.config/mpv/scripts/README.md new file mode 120000 index 0000000..b6b1cce --- /dev/null +++ b/.config/mpv/scripts/README.md @@ -0,0 +1 @@ +/usr/share/mpv/scripts/README.md \ No newline at end of file diff --git a/.config/mpv/scripts/acompressor.lua b/.config/mpv/scripts/acompressor.lua new file mode 120000 index 0000000..c3ab129 --- /dev/null +++ b/.config/mpv/scripts/acompressor.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/acompressor.lua \ No newline at end of file diff --git a/.config/mpv/scripts/ao-null-reload.lua b/.config/mpv/scripts/ao-null-reload.lua new file mode 120000 index 0000000..d20980b --- /dev/null +++ b/.config/mpv/scripts/ao-null-reload.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/ao-null-reload.lua \ No newline at end of file diff --git a/.config/mpv/scripts/autocrop.lua b/.config/mpv/scripts/autocrop.lua new file mode 120000 index 0000000..0364c44 --- /dev/null +++ b/.config/mpv/scripts/autocrop.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/autocrop.lua \ No newline at end of file diff --git a/.config/mpv/scripts/autodeint.lua b/.config/mpv/scripts/autodeint.lua new file mode 120000 index 0000000..870b172 --- /dev/null +++ b/.config/mpv/scripts/autodeint.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/autodeint.lua \ No newline at end of file diff --git a/.config/mpv/scripts/autoload.lua b/.config/mpv/scripts/autoload.lua new file mode 120000 index 0000000..d6de290 --- /dev/null +++ b/.config/mpv/scripts/autoload.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/autoload.lua \ No newline at end of file diff --git a/.config/mpv/scripts/cycle-deinterlace-pullup.lua b/.config/mpv/scripts/cycle-deinterlace-pullup.lua new file mode 120000 index 0000000..dedb5bc --- /dev/null +++ b/.config/mpv/scripts/cycle-deinterlace-pullup.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/cycle-deinterlace-pullup.lua \ No newline at end of file diff --git a/.config/mpv/scripts/dynamic-crop.lua b/.config/mpv/scripts/dynamic-crop.lua new file mode 100644 index 0000000..8cbffec --- /dev/null +++ b/.config/mpv/scripts/dynamic-crop.lua @@ -0,0 +1,837 @@ +--[[ +This script uses the lavfi cropdetect filter to automatically insert a crop filter with appropriate parameters + for the currently playing video, the script run continuously by default (mode 4). + +To use this script, "hwdec=no" (mpv default/recommended) or any "-copy" variant like "hwdec=auto-copy" is required, + consider editing "mpv.conf" to an appropriate value. + +The workflow is as follows: We observe ffmpeg log to collect metadata and process it. + Collected metadata are stored sequentially in s.buffer, then process to check and + store trusted values to speed up future change for the current video. + It will automatically crop the video as soon as a change is validated. + +The default options can be overridden by adding a line into "mpv.conf" with: + script-opts-append=-= + script-opts-append=dynamic_crop-mode=0 + script-opts-append=dynamic_crop-ratios=2.4 2.39 2 4/3 (quotes aren't needed like below) + +Extended descriptions for some parameters (For default values, see ): + +mode: [0-4] 0 disable, 1 on-demand, 2 one-shot, 3 dynamic-manual, 4 dynamic-auto + Mode 1 and 3 requires using the shortcut to start, 2 and 4 have an automatic start. + +Shortcut "C" (shift+c) to control the script. +Cycle between ENABLE / DISABLE_WITH_CROP / DISABLE + +prevent_change_mode: [0-3] 0 disable 1 keep-largest, 2 keep-lowest, 3 keep-latest + The prevent_change_timer is trigger after a change. + +fix_windowed_behavior: [0-3] Avoid the default behavior that resizes the window to the source size + when the crop filter changes in windowed/maximized mode by adjusting geometry. + +limit_timer: Only used if the cropdetect filter doesn't handle limit changes with a command (patch 01/2023). + Extend the time between each limit change to reduce the impact on performance caused by re-initializing the + full filter. + +read_ahead_mode: Linked to the associated timer and tells how much time in advance to collect the metadata. + This feature is useful for videos with multiple aspect ratio changes for "fast_change_timer". + Note: because this function is in sync with the playback, a delay equivalent to the timer used is + added/reset every time you seek before you get a reaction, so setting 1 is recommanded. + Required at least https://github.com/FFmpeg/FFmpeg/commit/69c060bea21d3b4ce63b5fff40d37e98c70ab88f + and optionally https://github.com/mpv-player/mpv/pull/11182, until mpv patch is being merged to master, + considered this feature experimental because of the errors generated in logs/console by vf-command and + the filter used to sync the filter chain (psnr). + +read_ahead_sync: Compensates for the delay when applying the crop filter and the visible result. + Must be adjusted to your tastes and each MPV client depending on their reaction time. + Note: Perfect adjustment is not really possible but generally <= 1 frame, sometimes more in + dark/ambiguous scenes. + +segmentation: e.g. 0.5 for 50% - Extra time to allow new metadata to be segmented instead of being continuous. + This is used with ratio_timer, offset_timer and fallback_timer. + e.g. ratio_timer is validated with 5 sec accumulated over 7.5 sec elapsed. +]] -- +require "mp.options" + +-- options +local options = { + -- behavior + mode = 4, -- [0-4] more details above + start_delay = 0, -- delay in seconds used to skip intro (usefull with mode 2) + prevent_change_timer = 30, -- seconds + prevent_change_mode = 0, -- [0-3], more details above + fix_windowed_behavior = 1, -- [0-3], 0 no-fix, 1 fix-no-resize, 2 fix-keep-width, 3 fix-keep-height + limit_timer = 0.5, -- seconds, 0 disable, more details above + fast_change_timer = 0.2, -- seconds, recommanded to keep default or > 0 if read_ahead is supported by mpv + ratio_timer = 2, -- seconds, meta in ratios list + offset_timer = 20, -- seconds, >= 'ratio_timer', new offset for asymmetric video + fallback_timer = 40, -- seconds, >= 'offset_timer', not in ratios list and possibly with new offset + linked_tolerance = 2, -- int, scale with detect_round to match against source width/height + ratios = "2.76 2.55 24/9 2.4 2.39 2.35 2.2 2.1 2 1.9 1.85 16/9 5/3 1.5 1.43 4/3 1.25 9/16", -- list + ratio_tolerance = 2, -- int (even number), adjust in order to match more easly the ratios list + read_ahead_mode = 0, -- [0-2], 0 disable, 1 fast_change_timer, 2 ratio_timer, more details above + read_ahead_sync = 0, -- int/frame, increase for advance, more details above + segmentation = 0.5, -- [0.0-1] %, 0 will approved only a continuous metadata (strict) + crop_method = 1, -- 0 lavfi-crop (ffmpeg/filter), 1 video-crop (mpv/VO) + -- filter, see https://ffmpeg.org/ffmpeg-filters.html#cropdetect for details + detect_limit = 26, -- is the maximum use, increase it slowly if lighter black are present + detect_round = 2, -- even number + -- verbose + debug = false +} +read_options(options) + +if options.mode == 0 then + mp.msg.info("mode = 0, disable script.") + return +end + +-- forward declarations +local cleanup, on_toggle +local s = {} + +-- labels +local label_prefix = mp.get_script_name() +local labels = { + crop = string.format("%s-crop", label_prefix), cropdetect = string.format("%s-cropdetect", label_prefix) +} + +-- shifting decimal to +local LEFT, RIGHT = true, false +local function shifting_to(left, value) + local shift = 1e3 + return left and (value / shift) or value >= 1 and math.ceil(value * shift) or value * shift +end + +-- options: compute timer and other stuff +for k, v in pairs(options) do + local timer = string.match(tostring(k), "_timer") + if timer then options[k] = shifting_to(RIGHT, v) end +end +options.read_ahead_timer = + options.read_ahead_mode == 1 and options.fast_change_timer or options.read_ahead_mode == 2 and options.ratio_timer * + (1 + options.segmentation) or nil +options.read_ahead_cropdetect = options.read_ahead_timer and shifting_to(LEFT, options.read_ahead_timer) or nil +options.reverse_segmentation = 1 / (1 * (1 + options.segmentation)) +options.crop_method_sync = options.crop_method == 0 and 1 or 0 -- lavfi-crop is slower, so give it some advance for read_ahead + +local function print_debug(msg_type, meta, label) + if not options.debug then + return + elseif msg_type == "pre_format" then + mp.msg.info(meta) + elseif msg_type == "metadata" then + mp.msg.info(string.format("%s, %-29s | offX:%3s offY:%3s | limit:%-2s", label, meta.whxy, meta.offset.x, + meta.offset.y, s.limit.current)) + elseif msg_type == "buffer" and s.stats.buffer then + mp.msg.info("Buffer stats:") + for whxy, ref in pairs(s.stats.buffer) do + mp.msg.info(string.format( + "\\ %-29s | offX=%4s offY=%4s | time=%6ss linked_source=%-4s known_ratio=%-4s trusted_offsets=%s", whxy, + ref.offset.x, ref.offset.y, shifting_to(LEFT, ref.time.buffer), ref.is_linked_to_source or false, + ref.is_known_ratio or false, ref.is_trusted_offsets)) + end + mp.msg.info("Buffer list:") + for i, v in ipairs(s.buffer.indexed_list) do + local new_ref = v.new_ref and v.new_ref.whxy or "" + local pts = shifting_to(RIGHT, v.pts) + mp.msg.info(string.format("\\ %3s %-29s %4sms pts:%d new_ref:%s", i, v.ref.whxy, v.t_elapsed, pts, new_ref)) + end + mp.msg.info("i_fallback", s.candidate.i_fallback) + mp.msg.info("i_offset", s.candidate.i_offset) + elseif msg_type == "applied" and s.stats.indexed_applied then + mp.msg.info("Applied list:") + for i, v in ipairs(s.stats.indexed_applied) do + mp.msg.info(string.format("\\ %3s %-29s pts:%d", i, v.ref.whxy, shifting_to(RIGHT, v.pts))) + end + end +end + +local function print_stats() + if not s.stats and not s.stats.trusted then return end + mp.msg.info("Meta Stats:") + local offsets_list = {x = "", y = ""} + for axis, _ in pairs(offsets_list) do + for _, v in pairs(s.stats.trusted_offset[axis]) do offsets_list[axis] = offsets_list[axis] .. v .. " " end + end + mp.msg.info( + string.format("Limit - min/max: %s/%s | counter: %s", s.limit.min, options.detect_limit, s.limit.counter)) + mp.msg.info(string.format("Trusted - unique: %s | offset: X:%sY:%s", s.stats.trusted_unique, offsets_list.x, + offsets_list.y)) + for whxy, ref in pairs(s.stats.trusted) do + if s.stats.trusted[whxy] then + mp.msg.info(string.format("\\ %-29s | offX=%3s offY=%3s | applied=%s overall=%ss accumulated=%ss", whxy, + ref.offset.x, ref.offset.y, ref.applied, shifting_to(LEFT, ref.time.overall), + shifting_to(LEFT, ref.time.accumulated))) + end + end + mp.msg.info("Buffer - unique: " .. s.stats.buffer_unique .. " | total: " .. s.buffer.i_total, + shifting_to(LEFT, s.buffer.t_total) .. "s | known_ratio:", s.buffer.i_ratio, + shifting_to(LEFT, s.buffer.t_ratio) .. "s") +end + +local function is_trusted_offset(offset, axis) + local trusted_offset = s.stats.trusted_offset[axis] + for _, v in ipairs(trusted_offset) do if math.abs(offset - v) <= 1 then return true end end + return false +end + +local function is_cropable() + for _, track in pairs(mp.get_property_native('track-list')) do + if track.type == 'video' and track.selected then return not track.albumart end + end + return false +end + +local function filter_state(label, key, value) + local filters = mp.get_property_native("vf") + for _, filter in pairs(filters) do + if filter["label"] == label and + ((not key or key ~= "graph" and filter[key] == value or key == "graph" and + string.find(filter.params.graph, value))) then return true end + end + return false +end + +local function command_filter(label, command, argument, target) + if not s.f_vfcommand then + local res, reason = mp.commandv("vf-command", label, command, argument, target) + if not res and reason == "invalid parameter" then + s.f_vfcommand = true -- if mpv doesn't handle target parameter + end + end + if s.f_vfcommand then + -- fallback and send to all filters inside the graph + mp.commandv("vf-command", label, command, argument) + end +end + +local function insert_cropdetect_filter(limit, change) + if s.toggled > 1 or s.paused then return end + local function insert_filter() + local cropdetect = string.format("cropdetect@dyn_cd=limit=%d/255:round=%d:reset=1", limit, options.detect_round) + if s.f_limit_runtime and change then + command_filter(labels.cropdetect, "limit", string.format("%d/255", limit), "cropdetect") + return true + elseif s.f_limit_runtime and options.read_ahead_mode > 0 then + return mp.commandv("vf", "pre", + string.format("@%s:lavfi=[split[a][b];[b]setpts=PTS-%s/TB,%s[b];%s]", labels.cropdetect, + options.read_ahead_cropdetect, cropdetect, s.f_sync)) + else + return mp.commandv("vf", "pre", string.format("@%s:lavfi=[split[a][b];[b]%s,nullsink;[a]null]", + labels.cropdetect, cropdetect)) + end + end + if not insert_filter() then + mp.msg.error("Does vf=help as #1 line in mvp.conf return libavfilter list with crop/cropdetect in log?") + s.f_missing = true + cleanup() + return + end + if not s.f_limit_runtime then + s.f_inserted = true -- skip process and wait for new s.collected + end + s.f_limit_change = change -- filter is updated for limit change +end + +local function apply_crop(ref, pts) + -- osd size change + -- TODO add auto/smart mode + local prop_fullscreen = mp.get_property("fullscreen") + if prop_fullscreen ~= "yes" and options.fix_windowed_behavior ~= 0 then + local prop_maximized = mp.get_property("window-maximized") + local osd = mp.get_property_native("osd-dimensions") + local prop_auto_window_resize = mp.get_property("auto-window-resize") + if prop_auto_window_resize == "yes" and options.fix_windowed_behavior == 1 then + -- disable auto resize to avoid resizing at the original size of the video + mp.set_property("auto-window-resize", "no") + end + if prop_maximized ~= "yes" then + if options.fix_windowed_behavior == 2 then + mp.set_property("geometry", string.format("%s", osd.w)) + elseif options.fix_windowed_behavior == 3 then + mp.set_property("geometry", string.format("x%s", osd.h)) + end + end + end + + -- crop filter insertion/update + if s.f_video_crop then + mp.set_property("video-crop", string.format("%sx%s+%s+%s", ref.w, ref.h, ref.x, ref.y)) + elseif filter_state(labels.crop) and not s.seeking then + for _, axis in ipairs({"w", "x", "h", "y"}) do -- "w""x" then "h""y" to reduce visual glitch + if s.applied[axis] ~= ref[axis] then command_filter(labels.crop, axis, ref[axis], "crop") end + end + else + mp.commandv("vf", "append", string.format("@%s:lavfi-crop=%s", labels.crop, ref.whxy)) + end + ref.applied = ref.applied + 1 + s.applied = ref + + print_debug("pre_format", string.format("- Apply: %s", ref.whxy)) + if options.debug and pts then table.insert(s.stats.indexed_applied, {ref = ref, pts = pts}) end +end + +local function compute_metadata(meta) + meta.whxy = string.format("w=%s:h=%s:x=%s:y=%s", meta.w, meta.h, meta.x, meta.y) + meta.offset = {x = meta.x - (s.source.w - meta.w) / 2, y = meta.y - (s.source.h - meta.h) / 2} + meta.mt = meta.y + meta.mb = s.source.h - meta.h - meta.y + meta.ml = meta.x + meta.mr = s.source.w - meta.w - meta.x + meta.is_source = meta.whxy == s.source.whxy + meta.is_invalid = meta.h < 0 or meta.w < 0 + meta.is_trusted_offsets = is_trusted_offset(meta.offset.x, "x") and is_trusted_offset(meta.offset.y, "y") + meta.time = {buffer = 0, overall = 0} + if options.read_ahead_mode > 0 then meta.pts = {} end + local margin = options.detect_round * options.linked_tolerance + meta.is_linked_to_source = meta.mt <= margin and meta.mb <= margin or meta.ml <= margin and meta.mr <= margin + if meta.is_linked_to_source and not meta.is_invalid and s.ratios.w[meta.w] or s.ratios.h[meta.h] then + meta.is_known_ratio = true + end + return meta +end + +local function generate_ratios(list) + for ratio in string.gmatch(list, "%S+%s?") do + for a, b in string.gmatch(tostring(ratio), "(%d+)/(%d+)") do ratio = a / b end + local w, h = math.floor((s.source.h * ratio)), math.floor((s.source.w / ratio)) + local margin = options.ratio_tolerance + for k, v in pairs({w = w, h = h}) do + if v < s.source[k] - options.linked_tolerance then + if v % 2 == 1 then + s.ratios[k][v + 1], s.ratios[k][v - 1] = true, true + if margin > 0 then + s.ratios[k][v + 1 + margin], s.ratios[k][v - 1 - margin] = true, true + end + else + s.ratios[k][v] = true + if margin > 0 then s.ratios[k][v + margin], s.ratios[k][v - margin] = true, true end + end + end + end + end +end + +local function switch_hwdec(id, hwdec, error) + if hwdec ~= "no" and not string.match(hwdec, "-copy") then + local msg = "Switch to SW decoding or HW -copy variant." + mp.msg.info(msg) + mp.osd_message(string.format("%s: %s", label_prefix, msg), 5) + end + if s.hwdec and hwdec ~= s.hwdec and s.hwdec ~= "no" and not string.match(s.hwdec, "-copy") and + filter_state(labels.cropdetect) then mp.commandv("vf", "remove", string.format("@%s", labels.cropdetect)) end + s.hwdec = hwdec +end + +local function process_metadata(collected, timestamp, elapsed_time) + s.in_progress = true -- prevent event race + print_debug("metadata", collected, "Collected") + + local function cleanup_stat(whxy, ref, ref_i, index) + if ref[whxy] then + ref[whxy] = nil + ref_i[index] = ref_i[index] - 1 + end + end + + -- buffer: init + if not s.stats.buffer[collected.whxy] then + s.stats.buffer[collected.whxy] = collected + s.stats.buffer_unique = s.stats.buffer_unique + 1 + end + + -- buffer: add collected or increase it's timer + if s.buffer.i_total == 0 or s.buffer.indexed_list[s.buffer.i_total].ref ~= collected then + s.buffer.i_total = s.buffer.i_total + 1 + s.buffer.i_ratio = s.buffer.i_ratio + 1 + s.buffer.indexed_list[s.buffer.i_total] = {ref = collected, pts = timestamp, t_elapsed = elapsed_time} + if options.read_ahead_mode > 0 then table.insert(collected.pts, timestamp) end + elseif s.last_collected == collected then + s.buffer.indexed_list[s.buffer.i_total].t_elapsed = s.buffer.indexed_list[s.buffer.i_total].t_elapsed + + elapsed_time + end + collected.time.overall = collected.time.overall + elapsed_time + collected.time.buffer = collected.time.buffer + elapsed_time + s.buffer.t_total = s.buffer.t_total + elapsed_time + if s.buffer.i_ratio > 0 then s.buffer.t_ratio = s.buffer.t_ratio + elapsed_time end + + -- candidate offset/fallback to later extend buffer size + if not s.stats.trusted[collected.whxy] and collected.time.buffer > options.ratio_timer and + collected.is_linked_to_source then + if not s.candidate.offset[collected.whxy] and not collected.is_trusted_offsets and collected.is_known_ratio then + s.candidate.offset[collected.whxy] = collected + s.candidate.i_offset = s.candidate.i_offset + 1 + elseif not collected.is_known_ratio and not s.candidate.offset[collected.whxy] and + not s.candidate.fallback[collected.whxy] then + s.candidate.fallback[collected.whxy] = collected + s.candidate.i_fallback = s.candidate.i_fallback + 1 + end + end + + -- add new fallback ratio to the ratio list + if s.candidate.fallback[collected.whxy] and collected.time.buffer >= options.fallback_timer then + -- TODO eventually re-check the buffer list with new ratio + generate_ratios(collected.w .. "/" .. collected.h) + collected.is_known_ratio = true + cleanup_stat(collected.whxy, s.candidate.fallback, s.candidate, "i_fallback") + end + + -- add new offset to the trusted_offsets list + if s.candidate.offset[collected.whxy] and collected.is_known_ratio and collected.is_linked_to_source and + collected.time.buffer >= options.offset_timer then + for _, axis in ipairs({"x", "y"}) do + if not is_trusted_offset(collected.offset[axis], axis) then + table.insert(s.stats.trusted_offset[axis], collected.offset[axis]) + end + end + cleanup_stat(collected.whxy, s.candidate.offset, s.candidate, "i_offset") + collected.is_trusted_offsets = true + end + + -- add collected ready to the trusted list + local new_ready = + not s.stats.trusted[collected.whxy] and collected.is_trusted_offsets and not collected.is_invalid and + collected.is_linked_to_source and collected.is_known_ratio and collected.time.buffer >= options.ratio_timer + if new_ready then + s.stats.trusted[collected.whxy] = collected + s.stats.trusted_unique = s.stats.trusted_unique + 1 + collected.applied = 0 + collected.time.accumulated = collected.time.buffer + end + + -- use current as main metadata, override by corrected or stabilized if needed + local current = collected + + -- correction with trusted metadata for fast change in dark/ambiguous scene + local corrected = {} + if not current.is_invalid and s.stats.trusted_unique > 1 and not s.stats.trusted[current.whxy] then + -- is_bigger than applied meta + corrected.is_bigger = current.mt < s.approved.mt or current.mb < s.approved.mb or current.ml < s.approved.ml or + current.mr < s.approved.mr + -- find closest trusted metadata + local closest = {} + local margin = options.detect_round * options.linked_tolerance + for _, ref in pairs(s.stats.trusted) do + local diff = {ref = ref, vs_current = 0, vs_applied = 0, total = 0} + for _, side in ipairs({"mt", "mb", "ml", "mr"}) do + diff[side] = current[side] - ref[side] + diff.total = diff.total + math.abs(diff[side]) + if diff[side] > margin or diff[side] < -margin then diff.vs_current = diff.vs_current + 1 end + if ref[side] ~= s.approved[side] then diff.vs_applied = diff.vs_applied + 1 end + end + -- is_inside this trusted meta with tiny tolerance for being outside + diff.is_inside = not (diff.mt < -margin or diff.mb < -margin or diff.ml < -margin or diff.mr < -margin) + local pattern = diff.is_inside and + (diff.vs_current <= 1 or diff.vs_current == 2 and diff.vs_applied <= 2 or + diff.vs_current > 2 and corrected.is_bigger) + local set = closest.ref and + (diff.vs_current < closest.vs_current or diff.vs_current == closest.vs_current and + diff.vs_applied < closest.vs_applied or diff.vs_current == closest.vs_current and + diff.vs_applied == closest.vs_applied and diff.total < closest.total) + -- mp.msg.info(string.format("\\ %-5s %-29s curr:%s appl:%s | %-3s %-3s %-3s %-3s %-4s | is_in:%s ", + -- pattern and (not closest.ref or set), ref.whxy, diff.vs_current, diff.vs_applied, diff.mt, diff.mb, + -- diff.ml, diff.mr, diff.total, diff.is_inside)) + if pattern and (not closest.ref or set) then closest = diff end + end + -- replace current with corrected + if closest.ref then + current = closest.ref + corrected.ref = closest.ref + s.buffer.indexed_list[s.buffer.i_total].new_ref = current + print_debug("metadata", current, "\\ Corrected") + else + print_debug("pre_format", "\\ Uncorrected") + end + end + + -- stabilization of odd/unstable meta + local stabilized + if options.detect_round <= 4 and s.stats.trusted[current.whxy] then + local margin = options.detect_round * 4 + local applied_in_margin = math.abs(current.w - s.approved.w) <= margin and math.abs(current.h - s.approved.h) <= + margin + for _, ref in pairs(s.stats.trusted) do + local in_margin = math.abs(current.w - ref.w) <= margin and math.abs(current.h - ref.h) <= margin + if in_margin then + local gt_applied = applied_in_margin and ref ~= s.approved and ref.time.overall > + s.approved.time.overall * 2 + local applied_gt = applied_in_margin and ref == s.approved and ref.time.overall * 2 > + current.time.overall + local pattern = not applied_in_margin and ref.time.overall > current.time.overall or gt_applied or + applied_gt + local set = stabilized and ref.time.overall > stabilized.time.overall + -- mp.msg.info("\\", ref.whxy, ref.time.overall, current.time.overall, s.approved.time.overall) + if ref ~= current and pattern and (not stabilized or set) then stabilized = ref end + end + end + if stabilized then + current = stabilized + s.buffer.indexed_list[s.buffer.i_total].new_ref = current + print_debug("metadata", current, "\\ Stabilized") + end + end + + -- cycle time.accumulated for fast_change_timer (reset if uncorrected) + for whxy, ref in pairs(s.stats.trusted) do + ref.time.accumulated = whxy ~= current.whxy and 0 or ref.time.accumulated < 0 and 0 + elapsed_time or + not new_ready and ref.time.accumulated + elapsed_time or ref.time.accumulated + end + + -- crop: final validation then store or apply it + local detect_source = current == s.last_current and (current.is_source or collected.is_source) and s.limit.target >= + 0 + local confirmation = not current.is_source and s.stats.trusted[current.whxy] and current.time.accumulated >= + options.fast_change_timer and (not corrected.ref or current == s.last_current) + local crop_filter = s.approved ~= current and (confirmation or detect_source) + if crop_filter and (not s.timestamps.prevent or timestamp >= s.timestamps.prevent) then + s.approved = current -- reflect s.applied for read_head + if s.limit.current < s.limit.min then + s.limit.min = s.limit.current -- store minimum limit + end + if s.f_limit_runtime and options.read_ahead_mode > 0 then + local pts = current.time.accumulated < options.ratio_timer and timestamp - current.time.accumulated or + current.pts[1] + table.insert(s.indexed_read_ahead, {ref = current, pts = pts}) + s.timestamps.read_ahead = nil + else + apply_crop(current, timestamp) + end + if options.prevent_change_mode > 0 then + s.timestamps.prevent = nil + if (options.prevent_change_mode == 1 and (current.w > s.approved.w or current.h > s.approved.h) or + options.prevent_change_mode == 2 and (current.w < s.approved.w or current.h < s.approved.h) or + options.prevent_change_mode == 3) then + s.timestamps.prevent = timestamp + options.prevent_change_timer + end + end + if options.mode <= 2 then on_toggle(true) end + end + + local function is_time_to_cleanup_buffer(time, target_time) + return time > target_time * (1 + options.segmentation) + end + + -- buffer: reduce size of known ratio stats + while is_time_to_cleanup_buffer(s.buffer.t_ratio, options.ratio_timer) do + local i = (s.buffer.i_total + 1) - s.buffer.i_ratio + s.buffer.t_ratio = s.buffer.t_ratio - s.buffer.indexed_list[i].t_elapsed + s.buffer.i_ratio = s.buffer.i_ratio - 1 + end + + -- buffer: check for candidate to extend it + local buffer_timer = s.candidate.i_offset > 0 and options.offset_timer or s.candidate.i_fallback > 0 and + options.fallback_timer or options.ratio_timer + + -- buffer: cleanup fake candidate + local function is_proactive_cleanup_needed() + local test + if is_time_to_cleanup_buffer(s.buffer.t_total, options.ratio_timer) then + for _, cat in ipairs({"offset", "fallback"}) do + if s.candidate["i_" .. cat] > 0 then + test = true + for whxy, ref in pairs(s.candidate[cat]) do + if ref.time.buffer > s.buffer.t_total * options.reverse_segmentation then + return false -- if at least one is a proper candidate + end + end + end + end + end + return test + end + + -- buffer: reduce total size + while is_time_to_cleanup_buffer(s.buffer.t_total, buffer_timer) or is_proactive_cleanup_needed() do + s.buffer.i_to_shift = s.buffer.i_to_shift + 1 + local entry = s.buffer.indexed_list[s.buffer.i_to_shift] + entry.ref.time.buffer = entry.ref.time.buffer - entry.t_elapsed + if options.read_ahead_mode > 0 then table.remove(entry.ref.pts, 1) end + if s.stats.buffer[entry.ref.whxy] and entry.ref.time.buffer == 0 then + cleanup_stat(entry.ref.whxy, s.stats.buffer, s.stats, "buffer_unique") + cleanup_stat(entry.ref.whxy, s.candidate.offset, s.candidate, "i_offset") + cleanup_stat(entry.ref.whxy, s.candidate.fallback, s.candidate, "i_fallback") + end + s.buffer.t_total = s.buffer.t_total - entry.t_elapsed + end + + -- buffer: shift the list to overwrite unused data + if s.buffer.i_to_shift >= 20 or s.buffer.i_to_shift == s.buffer.i_total then + for i = s.buffer.i_to_shift + 1, s.buffer.i_total do + s.buffer.indexed_list[i - s.buffer.i_to_shift] = s.buffer.indexed_list[i] + end + for i = 0, s.buffer.i_to_shift - 1 do s.buffer.indexed_list[s.buffer.i_total - i] = nil end + s.buffer.i_total = s.buffer.i_total - s.buffer.i_to_shift + s.buffer.i_to_shift = 0 + collectgarbage("step") + end + + -- limit: automatic adjustment + s.last_limit = s.limit.current + if s.f_limit_runtime or timestamp >= s.limit.timer then + s.limit.last_target = s.limit.target + if collected.is_source or current.is_source or corrected.is_bigger then + -- increase limit + s.limit.target = 1 + if s.limit.current + s.limit.step * s.limit.up <= options.detect_limit then + s.limit.current = s.limit.current + s.limit.step * s.limit.up + else + s.limit.current = options.detect_limit + end + elseif not current.is_invalid and + (collected.is_trusted_offsets or collected == s.last_collected or current == s.last_current) then + -- stable limit + s.limit.target = 0 + -- reset limit to help with different dark color + if not current.is_trusted_offsets then s.limit.current = options.detect_limit end + elseif s.limit.current > 0 then + -- decrease limit + s.limit.target = -1 + if s.limit.min < s.limit.current and s.limit.last_target == -1 then + s.limit.current = s.limit.min + elseif s.limit.current - s.limit.step >= 0 then + s.limit.current = s.limit.current - s.limit.step + else + s.limit.current = 0 + end + end + end + + -- store for next process + s.last_current = current + s.last_collected = collected + s.last_timestamp = timestamp + + -- limit: apply change + if s.last_limit ~= s.limit.current then + if not s.f_limit_runtime and options.limit_timer > 0 then s.limit.timer = timestamp + options.limit_timer end + s.limit.counter = s.limit.counter + 1 + insert_cropdetect_filter(s.limit.current, true) + end + + s.in_progress = false +end + +local function time_pos(event, value, err) + if value and s.indexed_read_ahead[1] then + local time_pos = shifting_to(RIGHT, value) + local deviation = math.abs(time_pos - s.pts) + local crop_sync = s.frametime * (options.read_ahead_sync + options.crop_method_sync) + local time_pos_read_ahead = time_pos - (options.read_ahead_timer - deviation - crop_sync) + if time_pos_read_ahead >= s.indexed_read_ahead[1].pts then + apply_crop(s.indexed_read_ahead[1].ref, s.indexed_read_ahead[1].pts) + table.remove(s.indexed_read_ahead, 1) + end + end +end + +local function collect_metadata(event) + if event.prefix == "ffmpeg" and event.level == "v" and string.find(event.text, "^.*dyn_cd: ") and + not (s.seeking or s.paused or s.toggled > 1) then + local tmp = {} + for k, v in string.gmatch(event.text, "(%w+):(%-?%d+%.?%d* )") do tmp[k] = tonumber(v) end + tmp.whxy = string.format("w=%d:h=%d:x=%d:y=%d", tmp.w, tmp.h, tmp.x, tmp.y) + s.pts = shifting_to(LEFT, tmp.pts) + if tmp.whxy ~= s.collected.whxy then + s.collected = s.stats.trusted[tmp.whxy] or s.stats.buffer[tmp.whxy] or compute_metadata(tmp) + end + + s.limit.last_collect = s.limit.collect + s.limit.collect = tmp.limit or s.limit.collect + s.f_limit_runtime = tmp.limit ~= nil -- if ffmpeg is patch for limit change at runtime + + s.timestamps.previous = s.timestamps.current + s.timestamps.current = s.pts + + local wait_limit = s.f_limit_runtime and s.f_limit_change and s.limit.collect == s.limit.last_collect + if not wait_limit then s.f_limit_change = false end + + if s.in_progress or not s.timestamps.previous or wait_limit or s.f_inserted or s.timestamps.current < + options.start_delay then + s.f_inserted = false + return + end + + local elapsed_time = s.timestamps.current - s.timestamps.previous + if not s.frametime or elapsed_time < s.frametime and elapsed_time > 0 then s.frametime = elapsed_time end + + process_metadata(s.collected, s.timestamps.current, elapsed_time) + end +end + +local function seek(event) + if s.seek_done then return end + print_debug("pre_format", string.format("Stop by %s event.", event)) + if event == "seek" or event == "toggle" then + s.timestamps = {} + s.limit.timer = 0 + s.approved = s.applied -- re-sync + if event == "seek" then + if s.f_limit_runtime then insert_cropdetect_filter(s.limit.current) end + if not s.f_video_crop and + (filter_state(labels.crop, "enabled", true) or not filter_state(labels.crop) and s.applied ~= s.source) then + apply_crop(s.applied) + end + end + if s.f_limit_runtime then + s.indexed_read_ahead = {} + s.collected = {} + end + s.seek_done = true -- avoid seek() in loop until we resume() + end +end + +local function resume(event) + s.seek_done = false + print_debug("pre_format", string.format("Resume by %s event.", event)) + if event == "toggle" and s.f_limit_runtime or not filter_state(labels.cropdetect) then + insert_cropdetect_filter(s.limit.current) + end +end + +local function playback_events(t, id, error) + if t.event == "seek" then + s.seeking = true + seek(t.event) + else + if not s.paused then resume(t.event) end + s.seeking = false + end +end + +local ENABLE, DISABLE_WITH_CROP, DISABLE = 1, 2, 3 +function on_toggle(auto) + if s.f_missing then + mp.osd_message("Libavfilter cropdetect missing", 3) + return + end + local EVENT = "toggle" + if s.toggled == ENABLE then + s.toggled = DISABLE_WITH_CROP + if filter_state(labels.cropdetect, "enabled", true) then + mp.commandv("vf", EVENT, string.format("@%s", labels.cropdetect)) + end + seek(EVENT) + if not auto then mp.osd_message(string.format("%s: disabled, crop remains.", label_prefix), 3) end + elseif s.toggled == DISABLE_WITH_CROP then + s.toggled = DISABLE + if filter_state(labels.cropdetect, "enabled", false) then + if s.f_video_crop then + mp.set_property("video-crop", "") + elseif filter_state(labels.crop, "enabled", true) then + mp.commandv("vf", EVENT, string.format("@%s", labels.crop)) + end + end + if not auto then mp.osd_message(string.format("%s: crop removed.", label_prefix), 3) end + else -- s.toggled == DISABLE + s.toggled = ENABLE + if filter_state(labels.cropdetect, "enabled", false) then + mp.commandv("vf", EVENT, string.format("@%s", labels.cropdetect)) + end + if s.f_video_crop then + apply_crop(s.applied) + elseif filter_state(labels.crop, "enabled", false) then + mp.commandv("vf", EVENT, string.format("@%s", labels.crop)) + end + resume(EVENT) + if not auto then mp.osd_message(string.format("%s: enabled.", label_prefix), 3) end + end +end + +local function pause(event, is_paused) + s.paused = is_paused + if is_paused then + seek(event) + print_stats() + print_debug("buffer") + print_debug("applied") + print_debug("pre_format", "s.approved: " .. s.approved.whxy) + print_debug("pre_format", "s.applied: " .. s.applied.whxy) + if s.indexed_read_ahead[1] then + print_debug("pre_format", "s.indexed_read_ahead[1]: " .. s.indexed_read_ahead[1].ref.whxy) + end + else + if s.toggled == 1 then resume(event) end + end +end + +function cleanup() + if not s.started then return end + if not s.paused then print_stats() end + mp.msg.info("Cleanup...") + mp.set_property("auto-window-resize", s.user_auto_window_resize) + mp.unregister_event(playback_events) + mp.unregister_event(collect_metadata) + mp.unobserve_property(time_pos) + mp.unobserve_property(switch_hwdec) + mp.unobserve_property(pause) + for _, label in pairs(labels) do + if filter_state(label) then mp.commandv("vf", "remove", string.format("@%s", label)) end + end + if s.f_video_crop then mp.set_property("video-crop", "") end + mp.msg.info("Done.") + s.started = false +end + +local function on_start() + mp.msg.info("File loaded.") + if not is_cropable() then + mp.msg.warn("Exit, only works for videos.") + return + end + s.user_geometry = mp.get_property("geometry") + s.user_auto_window_resize = mp.get_property("auto-window-resize") + if options.fix_windowed_behavior == 1 and s.user_auto_window_resize == "yes" then + mp.set_property("auto-window-resize", "no") + end + -- init/re-init stored data + s.buffer = {i_to_shift = 0, i_total = 0, i_ratio = 0, indexed_list = {}, t_total = 0, t_ratio = 0} + s.candidate = {i_fallback = 0, i_offset = 0, fallback = {}, offset = {}} + s.collected = {} + s.indexed_read_ahead = {} + s.limit = { + counter = 0, current = options.detect_limit, min = options.detect_limit, step = 2, target = 0, timer = 0, up = 2 + } + s.stats = {applied = {}, buffer = {}, buffer_unique = 0, trusted = {}, trusted_offset = {}, trusted_unique = 1} + s.stats.indexed_applied = {} + s.source = {w_untouched = mp.get_property_number("width"), h_untouched = mp.get_property_number("height")} + s.source.w = math.floor(s.source.w_untouched / options.detect_round) * options.detect_round + s.source.h = math.floor(s.source.h_untouched / options.detect_round) * options.detect_round + s.source.x = math.floor((s.source.w_untouched - s.source.w) / 2) + s.source.y = math.floor((s.source.h_untouched - s.source.h) / 2) + s.stats.trusted_offset = {x = {s.source.x}, y = {s.source.y}} + s.ratios = {w = {}, h = {}} + generate_ratios(options.ratios) + s.source = compute_metadata(s.source) + s.stats.trusted[s.source.whxy] = s.source + s.source.applied = 1 + s.source.time.accumulated = 0 + s.applied = s.source + s.approved = s.source + s.timestamps = {} + if options.read_ahead_mode > 0 then + -- assume cropdetect is patch for command "limit", fallback at the first collected metadata otherwise. + s.f_limit_runtime = true + -- quick test for dummysync filter + s.f_sync = mp.commandv("vf", "add", string.format("@%s:lavfi=[split[a][b];[a][b]dummysync]", label_prefix)) and + mp.commandv("vf", "remove", string.format("@%s", label_prefix)) and "[a][b]dummysync" or + "[a][b]psnr=eof_action=pass" + end + s.f_video_crop = options.crop_method == 1 and mp.get_property("video-crop") ~= nil -- true if supported + -- register events + mp.register_event("seek", playback_events) + mp.register_event("playback-restart", playback_events) + mp.observe_property("time-pos", "number", time_pos) + mp.observe_property("hwdec", "string", switch_hwdec) + mp.observe_property("pause", "bool", pause) + mp.enable_messages('v') + mp.register_event("log-message", collect_metadata) + s.toggled = (options.mode % 2 == 1) and DISABLE or ENABLE + s.started = true -- everything ready +end + +mp.add_key_binding("C", "toggle_crop", on_toggle) +mp.register_event("end-file", cleanup) +mp.register_event("file-loaded", on_start) + + diff --git a/.config/mpv/scripts/gamma-auto.lua b/.config/mpv/scripts/gamma-auto.lua new file mode 120000 index 0000000..76d560a --- /dev/null +++ b/.config/mpv/scripts/gamma-auto.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/gamma-auto.lua \ No newline at end of file diff --git a/.config/mpv/scripts/idet.sh b/.config/mpv/scripts/idet.sh new file mode 120000 index 0000000..0f14412 --- /dev/null +++ b/.config/mpv/scripts/idet.sh @@ -0,0 +1 @@ +/usr/share/mpv/scripts/idet.sh \ No newline at end of file diff --git a/.config/mpv/scripts/mpris.so b/.config/mpv/scripts/mpris.so new file mode 120000 index 0000000..f85fd8b --- /dev/null +++ b/.config/mpv/scripts/mpris.so @@ -0,0 +1 @@ +/usr/lib/mpv-mpris/mpris.so \ No newline at end of file diff --git a/.config/mpv/scripts/mpv_identify.sh b/.config/mpv/scripts/mpv_identify.sh new file mode 120000 index 0000000..4d1dcc8 --- /dev/null +++ b/.config/mpv/scripts/mpv_identify.sh @@ -0,0 +1 @@ +/usr/share/mpv/scripts/mpv_identify.sh \ No newline at end of file diff --git a/.config/mpv/scripts/observe-all.lua b/.config/mpv/scripts/observe-all.lua new file mode 120000 index 0000000..fc1027d --- /dev/null +++ b/.config/mpv/scripts/observe-all.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/observe-all.lua \ No newline at end of file diff --git a/.config/mpv/scripts/ontop-playback.lua b/.config/mpv/scripts/ontop-playback.lua new file mode 120000 index 0000000..3862f7d --- /dev/null +++ b/.config/mpv/scripts/ontop-playback.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/ontop-playback.lua \ No newline at end of file diff --git a/.config/mpv/scripts/pause-when-minimize.lua b/.config/mpv/scripts/pause-when-minimize.lua new file mode 120000 index 0000000..a6fc9af --- /dev/null +++ b/.config/mpv/scripts/pause-when-minimize.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/pause-when-minimize.lua \ No newline at end of file diff --git a/.config/mpv/scripts/quality-menu-osc.lua.bak b/.config/mpv/scripts/quality-menu-osc.lua.bak new file mode 100644 index 0000000..d710ead --- /dev/null +++ b/.config/mpv/scripts/quality-menu-osc.lua.bak @@ -0,0 +1,3032 @@ +local assdraw = require("mp.assdraw") +local msg = require("mp.msg") +local opt = require("mp.options") + +-- +-- Parameters +-- +-- default user option values +-- do not touch, change them in osc.conf +local user_opts = { + showwindowed = true, -- show OSC when windowed? + showfullscreen = true, -- show OSC when fullscreen? + idlescreen = true, -- show mpv logo on idle + scalewindowed = 1, -- scaling of the controller when windowed + scalefullscreen = 1, -- scaling of the controller when fullscreen + vidscale = "auto", -- scale the controller with the video? + valign = 0.8, -- vertical alignment, -1 (top) to 1 (bottom) + halign = 0, -- horizontal alignment, -1 (left) to 1 (right) + barmargin = 0, -- vertical margin of top/bottombar + boxalpha = 80, -- alpha of the background box, + -- 0 (opaque) to 255 (fully transparent) + hidetimeout = 500, -- duration in ms until the OSC hides if no + -- mouse movement. enforced non-negative for the + -- user, but internally negative is "always-on". + fadeduration = 200, -- duration of fade out (and fade in, if enabled) in ms, 0 = no fade + fadein = false, -- whether to enable fade-in effect + deadzonesize = 0.5, -- size of deadzone + minmousemove = 0, -- minimum amount of pixels the mouse has to + -- move between ticks to make the OSC show up + layout = "bottombar", + seekbarstyle = "bar", -- bar, diamond or knob + seekbarhandlesize = 0.6, -- size ratio of the diamond and knob handle + seekrangestyle = "inverted", -- bar, line, slider, inverted or none + seekrangeseparate = true, -- whether the seekranges overlay on the bar-style seekbar + seekrangealpha = 200, -- transparency of seekranges + seekbarkeyframes = true, -- use keyframes when dragging the seekbar + scrollcontrols = true, -- allow scrolling when hovering certain OSC elements + title = "${!playlist-count==1:[${playlist-pos-1}/${playlist-count}] }${media-title}", + -- to be shown as OSC title + tooltipborder = 1, -- border of tooltip in bottom/topbar + timetotal = false, -- display total time instead of remaining time? + remaining_playtime = true, -- display the remaining time in playtime or video-time mode + -- playtime takes speed into account, whereas video-time doesn't + timems = false, -- display timecodes with milliseconds? + tcspace = 100, -- timecode spacing (compensate font size estimation) + visibility = "auto", -- only used at init to set visibility_mode(...) + visibility_modes = "never_auto_always", -- visibility modes to cycle through + boxmaxchars = 80, -- title crop threshold for box layout + boxvideo = false, -- apply osc_param.video_margins to video + windowcontrols = "auto", -- whether to show window controls + windowcontrols_alignment = "right", -- which side to show window controls on + windowcontrols_title = "${media-title}", -- same as title but for windowcontrols + greenandgrumpy = false, -- disable santa hat + livemarkers = true, -- update seekbar chapter markers on duration change + chapter_fmt = "Chapter: %s", -- chapter print format for seekbar-hover. "no" to disable + unicodeminus = false, -- whether to use the Unicode minus sign character + + background_color = "#000000", -- background color of the osc + timecode_color = "#FFFFFF", -- color of the progress bar and time color + title_color = "#FFFFFF", -- color of the title + time_pos_color = "#FFFFFF", -- color of the timecode at hovered position + buttons_color = "#FFFFFF", -- color of big buttons, wc buttons, and bar small buttons + small_buttonsL_color = "#FFFFFF", -- color of left small buttons + small_buttonsR_color = "#FFFFFF", -- color of right small buttons + top_buttons_color = "#FFFFFF", -- color of top buttons + held_element_color = "#999999", -- color of an element while held down + + time_pos_outline_color = "#000000", -- color of the border timecodes in slimbox and TimePosBar + + tick_delay = 1 / 60, -- minimum interval between OSC redraws in seconds + tick_delay_follow_display_fps = false, -- use display fps as the minimum interval + + -- luacheck: push ignore + -- luacheck: max line length + menu_mbtn_left_command = "script-binding select/menu; script-message-to osc osc-hide", + menu_mbtn_mid_command = "", + menu_mbtn_right_command = "", + + playlist_prev_mbtn_left_command = "playlist-prev", + playlist_prev_mbtn_mid_command = "show-text ${playlist} 3000", + playlist_prev_mbtn_right_command = "script-binding select/select-playlist; script-message-to osc osc-hide", + + playlist_next_mbtn_left_command = "playlist-next", + playlist_next_mbtn_mid_command = "show-text ${playlist} 3000", + playlist_next_mbtn_right_command = "script-binding select/select-playlist; script-message-to osc osc-hide", + + title_mbtn_left_command = "script-binding stats/display-page-5", + title_mbtn_mid_command = "show-text ${path}", + title_mbtn_right_command = "script-binding select/select-watch-history; script-message-to osc osc-hide", + + play_pause_mbtn_left_command = "cycle pause", + play_pause_mbtn_mid_command = "cycle-values loop-playlist inf no", + play_pause_mbtn_right_command = "cycle-values loop-file inf no", + + chapter_prev_mbtn_left_command = "osd-msg add chapter -1", + chapter_prev_mbtn_mid_command = "show-text ${chapter-list} 3000", + chapter_prev_mbtn_right_command = "script-binding select/select-chapter; script-message-to osc osc-hide", + + chapter_next_mbtn_left_command = "osd-msg add chapter 1", + chapter_next_mbtn_mid_command = "show-text ${chapter-list} 3000", + chapter_next_mbtn_right_command = "script-binding select/select-chapter; script-message-to osc osc-hide", + + audio_track_mbtn_left_command = "cycle audio", + audio_track_mbtn_mid_command = "cycle audio down", + audio_track_mbtn_right_command = "script-binding select/select-aid; script-message-to osc osc-hide", + audio_track_wheel_down_command = "cycle audio", + audio_track_wheel_up_command = "cycle audio down", + + sub_track_mbtn_left_command = "cycle sub", + sub_track_mbtn_mid_command = "cycle sub down", + sub_track_mbtn_right_command = "script-binding select/select-sid; script-message-to osc osc-hide", + sub_track_wheel_down_command = "cycle sub", + sub_track_wheel_up_command = "cycle sub down", + + volume_mbtn_left_command = "no-osd cycle mute", + volume_mbtn_mid_command = "", + volume_mbtn_right_command = "script-binding select/select-audio-device; script-message-to osc osc-hide", + volume_wheel_down_command = "add volume -5", + volume_wheel_up_command = "add volume 5", + + fullscreen_mbtn_left_command = "cycle fullscreen", + fullscreen_mbtn_mid_command = "", + fullscreen_mbtn_right_command = "cycle window-maximized", + -- luacheck: pop +} + +for i = 1, 99 do + user_opts["custom_button_" .. i .. "_content"] = "" + user_opts["custom_button_" .. i .. "_mbtn_left_command"] = "" + user_opts["custom_button_" .. i .. "_mbtn_mid_command"] = "" + user_opts["custom_button_" .. i .. "_mbtn_right_command"] = "" + user_opts["custom_button_" .. i .. "_wheel_down_command"] = "" + user_opts["custom_button_" .. i .. "_wheel_up_command"] = "" +end + +local icon_font = "mpv-osd-symbols" + +-- Running this in Lua 5.3+ or LuaJIT converts a hexadecimal Unicode code point +-- to the decimal value of every byte for Lua 5.1 and 5.2 compatibility: +-- glyph='\u{e000}' output='' +-- for i = 1, #glyph do output = output .. '\\' .. string.byte(glyph, i) end +-- print(output) +local icons = { + menu = "\238\132\130", -- E102 + prev = "\238\132\144", -- E110 + next = "\238\132\129", -- E101 + pause = "\238\128\130", -- E002 + play = "\238\132\129", -- E101 + play_backward = "\238\132\144", -- E110 + skip_backward = "\238\128\132", -- E004 + skip_forward = "\238\128\133", -- E005 + chapter_prev = "\238\132\132", -- E104 + chapter_next = "\238\132\133", -- E105 + audio = "\238\132\134", -- E106 + subtitle = "\238\132\135", -- E107 + mute = "\238\132\138", -- E10A + volume = { "\238\132\139", "\238\132\140", "\238\132\141", "\238\132\142" }, -- E10B E10C E10D E10E + fullscreen = "\238\132\136", -- E108 + exit_fullscreen = "\238\132\137", -- E109 + close = "\238\132\149", -- E115 + minimize = "\238\132\146", -- E112 + maximize = "\238\132\147", -- E113 + unmaximize = "\238\132\148", -- E114 +} + +local osc_param = { -- calculated by osc_init() + playresy = 0, -- canvas size Y + playresx = 0, -- canvas size X + display_aspect = 1, + unscaled_y = 0, + areas = {}, + video_margins = { + l = 0, + r = 0, + t = 0, + b = 0, -- left/right/top/bottom + }, +} + +local margins_opts = { + { "l", "video-margin-ratio-left" }, + { "r", "video-margin-ratio-right" }, + { "t", "video-margin-ratio-top" }, + { "b", "video-margin-ratio-bottom" }, +} + +local tick_delay = 1 / 60 +local audio_track_count = 0 +local sub_track_count = 0 +local window_control_box_width = 80 +local layouts = {} +local is_december = os.date("*t").month == 12 +local UNICODE_MINUS = string.char(0xe2, 0x88, 0x92) -- UTF-8 for U+2212 MINUS SIGN +local last_custom_button = 0 + +local function osc_color_convert(color) + return color:sub(6, 7) .. color:sub(4, 5) .. color:sub(2, 3) +end + +-- luacheck: push ignore +-- luacheck: max line length +local osc_styles + +local function set_osc_styles() + osc_styles = { + bigButtons = "{\\blur0\\bord0\\1c&H" + .. osc_color_convert(user_opts.buttons_color) + .. "\\3c&HFFFFFF\\fs50\\fn" + .. icon_font + .. "}", + smallButtonsL = "{\\blur0\\bord0\\1c&H" + .. osc_color_convert(user_opts.small_buttonsL_color) + .. "\\3c&HFFFFFF\\fs19\\fn" + .. icon_font + .. "}", + smallButtonsLlabel = "{\\fscx105\\fscy105\\fn" .. mp.get_property("options/osd-font") .. "}", + smallButtonsR = "{\\blur0\\bord0\\1c&H" + .. osc_color_convert(user_opts.small_buttonsR_color) + .. "\\3c&HFFFFFF\\fs30\\fn" + .. icon_font + .. "}", + topButtons = "{\\blur0\\bord0\\1c&H" + .. osc_color_convert(user_opts.top_buttons_color) + .. "\\3c&HFFFFFF\\fs12\\fn" + .. icon_font + .. "}", + + elementDown = "{\\1c&H" .. osc_color_convert(user_opts.held_element_color) .. "}", + timecodes = "{\\blur0\\bord0\\1c&H" .. osc_color_convert(user_opts.timecode_color) .. "\\3c&HFFFFFF\\fs20}", + vidtitle = "{\\blur0\\bord0\\1c&H" .. osc_color_convert(user_opts.title_color) .. "\\3c&HFFFFFF\\fs14\\q2}", + box = "{\\rDefault\\blur0\\bord1\\1c&H" .. osc_color_convert(user_opts.background_color) .. "\\3c&HFFFFFF}", + + topButtonsBar = "{\\blur0\\bord0\\1c&H" + .. osc_color_convert(user_opts.top_buttons_color) + .. "\\3c&HFFFFFF\\fs18\\fn" + .. icon_font + .. "}", + smallButtonsBar = "{\\blur0\\bord0\\1c&H" + .. osc_color_convert(user_opts.buttons_color) + .. "\\3c&HFFFFFF\\fs28\\fn" + .. icon_font + .. "}", + timecodesBar = "{\\blur0\\bord0\\1c&H" .. osc_color_convert(user_opts.timecode_color) .. "\\3c&HFFFFFF\\fs27}", + timePosBar = "{\\blur0\\bord" .. user_opts.tooltipborder .. "\\1c&H" .. osc_color_convert( + user_opts.time_pos_color + ) .. "\\3c&H" .. osc_color_convert(user_opts.time_pos_outline_color) .. "\\fs30}", + vidtitleBar = "{\\blur0\\bord0\\1c&H" .. osc_color_convert(user_opts.title_color) .. "\\3c&HFFFFFF\\fs18\\q2}", + + wcButtons = "{\\1c&H" .. osc_color_convert(user_opts.buttons_color) .. "\\fs24\\fn" .. icon_font .. "}", + wcTitle = "{\\1c&H" .. osc_color_convert(user_opts.title_color) .. "\\fs24\\q2}", + wcBar = "{\\1c&H" .. osc_color_convert(user_opts.background_color) .. "}", + } +end + +-- internal states, do not touch +local state = { + showtime = nil, -- time of last invocation (last mouse move) + touchtime = nil, -- time of last invocation (last touch event) + osc_visible = false, + anistart = nil, -- time when the animation started + anitype = nil, -- current type of animation + animation = nil, -- current animation alpha + mouse_down_counter = 0, -- used for softrepeat + active_element = nil, -- nil = none, 0 = background, 1+ = see elements[] + active_event_source = nil, -- the "button" that issued the current event + rightTC_trem = not user_opts.timetotal, -- if the right timecode should display total or remaining time + tc_ms = user_opts.timems, -- Should the timecodes display their time with milliseconds + screen_sizeX = nil, + screen_sizeY = nil, -- last screen-resolution, to detect resolution changes to issue reINITs + initREQ = false, -- is a re-init request pending? + marginsREQ = false, -- is a margins update pending? + last_mouseX = nil, + last_mouseY = nil, -- last mouse position, to detect significant mouse movement + mouse_in_window = false, + fullscreen = false, + tick_timer = nil, + tick_last_time = 0, -- when the last tick() was run + hide_timer = nil, + cache_state = nil, + idle = false, + enabled = true, + input_enabled = true, + showhide_enabled = false, + windowcontrols_buttons = false, + windowcontrols_title = false, + dmx_cache = 0, + using_video_margins = false, + border = true, + maximized = false, + osd = mp.create_osd_overlay("ass-events"), + chapter_list = {}, -- sorted by time + visibility_modes = {}, -- visibility_modes to cycle through + osc_message_warned = false, -- deprecation warnings + osc_chapterlist_warned = false, + osc_playlist_warned = false, + osc_tracklist_warned = false, +} + +local logo_lines = { + -- White border + "{\\c&HE5E5E5&\\p6}m 895 10 b 401 10 0 410 0 905 0 1399 401 1800 895 1800 1390 1800 1790 1399 1790 905 1790 410 1390 10 895 10 {\\p0}", + -- Purple fill + "{\\c&H682167&\\p6}m 925 42 b 463 42 87 418 87 880 87 1343 463 1718 925 1718 1388 1718 1763 1343 1763 880 1763 418 1388 42 925 42{\\p0}", + -- Darker fill + "{\\c&H430142&\\p6}m 1605 828 b 1605 1175 1324 1456 977 1456 631 1456 349 1175 349 828 349 482 631 200 977 200 1324 200 1605 482 1605 828{\\p0}", + -- White fill + "{\\c&HDDDBDD&\\p6}m 1296 910 b 1296 1131 1117 1310 897 1310 676 1310 497 1131 497 910 497 689 676 511 897 511 1117 511 1296 689 1296 910{\\p0}", + -- Triangle + "{\\c&H691F69&\\p6}m 762 1113 l 762 708 b 881 776 1000 843 1119 911 1000 978 881 1046 762 1113{\\p0}", +} + +local santa_hat_lines = { + -- Pompoms + "{\\c&HC0C0C0&\\p6}m 500 -323 b 491 -322 481 -318 475 -311 465 -312 456 -319 446 -318 434 -314 427 -304 417 -297 410 -290 404 -282 395 -278 390 -274 387 -267 381 -265 377 -261 379 -254 384 -253 397 -244 409 -232 425 -228 437 -228 446 -218 457 -217 462 -216 466 -213 468 -209 471 -205 477 -203 482 -206 491 -211 499 -217 508 -222 532 -235 556 -249 576 -267 584 -272 584 -284 578 -290 569 -305 550 -312 533 -309 523 -310 515 -316 507 -321 505 -323 503 -323 500 -323{\\p0}", + "{\\c&HE0E0E0&\\p6}m 315 -260 b 286 -258 259 -240 246 -215 235 -210 222 -215 211 -211 204 -188 177 -176 172 -151 170 -139 163 -128 154 -121 143 -103 141 -81 143 -60 139 -46 125 -34 129 -17 132 -1 134 16 142 30 145 56 161 80 181 96 196 114 210 133 231 144 266 153 303 138 328 115 373 79 401 28 423 -24 446 -73 465 -123 483 -174 487 -199 467 -225 442 -227 421 -232 402 -242 384 -254 364 -259 342 -250 322 -260 320 -260 317 -261 315 -260{\\p0}", + -- Main cap + "{\\c&H0000F0&\\p6}m 1151 -523 b 1016 -516 891 -458 769 -406 693 -369 624 -319 561 -262 526 -252 465 -235 479 -187 502 -147 551 -135 588 -111 1115 165 1379 232 1909 761 1926 800 1952 834 1987 858 2020 883 2053 912 2065 952 2088 1000 2146 962 2139 919 2162 836 2156 747 2143 662 2131 615 2116 567 2122 517 2120 410 2090 306 2089 199 2092 147 2071 99 2034 64 1987 5 1928 -41 1869 -86 1777 -157 1712 -256 1629 -337 1578 -389 1521 -436 1461 -476 1407 -509 1343 -507 1284 -515 1240 -519 1195 -521 1151 -523{\\p0}", + -- Cap shadow + "{\\c&H0000AA&\\p6}m 1657 248 b 1658 254 1659 261 1660 267 1669 276 1680 284 1689 293 1695 302 1700 311 1707 320 1716 325 1726 330 1735 335 1744 347 1752 360 1761 371 1753 352 1754 331 1753 311 1751 237 1751 163 1751 90 1752 64 1752 37 1767 14 1778 -3 1785 -24 1786 -45 1786 -60 1786 -77 1774 -87 1760 -96 1750 -78 1751 -65 1748 -37 1750 -8 1750 20 1734 78 1715 134 1699 192 1694 211 1689 231 1676 246 1671 251 1661 255 1657 248 m 1909 541 b 1914 542 1922 549 1917 539 1919 520 1921 502 1919 483 1918 458 1917 433 1915 407 1930 373 1942 338 1947 301 1952 270 1954 238 1951 207 1946 214 1947 229 1945 239 1939 278 1936 318 1924 356 1923 362 1913 382 1912 364 1906 301 1904 237 1891 175 1887 150 1892 126 1892 101 1892 68 1893 35 1888 2 1884 -9 1871 -20 1859 -14 1851 -6 1854 9 1854 20 1855 58 1864 95 1873 132 1883 179 1894 225 1899 273 1908 362 1910 451 1909 541{\\p0}", + -- Brim and tip pompom + "{\\c&HF8F8F8&\\p6}m 626 -191 b 565 -155 486 -196 428 -151 387 -115 327 -101 304 -47 273 2 267 59 249 113 219 157 217 213 215 265 217 309 260 302 285 283 373 264 465 264 555 257 608 252 655 292 709 287 759 294 816 276 863 298 903 340 972 324 1012 367 1061 394 1125 382 1167 424 1213 462 1268 482 1322 506 1385 546 1427 610 1479 662 1510 690 1534 725 1566 752 1611 796 1664 830 1703 880 1740 918 1747 986 1805 1005 1863 991 1897 932 1916 880 1914 823 1945 777 1961 725 1979 673 1957 622 1938 575 1912 534 1862 515 1836 473 1790 417 1755 351 1697 305 1658 266 1633 216 1593 176 1574 138 1539 116 1497 110 1448 101 1402 77 1371 37 1346 -16 1295 15 1254 6 1211 -27 1170 -62 1121 -86 1072 -104 1027 -128 976 -133 914 -130 851 -137 794 -162 740 -181 679 -168 626 -191 m 2051 917 b 1971 932 1929 1017 1919 1091 1912 1149 1923 1214 1970 1254 2000 1279 2027 1314 2066 1325 2139 1338 2212 1295 2254 1238 2281 1203 2287 1158 2282 1116 2292 1061 2273 1006 2229 970 2206 941 2167 938 2138 918{\\p0}", +} +-- luacheck: pop + +-- +-- Helper functions +-- + +local function kill_animation() + state.anistart = nil + state.animation = nil + state.anitype = nil +end + +local function set_osd(res_x, res_y, text, z) + if state.osd.res_x == res_x and state.osd.res_y == res_y and state.osd.data == text then + return + end + state.osd.res_x = res_x + state.osd.res_y = res_y + state.osd.data = text + state.osd.z = z + state.osd:update() +end + +local function set_time_styles(timetotal_changed, timems_changed) + if timetotal_changed then + state.rightTC_trem = not user_opts.timetotal + end + if timems_changed then + state.tc_ms = user_opts.timems + end +end + +-- scale factor for translating between real and virtual ASS coordinates +local function get_virt_scale_factor() + local w, h = mp.get_osd_size() + if w <= 0 or h <= 0 then + return 0, 0 + end + return osc_param.playresx / w, osc_param.playresy / h +end + +-- return mouse position in virtual ASS coordinates (playresx/y) +local function get_virt_mouse_pos() + if state.mouse_in_window then + local sx, sy = get_virt_scale_factor() + local x, y = mp.get_mouse_pos() + return x * sx, y * sy + else + return -1, -1 + end +end + +local function set_virt_mouse_area(x0, y0, x1, y1, name) + local sx, sy = get_virt_scale_factor() + mp.set_mouse_area(x0 / sx, y0 / sy, x1 / sx, y1 / sy, name) +end + +local function scale_value(x0, x1, y0, y1, val) + local m = (y1 - y0) / (x1 - x0) + local b = y0 - (m * x0) + return (m * val) + b +end + +-- returns hitbox spanning coordinates (top left, bottom right corner) +-- according to alignment +local function get_hitbox_coords(x, y, an, w, h) + local alignments = { + [1] = function() + return x, y - h, x + w, y + end, + [2] = function() + return x - (w / 2), y - h, x + (w / 2), y + end, + [3] = function() + return x - w, y - h, x, y + end, + + [4] = function() + return x, y - (h / 2), x + w, y + (h / 2) + end, + [5] = function() + return x - (w / 2), y - (h / 2), x + (w / 2), y + (h / 2) + end, + [6] = function() + return x - w, y - (h / 2), x, y + (h / 2) + end, + + [7] = function() + return x, y, x + w, y + h + end, + [8] = function() + return x - (w / 2), y, x + (w / 2), y + h + end, + [9] = function() + return x - w, y, x, y + h + end, + } + + return alignments[an]() +end + +local function get_hitbox_coords_geo(geometry) + return get_hitbox_coords(geometry.x, geometry.y, geometry.an, geometry.w, geometry.h) +end + +local function get_element_hitbox(element) + return element.hitbox.x1, element.hitbox.y1, element.hitbox.x2, element.hitbox.y2 +end + +local function mouse_hit_coords(bX1, bY1, bX2, bY2) + local mX, mY = get_virt_mouse_pos() + return (mX >= bX1 and mX <= bX2 and mY >= bY1 and mY <= bY2) +end + +local function mouse_hit(element) + return mouse_hit_coords(get_element_hitbox(element)) +end + +local function limit_range(min, max, val) + if val > max then + val = max + elseif val < min then + val = min + end + return val +end + +-- translate value into element coordinates +local function get_slider_ele_pos_for(element, val) + local ele_pos = scale_value( + element.slider.min.value, + element.slider.max.value, + element.slider.min.ele_pos, + element.slider.max.ele_pos, + val + ) + + return limit_range(element.slider.min.ele_pos, element.slider.max.ele_pos, ele_pos) +end + +-- translates global (mouse) coordinates to value +local function get_slider_value_at(element, glob_pos) + local val = scale_value( + element.slider.min.glob_pos, + element.slider.max.glob_pos, + element.slider.min.value, + element.slider.max.value, + glob_pos + ) + + return limit_range(element.slider.min.value, element.slider.max.value, val) +end + +-- get value at current mouse position +local function get_slider_value(element) + return get_slider_value_at(element, get_virt_mouse_pos()) +end + +-- align: -1 .. +1 +-- frame: size of the containing area +-- obj: size of the object that should be positioned inside the area +-- margin: min. distance from object to frame (as long as -1 <= align <= +1) +local function get_align(align, frame, obj, margin) + return (frame / 2) + (((frame / 2) - margin - (obj / 2)) * align) +end + +-- multiplies two alpha values, formular can probably be improved +local function mult_alpha(alphaA, alphaB) + return 255 - (((1 - (alphaA / 255)) * (1 - (alphaB / 255))) * 255) +end + +local function add_area(name, x1, y1, x2, y2) + -- create area if needed + if osc_param.areas[name] == nil then + osc_param.areas[name] = {} + end + table.insert(osc_param.areas[name], { x1 = x1, y1 = y1, x2 = x2, y2 = y2 }) +end + +local function ass_append_alpha(ass, alpha, modifier) + local ar = {} + + for ai, av in pairs(alpha) do + av = mult_alpha(av, modifier) + if state.animation then + av = mult_alpha(av, state.animation) + end + ar[ai] = av + end + + ass:append(string.format("{\\1a&H%X&\\2a&H%X&\\3a&H%X&\\4a&H%X&}", ar[1], ar[2], ar[3], ar[4])) +end + +local function ass_draw_rr_h_cw(ass, x0, y0, x1, y1, r1, hexagon, r2) + if hexagon then + ass:hexagon_cw(x0, y0, x1, y1, r1, r2) + else + ass:round_rect_cw(x0, y0, x1, y1, r1, r2) + end +end + +local function ass_draw_rr_h_ccw(ass, x0, y0, x1, y1, r1, hexagon, r2) + if hexagon then + ass:hexagon_ccw(x0, y0, x1, y1, r1, r2) + else + ass:round_rect_ccw(x0, y0, x1, y1, r1, r2) + end +end + +local function get_hidetimeout() + if user_opts.visibility == "always" then + return -1 -- disable autohide + end + return user_opts.hidetimeout +end + +local function get_touchtimeout() + if state.touchtime == nil then + return 0 + end + return state.touchtime + (get_hidetimeout() / 1000) - mp.get_time() +end + +local function cache_enabled() + return state.cache_state and #state.cache_state["seekable-ranges"] > 0 +end + +local function reset_margins() + if state.using_video_margins then + for _, mopt in ipairs(margins_opts) do + mp.set_property_number(mopt[2], 0.0) + end + state.using_video_margins = false + end +end + +local function update_margins() + local margins = osc_param.video_margins + + -- Don't use margins if it's visible only temporarily. + if + not state.osc_visible + or get_hidetimeout() >= 0 + or (state.fullscreen and not user_opts.showfullscreen) + or (not state.fullscreen and not user_opts.showwindowed) + then + margins = { l = 0, r = 0, t = 0, b = 0 } + end + + if user_opts.boxvideo then + -- check whether any margin option has a non-default value + local margins_used = false + + if not state.using_video_margins then + for _, mopt in ipairs(margins_opts) do + if mp.get_property_number(mopt[2], 0.0) ~= 0.0 then + margins_used = true + end + end + end + + if not margins_used then + for _, mopt in ipairs(margins_opts) do + local v = margins[mopt[1]] + if v ~= 0 or state.using_video_margins then + mp.set_property_number(mopt[2], v) + state.using_video_margins = true + end + end + end + else + reset_margins() + end + + mp.set_property_native("user-data/osc/margins", margins) +end + +local tick +-- Request that tick() is called (which typically re-renders the OSC). +-- The tick is then either executed immediately, or rate-limited if it was +-- called a small time ago. +local function request_tick() + if state.tick_timer == nil then + state.tick_timer = mp.add_timeout(0, tick) + end + + if not state.tick_timer:is_enabled() then + local now = mp.get_time() + local timeout = tick_delay - (now - state.tick_last_time) + if timeout < 0 then + timeout = 0 + end + state.tick_timer.timeout = timeout + state.tick_timer:resume() + end +end + +local function request_init() + state.initREQ = true + request_tick() +end + +-- Like request_init(), but also request an immediate update +local function request_init_resize() + request_init() + -- ensure immediate update + state.tick_timer:kill() + state.tick_timer.timeout = 0 + state.tick_timer:resume() +end + +local function render_wipe() + msg.trace("render_wipe()") + state.osd.data = "" -- allows set_osd to immediately update on enable + state.osd:remove() +end + +-- +-- Tracklist Management +-- + +-- updates the OSC internal playlists, should be run each time the track-layout changes +local function update_tracklist() + audio_track_count, sub_track_count = 0, 0 + + for _, track in pairs(mp.get_property_native("track-list")) do + if track.type == "audio" then + audio_track_count = audio_track_count + 1 + elseif track.type == "sub" then + sub_track_count = sub_track_count + 1 + end + end +end + +-- WindowControl helpers +local function window_controls_enabled() + local val = user_opts.windowcontrols + if val == "auto" then + return not (state.border and state.title_bar) + else + return val ~= "no" + end +end + +local function window_controls_alignment() + return user_opts.windowcontrols_alignment +end + +-- +-- Element Management +-- + +local elements = {} + +local function prepare_elements() + -- remove elements without layout or invisible + local elements2 = {} + for _, element in pairs(elements) do + if element.layout ~= nil and element.visible then + table.insert(elements2, element) + end + end + elements = elements2 + + local function elem_compare(a, b) + return a.layout.layer < b.layout.layer + end + + table.sort(elements, elem_compare) + + for _, element in pairs(elements) do + local elem_geo = element.layout.geometry + + -- Calculate the hitbox + local bX1, bY1, bX2, bY2 = get_hitbox_coords_geo(elem_geo) + element.hitbox = { x1 = bX1, y1 = bY1, x2 = bX2, y2 = bY2 } + + local style_ass = assdraw.ass_new() + + -- prepare static elements + style_ass:append("{}") -- hack to troll new_event into inserting a \n + style_ass:new_event() + style_ass:pos(elem_geo.x, elem_geo.y) + style_ass:an(elem_geo.an) + style_ass:append(element.layout.style) + + element.style_ass = style_ass + + local static_ass = assdraw.ass_new() + + if element.type == "box" then + --draw box + static_ass:draw_start() + ass_draw_rr_h_cw( + static_ass, + 0, + 0, + elem_geo.w, + elem_geo.h, + element.layout.box.radius, + element.layout.box.hexagon + ) + static_ass:draw_stop() + elseif element.type == "slider" then + --draw static slider parts + + local r1 = 0 + local r2 = 0 + local slider_lo = element.layout.slider + -- offset between element outline and drag-area + local foV = slider_lo.border + slider_lo.gap + + -- calculate positions of min and max points + if slider_lo.stype ~= "bar" then + r1 = elem_geo.h / 2 + element.slider.min.ele_pos = elem_geo.h / 2 + element.slider.max.ele_pos = elem_geo.w - (elem_geo.h / 2) + if slider_lo.stype == "diamond" then + r2 = (elem_geo.h - 2 * slider_lo.border) / 2 + elseif slider_lo.stype == "knob" then + r2 = r1 + end + else + element.slider.min.ele_pos = slider_lo.border + slider_lo.gap + element.slider.max.ele_pos = elem_geo.w - (slider_lo.border + slider_lo.gap) + end + + element.slider.min.glob_pos = element.hitbox.x1 + element.slider.min.ele_pos + element.slider.max.glob_pos = element.hitbox.x1 + element.slider.max.ele_pos + + -- -- -- + + static_ass:draw_start() + + -- the box + ass_draw_rr_h_cw(static_ass, 0, 0, elem_geo.w, elem_geo.h, r1, slider_lo.stype == "diamond") + + -- the "hole" + ass_draw_rr_h_ccw( + static_ass, + slider_lo.border, + slider_lo.border, + elem_geo.w - slider_lo.border, + elem_geo.h - slider_lo.border, + r2, + slider_lo.stype == "diamond" + ) + + -- marker nibbles + if element.slider.markerF ~= nil and slider_lo.gap > 0 then + local markers = element.slider.markerF() + for _, marker in pairs(markers) do + if marker > element.slider.min.value and marker < element.slider.max.value then + local s = get_slider_ele_pos_for(element, marker) + + if slider_lo.gap > 1 then -- draw triangles + local a = slider_lo.gap / 0.5 --0.866 + + --top + if slider_lo.nibbles_top then + static_ass:move_to(s - (a / 2), slider_lo.border) + static_ass:line_to(s + (a / 2), slider_lo.border) + static_ass:line_to(s, foV) + end + + --bottom + if slider_lo.nibbles_bottom then + static_ass:move_to(s - (a / 2), elem_geo.h - slider_lo.border) + static_ass:line_to(s, elem_geo.h - foV) + static_ass:line_to(s + (a / 2), elem_geo.h - slider_lo.border) + end + else -- draw 2x1px nibbles + --top + if slider_lo.nibbles_top then + static_ass:rect_cw(s - 1, slider_lo.border, s + 1, slider_lo.border + slider_lo.gap) + end + + --bottom + if slider_lo.nibbles_bottom then + static_ass:rect_cw( + s - 1, + elem_geo.h - slider_lo.border - slider_lo.gap, + s + 1, + elem_geo.h - slider_lo.border + ) + end + end + end + end + end + end + + element.static_ass = static_ass + + -- if the element is supposed to be disabled, + -- style it accordingly and kill the eventresponders + if not element.enabled then + element.layout.alpha[1] = 136 + element.eventresponder = nil + end + end +end + +-- +-- Element Rendering +-- + +-- returns nil or a chapter element from the native property chapter-list +local function get_chapter(possec) + local cl = state.chapter_list -- sorted, get latest before possec, if any + + for n = #cl, 1, -1 do + if possec >= cl[n].time then + return cl[n] + end + end +end + +local function render_elements(master_ass) + -- when the slider is dragged or hovered and we have a target chapter name + -- then we use it instead of the normal title. we calculate it before the + -- render iterations because the title may be rendered before the slider. + state.forced_title = nil + local se, ae = state.slider_element, elements[state.active_element] + if user_opts.chapter_fmt ~= "no" and se and (ae == se or (not ae and mouse_hit(se))) then + local dur = mp.get_property_number("duration", 0) + if dur > 0 then + local possec = get_slider_value(se) * dur / 100 -- of mouse pos + local ch = get_chapter(possec) + if ch and ch.title and ch.title ~= "" then + state.forced_title = string.format(user_opts.chapter_fmt, ch.title) + end + end + end + + for n = 1, #elements do + local element = elements[n] + + local style_ass = assdraw.ass_new() + style_ass:merge(element.style_ass) + ass_append_alpha(style_ass, element.layout.alpha, 0) + + if element.eventresponder and (state.active_element == n) then + -- run render event functions + if element.eventresponder.render ~= nil then + element.eventresponder.render(element) + end + + if mouse_hit(element) then + -- mouse down styling + if element.styledown then + style_ass:append(osc_styles.elementDown) + end + + if element.softrepeat and state.mouse_down_counter >= 15 and state.mouse_down_counter % 5 == 0 then + element.eventresponder[state.active_event_source .. "_down"](element) + end + state.mouse_down_counter = state.mouse_down_counter + 1 + end + end + + local elem_ass = assdraw.ass_new() + + elem_ass:merge(style_ass) + + if element.type ~= "button" then + elem_ass:merge(element.static_ass) + end + + if element.type == "slider" then + local slider_lo = element.layout.slider + local elem_geo = element.layout.geometry + local s_min = element.slider.min.value + local s_max = element.slider.max.value + + -- draw pos marker + local foH, xp + local pos = element.slider.posF() + local foV = slider_lo.border + slider_lo.gap + local innerH = elem_geo.h - (2 * foV) + local seekRanges = element.slider.seekRangesF() + local seekRangeLineHeight = innerH / 5 + + if slider_lo.stype ~= "bar" then + foH = elem_geo.h / 2 + else + foH = slider_lo.border + slider_lo.gap + end + + if pos then + xp = get_slider_ele_pos_for(element, pos) + + if slider_lo.stype ~= "bar" then + local r = (user_opts.seekbarhandlesize * innerH) / 2 + ass_draw_rr_h_cw(elem_ass, xp - r, foH - r, xp + r, foH + r, r, slider_lo.stype == "diamond") + else + local h = 0 + if seekRanges and user_opts.seekrangeseparate and slider_lo.rtype ~= "inverted" then + h = seekRangeLineHeight + end + elem_ass:rect_cw(foH, foV, xp, elem_geo.h - foV - h) + + if seekRanges and not user_opts.seekrangeseparate and slider_lo.rtype ~= "inverted" then + -- Punch holes for the seekRanges to be drawn later + for _, range in pairs(seekRanges) do + if range["start"] < pos then + local pstart = get_slider_ele_pos_for(element, range["start"]) + local pend = xp + + if pos > range["end"] then + pend = get_slider_ele_pos_for(element, range["end"]) + end + elem_ass:rect_ccw( + pstart, + elem_geo.h - foV - seekRangeLineHeight, + pend, + elem_geo.h - foV + ) + end + end + end + end + + if slider_lo.rtype == "slider" then + ass_draw_rr_h_cw( + elem_ass, + foH - innerH / 6, + foH - innerH / 6, + xp, + foH + innerH / 6, + innerH / 6, + slider_lo.stype == "diamond", + 0 + ) + ass_draw_rr_h_cw( + elem_ass, + xp, + foH - innerH / 15, + elem_geo.w - foH + innerH / 15, + foH + innerH / 15, + 0, + slider_lo.stype == "diamond", + innerH / 15 + ) + for _, range in pairs(seekRanges or {}) do + local pstart = get_slider_ele_pos_for(element, range["start"]) + local pend = get_slider_ele_pos_for(element, range["end"]) + ass_draw_rr_h_ccw( + elem_ass, + pstart, + foH - innerH / 21, + pend, + foH + innerH / 21, + innerH / 21, + slider_lo.stype == "diamond" + ) + end + end + end + + if seekRanges then + if slider_lo.rtype ~= "inverted" then + elem_ass:draw_stop() + elem_ass:merge(element.style_ass) + ass_append_alpha(elem_ass, element.layout.alpha, user_opts.seekrangealpha) + elem_ass:merge(element.static_ass) + end + + for _, range in pairs(seekRanges) do + local pstart = get_slider_ele_pos_for(element, range["start"]) + local pend = get_slider_ele_pos_for(element, range["end"]) + + if slider_lo.rtype == "slider" then + ass_draw_rr_h_cw( + elem_ass, + pstart, + foH - innerH / 21, + pend, + foH + innerH / 21, + innerH / 21, + slider_lo.stype == "diamond" + ) + elseif slider_lo.rtype == "line" then + if slider_lo.stype == "bar" then + elem_ass:rect_cw(pstart, elem_geo.h - foV - seekRangeLineHeight, pend, elem_geo.h - foV) + else + ass_draw_rr_h_cw( + elem_ass, + pstart - innerH / 8, + foH - innerH / 8, + pend + innerH / 8, + foH + innerH / 8, + innerH / 8, + slider_lo.stype == "diamond" + ) + end + elseif slider_lo.rtype == "bar" then + if slider_lo.stype ~= "bar" then + ass_draw_rr_h_cw( + elem_ass, + pstart - innerH / 2, + foV, + pend + innerH / 2, + foV + innerH, + innerH / 2, + slider_lo.stype == "diamond" + ) + elseif range["end"] >= (pos or 0) then + elem_ass:rect_cw(pstart, foV, pend, elem_geo.h - foV) + else + elem_ass:rect_cw(pstart, elem_geo.h - foV - seekRangeLineHeight, pend, elem_geo.h - foV) + end + elseif slider_lo.rtype == "inverted" then + if slider_lo.stype ~= "bar" then + ass_draw_rr_h_ccw( + elem_ass, + pstart, + (elem_geo.h / 2) - 1, + pend, + (elem_geo.h / 2) + 1, + 1, + slider_lo.stype == "diamond" + ) + else + elem_ass:rect_ccw(pstart, (elem_geo.h / 2) - 1, pend, (elem_geo.h / 2) + 1) + end + end + end + end + + elem_ass:draw_stop() + + -- add tooltip + if element.slider.tooltipF ~= nil then + if mouse_hit(element) then + local sliderpos = get_slider_value(element) + local tooltiplabel = element.slider.tooltipF(sliderpos) + + local an = slider_lo.tooltip_an + + local ty + + if an == 2 then + ty = element.hitbox.y1 - slider_lo.border + else + ty = element.hitbox.y1 + elem_geo.h / 2 + end + + local tx = get_virt_mouse_pos() + if slider_lo.adjust_tooltip then + if an == 2 then + if sliderpos < (s_min + 3) then + an = an - 1 + elseif sliderpos > (s_max - 3) then + an = an + 1 + end + elseif sliderpos > (s_max + s_min) / 2 then + an = an + 1 + tx = tx - 5 + else + an = an - 1 + tx = tx + 10 + end + end + + -- tooltip label + elem_ass:new_event() + elem_ass:pos(tx, ty) + elem_ass:an(an) + elem_ass:append(slider_lo.tooltip_style) + ass_append_alpha(elem_ass, slider_lo.alpha, 0) + elem_ass:append(tooltiplabel) + end + end + elseif element.type == "button" then + local buttontext + if type(element.content) == "function" then + buttontext = element.content() -- function objects + elseif element.content ~= nil then + buttontext = element.content -- text objects + end + + local maxchars = element.layout.button.maxchars + if maxchars ~= nil and #buttontext > maxchars then + local max_ratio = 1.25 -- up to 25% more chars while shrinking + local limit = math.max(0, math.floor(maxchars * max_ratio) - 3) + if #buttontext > limit then + while #buttontext > limit do + buttontext = buttontext:gsub(".[\128-\191]*$", "") + end + buttontext = buttontext .. "..." + end + buttontext = string.format("{\\fscx%f}", (maxchars / #buttontext) * 100) .. buttontext + end + + elem_ass:append(buttontext) + end + + master_ass:merge(elem_ass) + end +end + +-- +-- Initialisation and Layout +-- + +local function new_element(name, type) + elements[name] = {} + elements[name].type = type + + -- add default stuff + elements[name].eventresponder = {} + elements[name].visible = true + elements[name].enabled = true + elements[name].softrepeat = false + elements[name].styledown = (type == "button") + elements[name].state = {} + + if type == "slider" then + elements[name].slider = { min = { value = 0 }, max = { value = 100 } } + end + + return elements[name] +end + +local function add_layout(name) + if elements[name] ~= nil then + -- new layout + elements[name].layout = {} + + -- set layout defaults + elements[name].layout.layer = 50 + elements[name].layout.alpha = { [1] = 0, [2] = 255, [3] = 255, [4] = 255 } + + if elements[name].type == "button" then + elements[name].layout.button = { + maxchars = nil, + } + elseif elements[name].type == "slider" then + -- slider defaults + elements[name].layout.slider = { + border = 1, + gap = 1, + nibbles_top = true, + nibbles_bottom = true, + stype = "slider", + adjust_tooltip = true, + tooltip_style = "", + tooltip_an = 2, + alpha = { [1] = 0, [2] = 255, [3] = 88, [4] = 255 }, + } + elseif elements[name].type == "box" then + elements[name].layout.box = { radius = 0, hexagon = false } + end + + return elements[name].layout + else + msg.error("Can't add_layout to element '" .. name .. "', doesn't exist.") + end +end + +-- Window Controls +local function window_controls(topbar) + local wc_geo = { + x = 0, + y = 30 + user_opts.barmargin, + an = 1, + w = osc_param.playresx, + h = 30, + } + + local alignment = window_controls_alignment() + local controlbox_w = window_control_box_width + local titlebox_w = wc_geo.w - controlbox_w + + -- Default alignment is "right" + local controlbox_left = wc_geo.w - controlbox_w + local titlebox_left = wc_geo.x + local titlebox_right = wc_geo.w - controlbox_w + + if alignment == "left" then + controlbox_left = wc_geo.x + titlebox_left = wc_geo.x + controlbox_w + titlebox_right = wc_geo.w + end + + add_area("window-controls", get_hitbox_coords(controlbox_left, wc_geo.y, wc_geo.an, controlbox_w, wc_geo.h)) + + local lo + + -- Background Bar + new_element("wcbar", "box") + lo = add_layout("wcbar") + lo.geometry = wc_geo + lo.layer = 10 + lo.style = osc_styles.wcBar + lo.alpha[1] = user_opts.boxalpha + + local button_y = wc_geo.y - (wc_geo.h / 2) + local first_geo = { x = controlbox_left + 5, y = button_y, an = 4, w = 25, h = 25 } + local second_geo = { x = controlbox_left + 30, y = button_y, an = 4, w = 25, h = 25 } + local third_geo = { x = controlbox_left + 55, y = button_y, an = 4, w = 25, h = 25 } + + -- Window control buttons use symbols in the custom mpv osd font + -- because the official unicode codepoints are sufficiently + -- exotic that a system might lack an installed font with them, + -- and libass will complain that they are not present in the + -- default font, even if another font with them is available. + + -- Close: 🗙 + local ne = new_element("close", "button") + ne.content = icons.close + ne.eventresponder["mbtn_left_up"] = function() + mp.commandv("quit") + end + lo = add_layout("close") + lo.geometry = alignment == "left" and first_geo or third_geo + lo.style = osc_styles.wcButtons + + -- Minimize: 🗕 + ne = new_element("minimize", "button") + ne.content = icons.minimize + ne.eventresponder["mbtn_left_up"] = function() + mp.commandv("cycle", "window-minimized") + end + lo = add_layout("minimize") + lo.geometry = alignment == "left" and second_geo or first_geo + lo.style = osc_styles.wcButtons + + -- Maximize: 🗖 /🗗 + ne = new_element("maximize", "button") + if state.maximized or state.fullscreen then + ne.content = icons.unmaximize + else + ne.content = icons.maximize + end + ne.eventresponder["mbtn_left_up"] = function() + if state.fullscreen then + mp.commandv("cycle", "fullscreen") + else + mp.commandv("cycle", "window-maximized") + end + end + lo = add_layout("maximize") + lo.geometry = alignment == "left" and third_geo or second_geo + lo.style = osc_styles.wcButtons + + -- deadzone below window controls + local sh_area_y0, sh_area_y1 + sh_area_y0 = user_opts.barmargin + sh_area_y1 = wc_geo.y + get_align(1 - (2 * user_opts.deadzonesize), osc_param.playresy - wc_geo.y, 0, 0) + add_area("showhide_wc", wc_geo.x, sh_area_y0, wc_geo.w, sh_area_y1) + + if topbar then + -- The title is already there as part of the top bar + return + else + -- Apply boxvideo margins to the control bar + osc_param.video_margins.t = wc_geo.h / osc_param.playresy + end + + -- Window Title + ne = new_element("wctitle", "button") + ne.content = function() + local title = mp.command_native({ "expand-text", user_opts.windowcontrols_title }) + title = title:gsub("\n", " ") + return title ~= "" and mp.command_native({ "escape-ass", title }) or "mpv" + end + local left_pad = 5 + local right_pad = 10 + lo = add_layout("wctitle") + lo.geometry = { x = titlebox_left + left_pad, y = wc_geo.y - 3, an = 1, w = titlebox_w, h = wc_geo.h } + lo.style = string.format( + "%s{\\clip(%f,%f,%f,%f)}", + osc_styles.wcTitle, + titlebox_left + left_pad, + wc_geo.y - wc_geo.h, + titlebox_right - right_pad, + wc_geo.y + wc_geo.h + ) + + add_area("window-controls-title", titlebox_left, 0, titlebox_right, wc_geo.h) +end + +-- +-- Layouts +-- + +-- Classic box layout +layouts["box"] = function() + local osc_geo = { + w = 550, -- width + h = 138, -- height + r = 10, -- corner-radius + p = 15, -- padding + } + + -- make sure the OSC actually fits into the video + if osc_param.playresx < (osc_geo.w + (2 * osc_geo.p)) then + osc_param.playresy = (osc_geo.w + (2 * osc_geo.p)) / osc_param.display_aspect + osc_param.playresx = osc_param.playresy * osc_param.display_aspect + end + + -- position of the controller according to video aspect and valignment + local posX = math.floor(get_align(user_opts.halign, osc_param.playresx, osc_geo.w, 0)) + local posY = math.floor(get_align(user_opts.valign, osc_param.playresy, osc_geo.h, 0)) + + -- position offset for contents aligned at the borders of the box + local pos_offsetX = (osc_geo.w - (2 * osc_geo.p)) / 2 + local pos_offsetY = (osc_geo.h - (2 * osc_geo.p)) / 2 + + osc_param.areas = {} -- delete areas + + -- area for active mouse input + add_area("input", get_hitbox_coords(posX, posY, 5, osc_geo.w, osc_geo.h)) + + -- area for show/hide + local sh_area_y0, sh_area_y1 + if user_opts.valign > 0 then + -- deadzone above OSC + sh_area_y0 = get_align(-1 + (2 * user_opts.deadzonesize), posY - (osc_geo.h / 2), 0, 0) + sh_area_y1 = osc_param.playresy + else + -- deadzone below OSC + sh_area_y0 = 0 + sh_area_y1 = (posY + (osc_geo.h / 2)) + + get_align(1 - (2 * user_opts.deadzonesize), osc_param.playresy - (posY + (osc_geo.h / 2)), 0, 0) + end + add_area("showhide", 0, sh_area_y0, osc_param.playresx, sh_area_y1) + + -- fetch values + local osc_w, osc_h, osc_r = osc_geo.w, osc_geo.h, osc_geo.r + + local lo + + -- + -- Background box + -- + + new_element("bgbox", "box") + lo = add_layout("bgbox") + + lo.geometry = { x = posX, y = posY, an = 5, w = osc_w, h = osc_h } + lo.layer = 10 + lo.style = osc_styles.box + lo.alpha[1] = user_opts.boxalpha + lo.alpha[3] = user_opts.boxalpha + lo.box.radius = osc_r + + -- + -- Title row + -- + + local titlerowY = posY - pos_offsetY - 10 + + lo = add_layout("title") + lo.geometry = { x = posX, y = titlerowY, an = 8, w = 496, h = 12 } + lo.style = osc_styles.vidtitle + lo.button.maxchars = user_opts.boxmaxchars + + lo = add_layout("playlist_prev") + lo.geometry = { x = (posX - pos_offsetX), y = titlerowY, an = 7, w = 12, h = 12 } + lo.style = osc_styles.topButtons + + lo = add_layout("playlist_next") + lo.geometry = { x = (posX + pos_offsetX), y = titlerowY, an = 9, w = 12, h = 12 } + lo.style = osc_styles.topButtons + + -- + -- Big buttons + -- + + local bigbtnrowY = posY - pos_offsetY + 35 + local bigbtndist = 60 + + lo = add_layout("play_pause") + lo.geometry = { x = posX, y = bigbtnrowY, an = 5, w = 40, h = 40 } + lo.style = osc_styles.bigButtons + + lo = add_layout("skip_backward") + lo.geometry = { x = posX - bigbtndist, y = bigbtnrowY, an = 5, w = 40, h = 40 } + lo.style = osc_styles.bigButtons + + lo = add_layout("skip_forward") + lo.geometry = { x = posX + bigbtndist, y = bigbtnrowY, an = 5, w = 40, h = 40 } + lo.style = osc_styles.bigButtons + + lo = add_layout("chapter_prev") + lo.geometry = { x = posX - (bigbtndist * 2), y = bigbtnrowY, an = 5, w = 40, h = 40 } + lo.style = osc_styles.bigButtons + + lo = add_layout("chapter_next") + lo.geometry = { x = posX + (bigbtndist * 2), y = bigbtnrowY, an = 5, w = 40, h = 40 } + lo.style = osc_styles.bigButtons + + lo = add_layout("audio_track") + lo.geometry = { x = posX - pos_offsetX, y = bigbtnrowY, an = 1, w = 70, h = 18 } + lo.style = osc_styles.smallButtonsL + + lo = add_layout("sub_track") + lo.geometry = { x = posX - pos_offsetX, y = bigbtnrowY, an = 7, w = 70, h = 18 } + lo.style = osc_styles.smallButtonsL + + lo = add_layout("fullscreen") + lo.geometry = { x = posX + pos_offsetX - 25, y = bigbtnrowY, an = 4, w = 25, h = 25 } + lo.style = osc_styles.smallButtonsR + + lo = add_layout("volume") + lo.geometry = { x = posX + pos_offsetX - (25 * 2) - osc_geo.p, y = bigbtnrowY, an = 4, w = 25, h = 25 } + lo.style = osc_styles.smallButtonsR + + -- + -- Seekbar + -- + + lo = add_layout("seekbar") + lo.geometry = { x = posX, y = posY + pos_offsetY - 22, an = 2, w = pos_offsetX * 2, h = 15 } + lo.style = osc_styles.timecodes + lo.slider.tooltip_style = osc_styles.vidtitle + lo.slider.stype = user_opts["seekbarstyle"] + lo.slider.rtype = user_opts["seekrangestyle"] + + -- + -- Timecodes + Cache + -- + + local bottomrowY = posY + pos_offsetY - 5 + + lo = add_layout("tc_left") + lo.geometry = { x = posX - pos_offsetX, y = bottomrowY, an = 4, w = 110, h = 18 } + lo.style = osc_styles.timecodes + + lo = add_layout("tc_right") + lo.geometry = { x = posX + pos_offsetX, y = bottomrowY, an = 6, w = 110, h = 18 } + lo.style = osc_styles.timecodes + + lo = add_layout("cache") + lo.geometry = { x = posX, y = bottomrowY, an = 5, w = 110, h = 18 } + lo.style = osc_styles.timecodes +end + +-- slim box layout +layouts["slimbox"] = function() + local osc_geo = { + w = 660, -- width + h = 70, -- height + r = 10, -- corner-radius + } + + -- make sure the OSC actually fits into the video + if osc_param.playresx < osc_geo.w then + osc_param.playresy = osc_geo.w / osc_param.display_aspect + osc_param.playresx = osc_param.playresy * osc_param.display_aspect + end + + -- position of the controller according to video aspect and valignment + local posX = math.floor(get_align(user_opts.halign, osc_param.playresx, osc_geo.w, 0)) + local posY = math.floor(get_align(user_opts.valign, osc_param.playresy, osc_geo.h, 0)) + + osc_param.areas = {} -- delete areas + + -- area for active mouse input + add_area("input", get_hitbox_coords(posX, posY, 5, osc_geo.w, osc_geo.h)) + + -- area for show/hide + local sh_area_y0, sh_area_y1 + if user_opts.valign > 0 then + -- deadzone above OSC + sh_area_y0 = get_align(-1 + (2 * user_opts.deadzonesize), posY - (osc_geo.h / 2), 0, 0) + sh_area_y1 = osc_param.playresy + else + -- deadzone below OSC + sh_area_y0 = 0 + sh_area_y1 = (posY + (osc_geo.h / 2)) + + get_align(1 - (2 * user_opts.deadzonesize), osc_param.playresy - (posY + (osc_geo.h / 2)), 0, 0) + end + add_area("showhide", 0, sh_area_y0, osc_param.playresx, sh_area_y1) + + local lo + + local tc_w, ele_h, inner_w = 100, 20, osc_geo.w - 100 + + -- styles + local styles = { + box = "{\\rDefault\\blur0\\bord1\\1c&H" .. osc_color_convert(user_opts.background_color) .. "\\3c&HFFFFFF}", + timecodes = "{\\1c&H" .. osc_color_convert(user_opts.timecode_color) .. "\\3c&H" .. osc_color_convert( + user_opts.time_pos_outline_color + ) .. "\\fs20\\bord2\\blur1}", + tooltip = "{\\1c&H" .. osc_color_convert(user_opts.time_pos_color) .. "\\3c&H" .. osc_color_convert( + user_opts.time_pos_outline_color + ) .. "\\fs12\\bord1\\blur0.5}", + } + + new_element("bgbox", "box") + lo = add_layout("bgbox") + + lo.geometry = { x = posX, y = posY - 1, an = 2, w = inner_w, h = ele_h } + lo.layer = 10 + lo.style = osc_styles.box + lo.alpha[1] = user_opts.boxalpha + lo.alpha[3] = 0 + if user_opts["seekbarstyle"] ~= "bar" then + lo.box.radius = osc_geo.r + lo.box.hexagon = user_opts["seekbarstyle"] == "diamond" + end + + lo = add_layout("seekbar") + lo.geometry = { x = posX, y = posY - 1, an = 2, w = inner_w, h = ele_h } + lo.style = osc_styles.timecodes + lo.slider.border = 0 + lo.slider.gap = 1.5 + lo.slider.tooltip_style = styles.tooltip + lo.slider.stype = user_opts["seekbarstyle"] + lo.slider.rtype = user_opts["seekrangestyle"] + lo.slider.adjust_tooltip = false + + -- + -- Timecodes + -- + + lo = add_layout("tc_left") + lo.geometry = { x = posX - (inner_w / 2) + osc_geo.r, y = posY + 1, an = 7, w = tc_w, h = ele_h } + lo.style = styles.timecodes + lo.alpha[3] = user_opts.boxalpha + + lo = add_layout("tc_right") + lo.geometry = { x = posX + (inner_w / 2) - osc_geo.r, y = posY + 1, an = 9, w = tc_w, h = ele_h } + lo.style = styles.timecodes + lo.alpha[3] = user_opts.boxalpha + + -- Cache + + lo = add_layout("cache") + lo.geometry = { x = posX, y = posY + 1, an = 8, w = tc_w, h = ele_h } + lo.style = styles.timecodes + lo.alpha[3] = user_opts.boxalpha +end + +local function bar_layout(direction, slim) + local osc_geo = { + x = -2, + y = nil, + an = (direction < 0) and 7 or 1, + w = nil, + h = slim and 25 or 56, + } + + local padX = 9 + local padY = 3 + local buttonW = 27 + local tcW = state.tc_ms and 170 or 110 + if user_opts.tcspace >= 50 and user_opts.tcspace <= 200 then + -- adjust our hardcoded font size estimation + tcW = tcW * user_opts.tcspace / 100 + end + + local tsW = 90 + local minW = (buttonW + padX) * 5 + (tcW + padX) * 4 + (tsW + padX) * 2 + + -- Special topbar handling when window controls are present + local padwc_l + local padwc_r + if direction < 0 or not window_controls_enabled() then + padwc_l = 0 + padwc_r = 0 + elseif window_controls_alignment() == "left" then + padwc_l = window_control_box_width + padwc_r = 0 + else + padwc_l = 0 + padwc_r = window_control_box_width + end + + if osc_param.display_aspect > 0 and osc_param.playresx < minW then + osc_param.playresy = minW / osc_param.display_aspect + osc_param.playresx = osc_param.playresy * osc_param.display_aspect + end + + osc_geo.y = direction * (osc_geo.h - 2 + user_opts.barmargin) + osc_geo.w = osc_param.playresx + 4 + if direction < 0 then + osc_geo.y = osc_geo.y + osc_param.playresy + end + + if direction < 0 then + osc_param.video_margins.b = osc_geo.h / osc_param.playresy + else + osc_param.video_margins.t = osc_geo.h / osc_param.playresy + end + + local line1 = osc_geo.y - direction * (9 + padY) + local line2 = osc_geo.y - direction * (36 + padY) + + osc_param.areas = {} + + add_area("input", get_hitbox_coords(osc_geo.x, osc_geo.y, osc_geo.an, osc_geo.w, osc_geo.h)) + + local sh_area_y0, sh_area_y1 + if direction > 0 then + -- deadzone below OSC + sh_area_y0 = user_opts.barmargin + sh_area_y1 = osc_geo.y + get_align(1 - (2 * user_opts.deadzonesize), osc_param.playresy - osc_geo.y, 0, 0) + else + -- deadzone above OSC + sh_area_y0 = get_align(-1 + (2 * user_opts.deadzonesize), osc_geo.y, 0, 0) + sh_area_y1 = osc_param.playresy - user_opts.barmargin + end + add_area("showhide", 0, sh_area_y0, osc_param.playresx, sh_area_y1) + + local lo, geo + + -- Background bar + new_element("bgbox", "box") + lo = add_layout("bgbox") + + lo.geometry = osc_geo + lo.layer = 10 + lo.style = osc_styles.box + lo.alpha[1] = user_opts.boxalpha + + -- Menu + geo = { x = osc_geo.x + padX + 4, y = line1, an = 4, w = 18, h = 18 - padY } + lo = add_layout("menu") + lo.geometry = geo + lo.style = osc_styles.topButtonsBar + + -- Playlist prev/next + geo = { x = geo.x + geo.w + padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } + lo = add_layout("playlist_prev") + lo.geometry = geo + lo.style = osc_styles.topButtonsBar + + geo = { x = geo.x + geo.w + padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } + lo = add_layout("playlist_next") + lo.geometry = geo + lo.style = osc_styles.topButtonsBar + + local t_l = geo.x + geo.w + padX + + -- Custom buttons + local t_r = osc_geo.x + osc_geo.w + + for i = last_custom_button, 1, -1 do + t_r = t_r - padX + geo = { x = t_r, y = geo.y, an = 6, w = geo.w, h = geo.h } + t_r = t_r - geo.w + lo = add_layout("custom_button_" .. i) + lo.geometry = geo + lo.style = osc_styles.vidtitleBar + end + + t_r = t_r - padX + + if slim then + -- Fullscreen button + geo = { x = t_r, y = geo.y, an = 6, w = buttonW, h = geo.h } + lo = add_layout("fullscreen") + lo.geometry = geo + lo.style = osc_styles.topButtonsBar + else + -- Cache + geo = { x = t_r, y = geo.y, an = 6, w = 150, h = geo.h } + lo = add_layout("cache") + lo.geometry = geo + lo.style = osc_styles.vidtitleBar + end + + t_r = t_r - geo.w - padX + + -- Title + geo = { x = t_l, y = geo.y, an = 4, w = t_r - t_l, h = geo.h } + lo = add_layout("title") + lo.geometry = geo + lo.style = + string.format("%s{\\clip(%f,%f,%f,%f)}", osc_styles.vidtitleBar, geo.x, geo.y - geo.h, geo.w, geo.y + geo.h) + + if slim then + return + end + + -- Playback control buttons + geo = { x = osc_geo.x + padX + padwc_l, y = line2, an = 4, w = buttonW, h = 36 - padY * 2 } + lo = add_layout("play_pause") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + geo = { x = geo.x + geo.w + padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } + lo = add_layout("chapter_prev") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + geo = { x = geo.x + geo.w + padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } + lo = add_layout("chapter_next") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + -- Left timecode + geo = { x = geo.x + geo.w + padX + tcW, y = geo.y, an = 6, w = tcW, h = geo.h } + lo = add_layout("tc_left") + lo.geometry = geo + lo.style = osc_styles.timecodesBar + + local sb_l = geo.x + padX + + -- Fullscreen button + geo = { x = osc_geo.x + osc_geo.w - buttonW - padX - padwc_r, y = geo.y, an = 4, w = buttonW, h = geo.h } + lo = add_layout("fullscreen") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + -- START quality-menu + geo = { x = geo.x - geo.w - padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } + lo = add_layout("quality-menu") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + -- END quality-menu + + -- Volume + geo = { x = geo.x - geo.w - padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } + lo = add_layout("volume") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + -- Track selection buttons + geo = { x = geo.x - tsW - padX, y = geo.y, an = geo.an, w = tsW, h = geo.h } + lo = add_layout("sub_track") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + geo = { x = geo.x - geo.w - padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } + lo = add_layout("audio_track") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + -- Right timecode + geo = { x = geo.x - padX - tcW - 10, y = geo.y, an = geo.an, w = tcW, h = geo.h } + lo = add_layout("tc_right") + lo.geometry = geo + lo.style = osc_styles.timecodesBar + + local sb_r = geo.x - padX + + -- Seekbar + geo = { x = sb_l, y = geo.y, an = geo.an, w = math.max(0, sb_r - sb_l), h = geo.h } + new_element("bgbar1", "box") + lo = add_layout("bgbar1") + + lo.geometry = geo + lo.layer = 15 + lo.style = osc_styles.timecodesBar + lo.alpha[1] = math.min(255, user_opts.boxalpha + (255 - user_opts.boxalpha) * 0.8) + if user_opts["seekbarstyle"] ~= "bar" then + lo.box.radius = geo.h / 2 + lo.box.hexagon = user_opts["seekbarstyle"] == "diamond" + end + + lo = add_layout("seekbar") + lo.geometry = geo + lo.style = osc_styles.timecodesBar + lo.slider.border = 0 + lo.slider.gap = 2 + lo.slider.tooltip_style = osc_styles.timePosBar + lo.slider.tooltip_an = 5 + lo.slider.stype = user_opts["seekbarstyle"] + lo.slider.rtype = user_opts["seekrangestyle"] +end + +layouts["bottombar"] = function() + bar_layout(-1) +end + +layouts["topbar"] = function() + bar_layout(1) +end + +layouts["slimbottombar"] = function() + bar_layout(-1, true) +end + +layouts["slimtopbar"] = function() + bar_layout(1, true) +end + +local function bind_mouse_buttons(element_name) + for _, button in pairs({ "mbtn_left", "mbtn_mid", "mbtn_right" }) do + local command = user_opts[element_name .. "_" .. button .. "_command"] + + if command ~= "" then + elements[element_name].eventresponder[button .. "_up"] = function() + mp.command(command) + end + end + end + + if user_opts.scrollcontrols then + for _, button in pairs({ "wheel_down", "wheel_up" }) do + local command = user_opts[element_name .. "_" .. button .. "_command"] + + if command and command ~= "" then + elements[element_name].eventresponder[button .. "_press"] = function() + mp.command(command) + end + end + end + end +end + +local function osc_init() + msg.debug("osc_init") + + -- set canvas resolution according to display aspect and scaling setting + local baseResY = 720 + local _, display_h, display_aspect = mp.get_osd_size() + local scale + + if state.fullscreen then + scale = user_opts.scalefullscreen + else + scale = user_opts.scalewindowed + end + + local scale_with_video + if user_opts.vidscale == "auto" then + scale_with_video = mp.get_property_native("osd-scale-by-window") + else + scale_with_video = user_opts.vidscale == "yes" + end + + if scale_with_video then + osc_param.unscaled_y = baseResY + else + osc_param.unscaled_y = display_h + end + osc_param.playresy = osc_param.unscaled_y / scale + if display_aspect > 0 then + osc_param.display_aspect = display_aspect + end + osc_param.playresx = osc_param.playresy * osc_param.display_aspect + + -- stop seeking with the slider to prevent skipping files + state.active_element = nil + + osc_param.video_margins = { l = 0, r = 0, t = 0, b = 0 } + + elements = {} + + -- some often needed stuff + local pl_count = mp.get_property_number("playlist-count", 0) + local have_pl = (pl_count > 1) + local pl_pos = mp.get_property_number("playlist-pos", 0) + 1 + local have_ch = (mp.get_property_number("chapters", 0) > 0) + local loop = mp.get_property("loop-playlist", "no") + + local ne + + -- title + ne = new_element("title", "button") + + ne.content = function() + local title = state.forced_title or mp.command_native({ "expand-text", user_opts.title }) + title = title:gsub("\n", " ") + return title ~= "" and mp.command_native({ "escape-ass", title }) or "mpv" + end + bind_mouse_buttons("title") + + -- menu + ne = new_element("menu", "button") + ne.content = icons.menu + bind_mouse_buttons("menu") + + -- playlist buttons + + -- prev + ne = new_element("playlist_prev", "button") + + ne.content = icons.prev + ne.enabled = (pl_pos > 1) or (loop ~= "no") + bind_mouse_buttons("playlist_prev") + + --next + ne = new_element("playlist_next", "button") + + ne.content = icons.next + ne.enabled = (have_pl and (pl_pos < pl_count)) or (loop ~= "no") + bind_mouse_buttons("playlist_next") + + -- big buttons + + --play_pause + ne = new_element("play_pause", "button") + + ne.content = function() + if not mp.get_property_native("pause") then + return icons.pause + end + + return mp.get_property("play-direction") == "forward" and icons.play or icons.play_backward + end + bind_mouse_buttons("play_pause") + + --skip_backward + ne = new_element("skip_backward", "button") + + ne.softrepeat = true + ne.content = icons.skip_backward + ne.eventresponder["mbtn_left_down"] = function() + mp.commandv("seek", -5) + end + ne.eventresponder["mbtn_mid"] = function() + mp.commandv("frame-back-step") + end + ne.eventresponder["mbtn_right_down"] = function() + mp.commandv("seek", -30) + end + + --skip_forward + ne = new_element("skip_forward", "button") + + ne.softrepeat = true + ne.content = icons.skip_forward + ne.eventresponder["mbtn_left_down"] = function() + mp.commandv("seek", 10) + end + ne.eventresponder["mbtn_mid"] = function() + mp.commandv("frame-step") + end + ne.eventresponder["mbtn_right_down"] = function() + mp.commandv("seek", 60) + end + + --chapter_prev + ne = new_element("chapter_prev", "button") + + ne.enabled = have_ch + ne.content = icons.chapter_prev + bind_mouse_buttons("chapter_prev") + + --chapter_next + ne = new_element("chapter_next", "button") + + ne.enabled = have_ch + ne.content = icons.chapter_next + bind_mouse_buttons("chapter_next") + + -- + update_tracklist() + + --audio_track + ne = new_element("audio_track", "button") + + ne.enabled = audio_track_count > 0 + ne.content = function() + return icons.audio + .. osc_styles.smallButtonsLlabel + .. " " + .. mp.get_property_number("aid", "-") + .. "/" + .. audio_track_count + end + bind_mouse_buttons("audio_track") + + --sub_track + ne = new_element("sub_track", "button") + + ne.enabled = sub_track_count > 0 + ne.content = function() + return icons.subtitle + .. osc_styles.smallButtonsLlabel + .. " " + .. mp.get_property_number("sid", "-") + .. "/" + .. sub_track_count + end + bind_mouse_buttons("sub_track") + + --fullscreen + ne = new_element("fullscreen", "button") + ne.content = function() + return state.fullscreen and icons.exit_fullscreen or icons.fullscreen + end + bind_mouse_buttons("fullscreen") + + --seekbar + ne = new_element("seekbar", "slider") + + ne.enabled = mp.get_property("percent-pos") ~= nil + and user_opts.layout ~= "slimbottombar" + and user_opts.layout ~= "slimtopbar" + state.slider_element = ne.enabled and ne or nil -- used for forced_title + ne.slider.markerF = function() + local duration = mp.get_property_number("duration") + if duration ~= nil then + local chapters = mp.get_property_native("chapter-list", {}) + local markers = {} + for n = 1, #chapters do + markers[n] = (chapters[n].time / duration * 100) + end + return markers + else + return {} + end + end + ne.slider.posF = function() + return mp.get_property_number("percent-pos") + end + ne.slider.tooltipF = function(pos) + local duration = mp.get_property_number("duration") + if duration ~= nil and pos ~= nil then + local possec = duration * (pos / 100) + return mp.format_time(possec) + else + return "" + end + end + ne.slider.seekRangesF = function() + if user_opts.seekrangestyle == "none" or not cache_enabled() then + return nil + end + local duration = mp.get_property_number("duration") + if duration == nil or duration <= 0 then + return nil + end + local nranges = {} + for _, range in pairs(state.cache_state["seekable-ranges"]) do + nranges[#nranges + 1] = { + ["start"] = 100 * range["start"] / duration, + ["end"] = 100 * range["end"] / duration, + } + end + return nranges + end + ne.eventresponder["mouse_move"] = --keyframe seeking when mouse is dragged + function(element) + if not element.state.mbtn_left then + return + end + + -- mouse move events may pile up during seeking and may still get + -- sent when the user is done seeking, so we need to throw away + -- identical seeks + local seekto = get_slider_value(element) + if element.state.lastseek == nil or element.state.lastseek ~= seekto then + local flags = "absolute-percent" + if not user_opts.seekbarkeyframes then + flags = flags .. "+exact" + end + mp.commandv("seek", seekto, flags) + element.state.lastseek = seekto + end + end + ne.eventresponder["mbtn_left_down"] = function(element) + element.state.mbtn_left = true + mp.commandv("seek", get_slider_value(element), "absolute-percent+exact") + end + ne.eventresponder["mbtn_left_up"] = function(element) + element.state.mbtn_left = false + end + ne.eventresponder["mbtn_right_up"] = function(element) + local chapter + local pos = get_slider_value(element) + local diff = math.huge + + for i, marker in ipairs(element.slider.markerF()) do + if math.abs(pos - marker) < diff then + diff = math.abs(pos - marker) + chapter = i + end + end + + if chapter then + mp.set_property("chapter", chapter - 1) + end + end + ne.eventresponder["reset"] = function(element) + element.state.lastseek = nil + end + + if user_opts.scrollcontrols then + ne.eventresponder["wheel_up_press"] = function() + mp.commandv("osd-auto", "seek", 10) + end + ne.eventresponder["wheel_down_press"] = function() + mp.commandv("osd-auto", "seek", -10) + end + end + + -- tc_left (current pos) + ne = new_element("tc_left", "button") + + ne.content = function() + if state.tc_ms then + return (mp.get_property_osd("playback-time/full")) + else + return (mp.get_property_osd("playback-time")) + end + end + ne.eventresponder["mbtn_left_up"] = function() + state.tc_ms = not state.tc_ms + request_init() + end + + -- tc_right (total/remaining time) + ne = new_element("tc_right", "button") + + ne.visible = (mp.get_property_number("duration", 0) > 0) + ne.content = function() + if state.rightTC_trem then + local minus = user_opts.unicodeminus and UNICODE_MINUS or "-" + local property = user_opts.remaining_playtime and "playtime-remaining" or "time-remaining" + if state.tc_ms then + return (minus .. mp.get_property_osd(property .. "/full")) + else + return (minus .. mp.get_property_osd(property)) + end + else + if state.tc_ms then + return (mp.get_property_osd("duration/full")) + else + return (mp.get_property_osd("duration")) + end + end + end + ne.eventresponder["mbtn_left_up"] = function() + state.rightTC_trem = not state.rightTC_trem + end + + -- cache + ne = new_element("cache", "button") + + ne.content = function() + if not cache_enabled() then + return "" + end + local dmx_cache = state.cache_state["cache-duration"] + local thresh = math.min(state.dmx_cache * 0.05, 5) -- 5% or 5s + if dmx_cache and math.abs(dmx_cache - state.dmx_cache) >= thresh then + state.dmx_cache = dmx_cache + else + dmx_cache = state.dmx_cache + end + local min = math.floor(dmx_cache / 60) + local sec = math.floor(dmx_cache % 60) -- don't round e.g. 59.9 to 60 + return "Cache: " .. (min > 0 and string.format("%sm%02.0fs", min, sec) or string.format("%3.0fs", sec)) + end + + -- START quality-menu + ne = new_element("quality-menu", "button") + ne.content = function() + return "≚" + end + ne.eventresponder["mbtn_left_up"] = function() + mp.commandv("script-message", "video_formats_toggle") + end + ne.eventresponder["mbtn_right_up"] = function() + mp.commandv("script-message", "audio_formats_toggle") + end + -- END quality-menu + + -- volume + ne = new_element("volume", "button") + + ne.content = function() + local volume = mp.get_property_number("volume") + if volume == 0 or mp.get_property_native("mute") then + return icons.mute + end + + return icons.volume[math.min(4, math.ceil(volume / (100 / 3)))] + end + bind_mouse_buttons("volume") + + -- custom buttons + for i = 1, math.huge do + local content = user_opts["custom_button_" .. i .. "_content"] + if not content or content == "" then + break + end + ne = new_element("custom_button_" .. i, "button") + ne.content = content + bind_mouse_buttons("custom_button_" .. i) + last_custom_button = i + end + + -- load layout + layouts[user_opts.layout]() + + -- load window controls + if window_controls_enabled() then + window_controls(user_opts.layout == "topbar") + end + + --do something with the elements + prepare_elements() + + update_margins() +end + +local function osc_visible(visible) + if state.osc_visible ~= visible then + state.osc_visible = visible + update_margins() + end + request_tick() +end + +local function show_osc() + -- show when disabled can happen (e.g. mouse_move) due to async/delayed unbinding + if not state.enabled then + return + end + + msg.trace("show_osc") + --remember last time of invocation (mouse move) + state.showtime = mp.get_time() + + if user_opts.fadeduration <= 0 then + osc_visible(true) + elseif user_opts.fadein then + if not state.osc_visible then + state.anitype = "in" + request_tick() + end + else + osc_visible(true) + state.anitype = nil + end +end + +local function hide_osc() + msg.trace("hide_osc") + if not state.enabled then + -- typically hide happens at render() from tick(), but now tick() is + -- no-op and won't render again to remove the osc, so do that manually. + state.osc_visible = false + render_wipe() + elseif user_opts.fadeduration > 0 then + if state.osc_visible then + state.anitype = "out" + request_tick() + end + else + osc_visible(false) + end +end + +local function pause_state(_, enabled) + state.paused = enabled + request_tick() +end + +local function cache_state(_, st) + state.cache_state = st + request_tick() +end + +local function mouse_leave() + if get_hidetimeout() >= 0 and get_touchtimeout() <= 0 then + hide_osc() + end + -- reset mouse position + state.last_mouseX, state.last_mouseY = nil, nil + state.mouse_in_window = false +end + +local function handle_touch() + --remember last time of invocation (touch event) + state.touchtime = mp.get_time() +end + +-- +-- Eventhandling +-- + +local function element_has_action(element, action) + return element and element.eventresponder and element.eventresponder[action] +end + +local function process_event(source, what) + local action = string.format("%s%s", source, what and ("_" .. what) or "") + + if what == "down" or what == "press" then + for n = 1, #elements do + if + mouse_hit(elements[n]) + and elements[n].eventresponder + and (elements[n].eventresponder[source .. "_up"] or elements[n].eventresponder[action]) + then + if what == "down" then + state.active_element = n + state.active_event_source = source + end + -- fire the down or press event if the element has one + if element_has_action(elements[n], action) then + elements[n].eventresponder[action](elements[n]) + end + end + end + elseif what == "up" then + if elements[state.active_element] then + local n = state.active_element + + if n == 0 then --luacheck: ignore 542 + --click on background (does not work) + elseif element_has_action(elements[n], action) and mouse_hit(elements[n]) then + elements[n].eventresponder[action](elements[n]) + end + + --reset active element + if element_has_action(elements[n], "reset") then + elements[n].eventresponder["reset"](elements[n]) + end + end + state.active_element = nil + state.mouse_down_counter = 0 + elseif source == "mouse_move" then + state.mouse_in_window = true + + local mouseX, mouseY = get_virt_mouse_pos() + if + user_opts.minmousemove == 0 + or ( + (state.last_mouseX ~= nil and state.last_mouseY ~= nil) + and ( + (math.abs(mouseX - state.last_mouseX) >= user_opts.minmousemove) + or (math.abs(mouseY - state.last_mouseY) >= user_opts.minmousemove) + ) + ) + then + show_osc() + end + state.last_mouseX, state.last_mouseY = mouseX, mouseY + + local n = state.active_element + if element_has_action(elements[n], action) then + elements[n].eventresponder[action](elements[n]) + end + end + + -- ensure rendering after any (mouse) event - icons could change etc + request_tick() +end + +local function do_enable_keybindings() + if state.enabled then + if not state.showhide_enabled then + mp.enable_key_bindings("showhide", "allow-vo-dragging+allow-hide-cursor") + mp.enable_key_bindings("showhide_wc", "allow-vo-dragging+allow-hide-cursor") + end + state.showhide_enabled = true + end +end + +local function enable_osc(enable) + state.enabled = enable + if enable then + do_enable_keybindings() + else + hide_osc() -- acts immediately when state.enabled == false + if state.showhide_enabled then + mp.disable_key_bindings("showhide") + mp.disable_key_bindings("showhide_wc") + end + state.showhide_enabled = false + end +end + +local function render() + msg.trace("rendering") + local current_screen_sizeX, current_screen_sizeY = mp.get_osd_size() + local mouseX, mouseY = get_virt_mouse_pos() + local now = mp.get_time() + + -- check if display changed, if so request reinit + if state.screen_sizeX ~= current_screen_sizeX or state.screen_sizeY ~= current_screen_sizeY then + request_init_resize() + + state.screen_sizeX = current_screen_sizeX + state.screen_sizeY = current_screen_sizeY + end + + -- init management + if state.active_element then + -- mouse is held down on some element - keep ticking and ignore initReq + -- till it's released, or else the mouse-up (click) will misbehave or + -- get ignored. that's because osc_init() recreates the osc elements, + -- but mouse handling depends on the elements staying unmodified + -- between mouse-down and mouse-up (using the index active_element). + request_tick() + elseif state.initREQ then + osc_init() + state.initREQ = false + + -- store initial mouse position + if (state.last_mouseX == nil or state.last_mouseY == nil) and not (mouseX == nil or mouseY == nil) then + state.last_mouseX, state.last_mouseY = mouseX, mouseY + end + end + + -- fade animation + if state.anitype ~= nil then + if state.anistart == nil then + state.anistart = now + end + + if now < state.anistart + (user_opts.fadeduration / 1000) then + if state.anitype == "in" then --fade in + osc_visible(true) + state.animation = + scale_value(state.anistart, (state.anistart + (user_opts.fadeduration / 1000)), 255, 0, now) + elseif state.anitype == "out" then --fade out + state.animation = + scale_value(state.anistart, (state.anistart + (user_opts.fadeduration / 1000)), 0, 255, now) + end + else + if state.anitype == "out" then + osc_visible(false) + end + kill_animation() + end + else + kill_animation() + end + + --mouse show/hide area + for _, cords in pairs(osc_param.areas["showhide"]) do + set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, "showhide") + end + if osc_param.areas["showhide_wc"] then + for _, cords in pairs(osc_param.areas["showhide_wc"]) do + set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, "showhide_wc") + end + else + set_virt_mouse_area(0, 0, 0, 0, "showhide_wc") + end + do_enable_keybindings() + + --mouse input area + local mouse_over_osc = false + + for _, cords in ipairs(osc_param.areas["input"]) do + if state.osc_visible then -- activate only when OSC is actually visible + set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, "input") + end + if state.osc_visible ~= state.input_enabled then + if state.osc_visible then + mp.enable_key_bindings("input") + else + mp.disable_key_bindings("input") + end + state.input_enabled = state.osc_visible + end + + if mouse_hit_coords(cords.x1, cords.y1, cords.x2, cords.y2) then + mouse_over_osc = true + end + end + + if osc_param.areas["window-controls"] then + for _, cords in ipairs(osc_param.areas["window-controls"]) do + if state.osc_visible then -- activate only when OSC is actually visible + set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, "window-controls") + end + if state.osc_visible ~= state.windowcontrols_buttons then + if state.osc_visible then + mp.enable_key_bindings("window-controls") + else + mp.disable_key_bindings("window-controls") + end + state.windowcontrols_buttons = state.osc_visible + end + + if mouse_hit_coords(cords.x1, cords.y1, cords.x2, cords.y2) then + mouse_over_osc = true + end + end + end + + if osc_param.areas["window-controls-title"] then + for _, cords in ipairs(osc_param.areas["window-controls-title"]) do + if state.osc_visible then -- activate only when OSC is actually visible + set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, "window-controls-title") + end + if state.osc_visible ~= state.windowcontrols_title then + if state.osc_visible then + mp.enable_key_bindings("window-controls-title", "allow-vo-dragging") + else + mp.disable_key_bindings("window-controls-title", "allow-vo-dragging") + end + state.windowcontrols_title = state.osc_visible + end + + if mouse_hit_coords(cords.x1, cords.y1, cords.x2, cords.y2) then + mouse_over_osc = true + end + end + end + + -- autohide + if state.showtime ~= nil and get_hidetimeout() >= 0 then + local timeout = state.showtime + (get_hidetimeout() / 1000) - now + if timeout <= 0 and get_touchtimeout() <= 0 then + if state.active_element == nil and not mouse_over_osc then + hide_osc() + end + else + -- the timer is only used to recheck the state and to possibly run + -- the code above again + if not state.hide_timer then + state.hide_timer = mp.add_timeout(0, tick) + end + state.hide_timer.timeout = timeout + -- re-arm + state.hide_timer:kill() + state.hide_timer:resume() + end + end + + -- actual rendering + local ass = assdraw.ass_new() + + -- actual OSC + if state.osc_visible then + render_elements(ass) + end + + -- submit + set_osd(osc_param.playresy * osc_param.display_aspect, osc_param.playresy, ass.text, 1000) +end + +-- called by mpv on every frame +tick = function() + if state.marginsREQ == true then + update_margins() + state.marginsREQ = false + end + + if not state.enabled then + return + end + + if state.idle then + -- render idle message + msg.trace("idle message") + local _, _, display_aspect = mp.get_osd_size() + if display_aspect == 0 then + return + end + local display_h = 360 + local display_w = display_h * display_aspect + -- logo is rendered at 2^(6-1) = 32 times resolution with size 1800x1800 + local icon_x, icon_y = (display_w - 1800 / 32) / 2, 140 + local line_prefix = ("{\\rDefault\\an7\\1a&H00&\\bord0\\shad0\\pos(%f,%f)}"):format(icon_x, icon_y) + + local ass = assdraw.ass_new() + -- mpv logo + if user_opts.idlescreen then + for _, line in ipairs(logo_lines) do + ass:new_event() + ass:append(line_prefix .. line) + end + end + + -- Santa hat + if is_december and user_opts.idlescreen and not user_opts.greenandgrumpy then + for _, line in ipairs(santa_hat_lines) do + ass:new_event() + ass:append(line_prefix .. line) + end + end + + if user_opts.idlescreen then + ass:new_event() + ass:pos(display_w / 2, icon_y + 65) + ass:an(8) + ass:append("Drop files or URLs to play here.") + end + set_osd(display_w, display_h, ass.text, -1000) + + if state.showhide_enabled then + mp.disable_key_bindings("showhide") + mp.disable_key_bindings("showhide_wc") + state.showhide_enabled = false + end + elseif state.fullscreen and user_opts.showfullscreen or (not state.fullscreen and user_opts.showwindowed) then + -- render the OSC + render() + else + -- Flush OSD + render_wipe() + end + + state.tick_last_time = mp.get_time() + + if state.anitype ~= nil then + -- state.anistart can be nil - animation should now start, or it can + -- be a timestamp when it started. state.idle has no animation. + if + not state.idle + and (not state.anistart or mp.get_time() < 1 + state.anistart + user_opts.fadeduration / 1000) + then + -- animating or starting, or still within 1s past the deadline + request_tick() + else + kill_animation() + end + end +end + +local function shutdown() + reset_margins() + mp.del_property("user-data/osc") +end + +-- duration is observed for the sole purpose of updating chapter markers +-- positions. live streams with chapters are very rare, and the update is also +-- expensive (with request_init), so it's only observed when we have chapters +-- and the user didn't disable the livemarkers option (update_duration_watch). +local function on_duration() + request_init() +end + +local duration_watched = false +local function update_duration_watch() + local want_watch = user_opts.livemarkers and (mp.get_property_number("chapters", 0) or 0) > 0 and true or + false -- ensure it's a boolean + + if want_watch ~= duration_watched then + if want_watch then + mp.observe_property("duration", "native", on_duration) + else + mp.unobserve_property(on_duration) + end + duration_watched = want_watch + end +end + +local function set_tick_delay(_, display_fps) + -- may be nil if unavailable or 0 fps is reported + if not display_fps or not user_opts.tick_delay_follow_display_fps then + tick_delay = user_opts.tick_delay + return + end + tick_delay = 1 / display_fps +end + +mp.register_event("shutdown", shutdown) +mp.register_event("start-file", request_init) +mp.observe_property("track-list", "native", request_init) +mp.observe_property("playlist-count", "native", request_init) +mp.observe_property("playlist-pos", "native", request_init) +mp.observe_property("chapter-list", "native", function(_, list) + list = list or {} -- safety, shouldn't return nil + table.sort(list, function(a, b) + return a.time < b.time + end) + state.chapter_list = list + update_duration_watch() + request_init() +end) + +-- These are for backwards compatibility only. +mp.register_script_message("osc-message", function(message, dur) + if not state.osc_message_warned then + mp.msg.warn("osc-message is deprecated and may be removed in the future.", "Use the show-text command instead.") + state.osc_message_warned = true + end + mp.osd_message(message, dur) +end) +mp.register_script_message("osc-chapterlist", function(dur) + if not state.osc_chapterlist_warned then + mp.msg.warn( + "osc-chapterlist is deprecated and may be removed in the future.", + "Use show-text ${chapter-list} instead." + ) + state.osc_chapterlist_warned = true + end + mp.command("show-text ${chapter-list} " .. (dur and dur * 1000 or "")) +end) +mp.register_script_message("osc-playlist", function(dur) + if not state.osc_playlist_warned then + mp.msg.warn( + "osc-playlist is deprecated and may be removed in the future.", + "Use show-text ${playlist} instead." + ) + state.osc_playlist_warned = true + end + mp.command("show-text ${playlist} " .. (dur and dur * 1000 or "")) +end) +mp.register_script_message("osc-tracklist", function(dur) + if not state.osc_tracklist_warned then + mp.msg.warn( + "osc-tracklist is deprecated and may be removed in the future.", + "Use show-text ${track-list} instead." + ) + state.osc_tracklist_warned = true + end + mp.command("show-text ${track-list} " .. (dur and dur * 1000 or "")) +end) + +mp.observe_property("fullscreen", "bool", function(_, val) + state.fullscreen = val + state.marginsREQ = true + request_init_resize() +end) +mp.observe_property("border", "bool", function(_, val) + state.border = val + request_init_resize() +end) +mp.observe_property("title-bar", "bool", function(_, val) + state.title_bar = val + request_init_resize() +end) +mp.observe_property("window-maximized", "bool", function(_, val) + state.maximized = val + request_init_resize() +end) +mp.observe_property("idle-active", "bool", function(_, val) + state.idle = val + request_tick() +end) + +mp.observe_property("display-fps", "number", set_tick_delay) +mp.observe_property("pause", "bool", pause_state) +mp.observe_property("demuxer-cache-state", "native", cache_state) +mp.observe_property("vo-configured", "bool", request_tick) +mp.observe_property("playback-time", "number", request_tick) +mp.observe_property("osd-dimensions", "native", function() + -- (we could use the value instead of re-querying it all the time, but then + -- we might have to worry about property update ordering) + request_init_resize() +end) +mp.observe_property("osd-scale-by-window", "native", request_init_resize) +mp.observe_property("touch-pos", "native", handle_touch) + +-- mouse show/hide bindings +mp.set_key_bindings({ + { + "mouse_move", + function() + process_event("mouse_move", nil) + end, + }, + { "mouse_leave", mouse_leave }, +}, "showhide", "force") +mp.set_key_bindings({ + { + "mouse_move", + function() + process_event("mouse_move", nil) + end, + }, + { "mouse_leave", mouse_leave }, +}, "showhide_wc", "force") +do_enable_keybindings() + +--mouse input bindings +mp.set_key_bindings({ + { + "mbtn_left", + function() + process_event("mbtn_left", "up") + end, + function() + process_event("mbtn_left", "down") + end, + }, + { + "mbtn_mid", + function() + process_event("mbtn_mid", "up") + end, + function() + process_event("mbtn_mid", "down") + end, + }, + { + "mbtn_right", + function() + process_event("mbtn_right", "up") + end, + function() + process_event("mbtn_right", "down") + end, + }, + -- alias shift+mbtn_left to mbtn_mid for touchpads + { + "shift+mbtn_left", + function() + process_event("mbtn_mid", "up") + end, + function() + process_event("mbtn_mid", "down") + end, + }, + { + "wheel_up", + function() + process_event("wheel_up", "press") + end, + }, + { + "wheel_down", + function() + process_event("wheel_down", "press") + end, + }, + { "mbtn_left_dbl", "ignore" }, + { "shift+mbtn_left_dbl", "ignore" }, + { "mbtn_right_dbl", "ignore" }, +}, "input", "force") +mp.enable_key_bindings("input") + +mp.set_key_bindings({ + { + "mbtn_left", + function() + process_event("mbtn_left", "up") + end, + function() + process_event("mbtn_left", "down") + end, + }, +}, "window-controls", "force") +mp.enable_key_bindings("window-controls") + +local function always_on(val) + if state.enabled then + if val then + show_osc() + else + hide_osc() + end + end +end + +-- mode can be auto/always/never/cycle +-- the modes only affect internal variables and not stored on its own. +local function visibility_mode(mode, no_osd) + if mode == "cycle" then + for i, allowed_mode in ipairs(state.visibility_modes) do + if i == #state.visibility_modes then + mode = state.visibility_modes[1] + break + elseif user_opts.visibility == allowed_mode then + mode = state.visibility_modes[i + 1] + break + end + end + end + + if mode == "auto" then + always_on(false) + enable_osc(true) + elseif mode == "always" then + enable_osc(true) + always_on(true) + elseif mode == "never" then + enable_osc(false) + else + msg.warn("Ignoring unknown visibility mode '" .. mode .. "'") + return + end + + user_opts.visibility = mode + mp.set_property_native("user-data/osc/visibility", mode) + + if not no_osd and tonumber(mp.get_property("osd-level")) >= 1 then + mp.osd_message("OSC visibility: " .. mode) + end + + -- Reset the input state on a mode change. The input state will be + -- recalculated on the next render cycle, except in 'never' mode where it + -- will just stay disabled. + mp.disable_key_bindings("input") + mp.disable_key_bindings("window-controls") + state.input_enabled = false + + update_margins() + request_tick() +end + +local function idlescreen_visibility(mode, no_osd) + if mode == "cycle" then + if user_opts.idlescreen then + mode = "no" + else + mode = "yes" + end + end + + if mode == "yes" then + user_opts.idlescreen = true + else + user_opts.idlescreen = false + end + + mp.set_property_native("user-data/osc/idlescreen", user_opts.idlescreen) + + if not no_osd and tonumber(mp.get_property("osd-level")) >= 1 then + mp.osd_message("OSC logo visibility: " .. tostring(mode)) + end + + request_tick() +end + +mp.register_script_message("osc-visibility", visibility_mode) +mp.register_script_message("osc-show", show_osc) +mp.register_script_message("osc-hide", function() + if user_opts.visibility == "auto" then + osc_visible(false) + end +end) +mp.add_key_binding(nil, "visibility", function() + visibility_mode("cycle") +end) + +mp.register_script_message("osc-idlescreen", idlescreen_visibility) + +-- Validate string type user options +local function validate_user_opts() + if layouts[user_opts.layout] == nil then + msg.warn("Invalid setting '" .. user_opts.layout .. "' for layout") + user_opts.layout = "bottombar" + end + + if user_opts.seekbarstyle ~= "bar" and user_opts.seekbarstyle ~= "diamond" and user_opts.seekbarstyle ~= "knob" then + msg.warn("Invalid setting '" .. user_opts.seekbarstyle .. "' for seekbarstyle") + user_opts.seekbarstyle = "bar" + end + + if + user_opts.seekrangestyle ~= "bar" + and user_opts.seekrangestyle ~= "line" + and user_opts.seekrangestyle ~= "slider" + and user_opts.seekrangestyle ~= "inverted" + and user_opts.seekrangestyle ~= "none" + then + msg.warn("Invalid setting '" .. user_opts.seekrangestyle .. "' for seekrangestyle") + user_opts.seekrangestyle = "inverted" + end + + if user_opts.seekrangestyle == "slider" and user_opts.seekbarstyle == "bar" then + msg.warn("Using 'slider' seekrangestyle together with 'bar' seekbarstyle is not supported") + user_opts.seekrangestyle = "inverted" + end + + if + user_opts.windowcontrols ~= "auto" + and user_opts.windowcontrols ~= "yes" + and user_opts.windowcontrols ~= "no" + then + msg.warn("windowcontrols cannot be '" .. user_opts.windowcontrols .. "'. Ignoring.") + user_opts.windowcontrols = "auto" + end + if user_opts.windowcontrols_alignment ~= "right" and user_opts.windowcontrols_alignment ~= "left" then + msg.warn("windowcontrols_alignment cannot be '" .. user_opts.windowcontrols_alignment .. "'. Ignoring.") + user_opts.windowcontrols_alignment = "right" + end + + local colors = { + user_opts.background_color, + user_opts.top_buttons_color, + user_opts.small_buttonsL_color, + user_opts.small_buttonsR_color, + user_opts.buttons_color, + user_opts.title_color, + user_opts.timecode_color, + user_opts.time_pos_color, + user_opts.held_element_color, + user_opts.time_pos_outline_color, + } + for _, color in pairs(colors) do + if color:find("^#%x%x%x%x%x%x$") == nil then + msg.warn("'" .. color .. "' is not a valid color") + end + end + + for str in string.gmatch(user_opts.visibility_modes, "([^_]+)") do + if str ~= "auto" and str ~= "always" and str ~= "never" then + msg.warn("Ignoring unknown visibility mode '" .. str .. "' in list") + else + table.insert(state.visibility_modes, str) + end + end +end + +-- read options from config and command-line +opt.read_options(user_opts, "osc", function(changed) + validate_user_opts() + set_osc_styles() + set_time_styles(changed.timetotal, changed.timems) + if changed.tick_delay or changed.tick_delay_follow_display_fps then + set_tick_delay("display_fps", mp.get_property_number("display_fps")) + end + request_tick() + visibility_mode(user_opts.visibility, true) + update_duration_watch() + request_init() +end) + +validate_user_opts() +set_osc_styles() +set_time_styles(true, true) +set_tick_delay("display_fps", mp.get_property_number("display_fps")) +visibility_mode(user_opts.visibility, true) +update_duration_watch() + +set_virt_mouse_area(0, 0, 0, 0, "input") +set_virt_mouse_area(0, 0, 0, 0, "window-controls") +set_virt_mouse_area(0, 0, 0, 0, "window-controls-title") diff --git a/.config/mpv/scripts/skip-logo.lua b/.config/mpv/scripts/skip-logo.lua new file mode 120000 index 0000000..9d895cd --- /dev/null +++ b/.config/mpv/scripts/skip-logo.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/skip-logo.lua \ No newline at end of file diff --git a/.config/mpv/scripts/sponsorblock.lua b/.config/mpv/scripts/sponsorblock.lua new file mode 100644 index 0000000..96bfb24 --- /dev/null +++ b/.config/mpv/scripts/sponsorblock.lua @@ -0,0 +1,569 @@ +-- sponsorblock.lua +-- +-- This script skips sponsored segments of YouTube videos +-- using data from https://github.com/ajayyy/SponsorBlock + +local ON_WINDOWS = package.config:sub(1,1) ~= "/" + +local options = { + server_address = "https://sponsor.ajay.app", + + python_path = ON_WINDOWS and "python" or "python3", + + -- Categories to fetch + categories = "sponsor,intro,outro,interaction,selfpromo,filler", + + -- Categories to skip automatically + skip_categories = "sponsor", + + -- If true, sponsored segments will only be skipped once + skip_once = true, + + -- Note that sponsored segments may ocasionally be inaccurate if this is turned off + -- see https://blog.ajay.app/voting-and-pseudo-randomness-or-sponsorblock-or-youtube-sponsorship-segment-blocker + local_database = false, + + -- Update database on first run, does nothing if local_database is false + auto_update = true, + + -- How long to wait between local database updates + -- Format: "X[d,h,m]", leave blank to update on every mpv run + auto_update_interval = "6h", + + -- User ID used to submit sponsored segments, leave blank for random + user_id = "", + + -- Name to display on the stats page https://sponsor.ajay.app/stats/ leave blank to keep current name + display_name = "", + + -- Tell the server when a skip happens + report_views = true, + + -- Auto upvote skipped sponsors + auto_upvote = false, + + -- Use sponsor times from server if they're more up to date than our local database + server_fallback = true, + + -- Create chapters at sponsor boundaries for OSC display and manual skipping + make_chapters = true, + + -- Minimum duration for sponsors (in seconds), segments under that threshold will be ignored + min_duration = 1, + + -- Fade audio for smoother transitions + audio_fade = false, + + -- Audio fade step, applied once every 100ms until cap is reached + audio_fade_step = 10, + + -- Audio fade cap + audio_fade_cap = 0, + + -- Fast forward through sponsors instead of skipping + fast_forward = false, + + -- Playback speed modifier when fast forwarding, applied once every second until cap is reached + fast_forward_increase = .2, + + -- Playback speed cap + fast_forward_cap = 2, + + -- Length of the sha256 prefix (3-32) when querying server, 0 to disable + sha256_length = 4, + + -- Pattern for video id in local files, ignored if blank + -- Recommended value for base youtube-dl is "-([%w-_]+)%.[mw][kpe][v4b]m?$" + local_pattern = "", + + -- Legacy option, use skip_categories instead + skip = true +} + +mp.options = require "mp.options" +mp.options.read_options(options, "sponsorblock") + +local legacy = mp.command_native_async == nil +--[[ +if legacy then + options.local_database = false +end +--]] +options.local_database = false + +local utils = require "mp.utils" +scripts_dir = mp.find_config_file("scripts") + +local sponsorblock = utils.join_path(scripts_dir, "sponsorblock_shared/sponsorblock.py") +local uid_path = utils.join_path(scripts_dir, "sponsorblock_shared/sponsorblock.txt") +local database_file = options.local_database and utils.join_path(scripts_dir, "sponsorblock_shared/sponsorblock.db") or "" +local youtube_id = nil +local ranges = {} +local init = false +local segment = {a = 0, b = 0, progress = 0, first = true} +local retrying = false +local last_skip = {uuid = "", dir = nil} +local speed_timer = nil +local fade_timer = nil +local fade_dir = nil +local volume_before = mp.get_property_number("volume") +local categories = {} +local all_categories = {"sponsor", "intro", "outro", "interaction", "selfpromo", "preview", "music_offtopic", "filler"} +local chapter_cache = {} + +for category in string.gmatch(options.skip_categories, "([^,]+)") do + categories[category] = true +end + +function file_exists(name) + local f = io.open(name,"r") + if f ~= nil then io.close(f) return true else return false end +end + +function t_count(t) + local count = 0 + for _ in pairs(t) do count = count + 1 end + return count +end + +function time_sort(a, b) + if a.time == b.time then + return string.match(a.title, "segment end") + end + return a.time < b.time +end + +function parse_update_interval() + local s = options.auto_update_interval + if s == "" then return 0 end -- Interval Disabled + + local num, mod = s:match "^(%d+)([hdm])$" + + if num == nil or mod == nil then + mp.osd_message("[sponsorblock] auto_update_interval " .. s .. " is invalid", 5) + return nil + end + + local time_table = { + m = 60, + h = 60 * 60, + d = 60 * 60 * 24, + } + + return num * time_table[mod] +end + +function clean_chapters() + local chapters = mp.get_property_native("chapter-list") + local new_chapters = {} + for _, chapter in pairs(chapters) do + if chapter.title ~= "Preview segment start" and chapter.title ~= "Preview segment end" then + table.insert(new_chapters, chapter) + end + end + mp.set_property_native("chapter-list", new_chapters) +end + +function create_chapter(chapter_title, chapter_time) + local chapters = mp.get_property_native("chapter-list") + local duration = mp.get_property_native("duration") + table.insert(chapters, {title=chapter_title, time=(duration == nil or duration > chapter_time) and chapter_time or duration - .001}) + table.sort(chapters, time_sort) + mp.set_property_native("chapter-list", chapters) +end + +function process(uuid, t, new_ranges) + start_time = tonumber(string.match(t, "[^,]+")) + end_time = tonumber(string.sub(string.match(t, ",[^,]+"), 2)) + for o_uuid, o_t in pairs(ranges) do + if (start_time >= o_t.start_time and start_time <= o_t.end_time) or (o_t.start_time >= start_time and o_t.start_time <= end_time) then + new_ranges[o_uuid] = o_t + return + end + end + category = string.match(t, "[^,]+$") + if categories[category] and end_time - start_time >= options.min_duration then + new_ranges[uuid] = { + start_time = start_time, + end_time = end_time, + category = category, + skipped = false + } + end + if options.make_chapters and not chapter_cache[uuid] then + chapter_cache[uuid] = true + local category_title = (category:gsub("^%l", string.upper):gsub("_", " ")) + create_chapter(category_title .. " segment start (" .. string.sub(uuid, 1, 6) .. ")", start_time) + create_chapter(category_title .. " segment end (" .. string.sub(uuid, 1, 6) .. ")", end_time) + end +end + +function getranges(_, exists, db, more) + if type(exists) == "table" and exists["status"] == "1" then + if options.server_fallback then + mp.add_timeout(0, function() getranges(true, true, "") end) + else + return mp.osd_message("[sponsorblock] database update failed, gave up") + end + end + if db ~= "" and db ~= database_file then db = database_file end + if exists ~= true and not file_exists(db) then + if not retrying then + mp.osd_message("[sponsorblock] database update failed, retrying...") + retrying = true + end + return update() + end + if retrying then + mp.osd_message("[sponsorblock] database update succeeded") + retrying = false + end + local sponsors + local args = { + options.python_path, + sponsorblock, + "ranges", + db, + options.server_address, + youtube_id, + options.categories, + tostring(options.sha256_length) + } + if not legacy then + sponsors = mp.command_native({name = "subprocess", capture_stdout = true, playback_only = false, args = args}) + else + sponsors = utils.subprocess({args = args}) + end + mp.msg.debug("Got: " .. string.gsub(sponsors.stdout, "[\n\r]", "")) + if not string.match(sponsors.stdout, "^%s*(.*%S)") then return end + if string.match(sponsors.stdout, "error") then return getranges(true, true) end + local new_ranges = {} + local r_count = 0 + if more then r_count = -1 end + for t in string.gmatch(sponsors.stdout, "[^:%s]+") do + uuid = string.match(t, "([^,]+),[^,]+$") + if ranges[uuid] then + new_ranges[uuid] = ranges[uuid] + else + process(uuid, t, new_ranges) + end + r_count = r_count + 1 + end + local c_count = t_count(ranges) + if c_count == 0 or r_count >= c_count then + ranges = new_ranges + end +end + +function fast_forward() + if options.fast_forward and options.fast_forward == true then + speed_timer = nil + mp.set_property("speed", 1) + end + local last_speed = mp.get_property_number("speed") + local new_speed = math.min(last_speed + options.fast_forward_increase, options.fast_forward_cap) + if new_speed <= last_speed then return end + mp.set_property("speed", new_speed) +end + +function fade_audio(step) + local last_volume = mp.get_property_number("volume") + local new_volume = math.max(options.audio_fade_cap, math.min(last_volume + step, volume_before)) + if new_volume == last_volume then + if step >= 0 then fade_dir = nil end + if fade_timer ~= nil then fade_timer:kill() end + fade_timer = nil + return + end + mp.set_property("volume", new_volume) +end + +function skip_ads(name, pos) + if pos == nil then return end + local sponsor_ahead = false + for uuid, t in pairs(ranges) do + if (options.fast_forward == uuid or not options.skip_once or not t.skipped) and t.start_time <= pos and t.end_time > pos then + if options.fast_forward == uuid then return end + if options.fast_forward == false then + mp.osd_message("[sponsorblock] " .. t.category .. " skipped") + mp.set_property("time-pos", t.end_time) + else + mp.osd_message("[sponsorblock] skipping " .. t.category) + end + t.skipped = true + last_skip = {uuid = uuid, dir = nil} + if options.report_views or options.auto_upvote then + local args = { + options.python_path, + sponsorblock, + "stats", + database_file, + options.server_address, + youtube_id, + uuid, + options.report_views and "1" or "", + uid_path, + options.user_id, + options.auto_upvote and "1" or "" + } + if not legacy then + mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end) + else + utils.subprocess_detached({args = args}) + end + end + if options.fast_forward ~= false then + options.fast_forward = uuid + if speed_timer ~= nil then speed_timer:kill() end + speed_timer = mp.add_periodic_timer(1, fast_forward) + end + return + elseif (not options.skip_once or not t.skipped) and t.start_time <= pos + 1 and t.end_time > pos + 1 then + sponsor_ahead = true + end + end + if options.audio_fade then + if sponsor_ahead then + if fade_dir ~= false then + if fade_dir == nil then volume_before = mp.get_property_number("volume") end + if fade_timer ~= nil then fade_timer:kill() end + fade_dir = false + fade_timer = mp.add_periodic_timer(.1, function() fade_audio(-options.audio_fade_step) end) + end + elseif fade_dir == false then + fade_dir = true + if fade_timer ~= nil then fade_timer:kill() end + fade_timer = mp.add_periodic_timer(.1, function() fade_audio(options.audio_fade_step) end) + end + end + if options.fast_forward and options.fast_forward ~= true then + options.fast_forward = true + speed_timer:kill() + speed_timer = nil + mp.set_property("speed", 1) + end +end + +function vote(dir) + if last_skip.uuid == "" then return mp.osd_message("[sponsorblock] no sponsors skipped, can't submit vote") end + local updown = dir == "1" and "up" or "down" + if last_skip.dir == dir then return mp.osd_message("[sponsorblock] " .. updown .. "vote already submitted") end + last_skip.dir = dir + local args = { + options.python_path, + sponsorblock, + "stats", + database_file, + options.server_address, + youtube_id, + last_skip.uuid, + "", + uid_path, + options.user_id, + dir + } + if not legacy then + mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end) + else + utils.subprocess({args = args}) + end + mp.osd_message("[sponsorblock] " .. updown .. "vote submitted") +end + +function update() + mp.command_native_async({name = "subprocess", playback_only = false, args = { + options.python_path, + sponsorblock, + "update", + database_file, + options.server_address + }}, getranges) +end + +function file_loaded() + local initialized = init + ranges = {} + segment = {a = 0, b = 0, progress = 0, first = true} + last_skip = {uuid = "", dir = nil} + chapter_cache = {} + local video_path = mp.get_property("path", "") + mp.msg.debug("Path: " .. video_path) + local video_referer = string.match(mp.get_property("http-header-fields", ""), "Referer:([^,]+)") or "" + mp.msg.debug("Referer: " .. video_referer) + + local urls = { + "ytdl://([%w-_]+).*", + "https?://youtu%.be/([%w-_]+).*", + "https?://w?w?w?%.?youtube%.com/v/([%w-_]+).*", + "/watch.*[?&]v=([%w-_]+).*", + "/embed/([%w-_]+).*" + } + youtube_id = nil + for i, url in ipairs(urls) do + youtube_id = youtube_id or string.match(video_path, url) or string.match(video_referer, url) + if youtube_id then break end + end + youtube_id = youtube_id or string.match(video_path, options.local_pattern) + + if not youtube_id or string.len(youtube_id) < 11 or (local_pattern and string.len(youtube_id) ~= 11) then return end + youtube_id = string.sub(youtube_id, 1, 11) + mp.msg.debug("Found YouTube ID: " .. youtube_id) + init = true + if not options.local_database then + getranges(true, true) + else + local exists = file_exists(database_file) + if exists and options.server_fallback then + getranges(true, true) + mp.add_timeout(0, function() getranges(true, true, "", true) end) + elseif exists then + getranges(true, true) + elseif options.server_fallback then + mp.add_timeout(0, function() getranges(true, true, "") end) + end + end + if initialized then return end + if options.skip then + mp.observe_property("time-pos", "native", skip_ads) + end + if options.display_name ~= "" then + local args = { + options.python_path, + sponsorblock, + "username", + database_file, + options.server_address, + youtube_id, + "", + "", + uid_path, + options.user_id, + options.display_name + } + if not legacy then + mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end) + else + utils.subprocess_detached({args = args}) + end + end + if not options.local_database or (not options.auto_update and file_exists(database_file)) then return end + + if file_exists(database_file) then + local db_info = utils.file_info(database_file) + local cur_time = os.time(os.date("*t")) + local upd_interval = parse_update_interval() + if upd_interval == nil or os.difftime(cur_time, db_info.mtime) < upd_interval then return end + end + + update() +end + +function set_segment() + if not youtube_id then return end + local pos = mp.get_property_number("time-pos") + if pos == nil then return end + if segment.progress > 1 then + segment.progress = segment.progress - 2 + end + if segment.progress == 1 then + segment.progress = 0 + segment.b = pos + mp.osd_message("[sponsorblock] segment boundary B set, press again for boundary A", 3) + else + segment.progress = 1 + segment.a = pos + mp.osd_message("[sponsorblock] segment boundary A set, press again for boundary B", 3) + end + if options.make_chapters and not segment.first then + local start_time = math.min(segment.a, segment.b) + local end_time = math.max(segment.a, segment.b) + if end_time - start_time ~= 0 and end_time ~= 0 then + clean_chapters() + create_chapter("Preview segment start", start_time) + create_chapter("Preview segment end", end_time) + end + end + segment.first = false +end + +function select_category(selected) + for category in string.gmatch(options.categories, "([^,]+)") do + mp.remove_key_binding("select_category_"..category) + mp.remove_key_binding("kp_select_category_"..category) + end + submit_segment(selected) +end + +function submit_segment(category) + if not youtube_id then return end + local start_time = math.min(segment.a, segment.b) + local end_time = math.max(segment.a, segment.b) + if end_time - start_time == 0 or end_time == 0 then + mp.osd_message("[sponsorblock] empty segment, not submitting") + elseif segment.progress <= 1 then + segment.progress = segment.progress + 2 + local category_list = "" + for category_id, category in pairs(all_categories) do + local category_title = (category:gsub("^%l", string.upper):gsub("_", " ")) + category_list = category_list .. category_id .. ": " .. category_title .. "\n" + mp.add_forced_key_binding(tostring(category_id), "select_category_"..category, function() select_category(category) end) + mp.add_forced_key_binding("KP"..tostring(category_id), "kp_select_category_"..category, function() select_category(category) end) + end + mp.osd_message(string.format("[sponsorblock] press a number to select category for segment: %.2d:%.2d:%.2d to %.2d:%.2d:%.2d\n\n" .. category_list .. "\nyou can press Shift+G again for default (Sponsor) or hide this message with g", math.floor(start_time/(60*60)), math.floor(start_time/60%60), math.floor(start_time%60), math.floor(end_time/(60*60)), math.floor(end_time/60%60), math.floor(end_time%60)), 30) + else + mp.osd_message("[sponsorblock] submitting segment...", 30) + local submit + local args = { + options.python_path, + sponsorblock, + "submit", + database_file, + options.server_address, + youtube_id, + tostring(start_time), + tostring(end_time), + uid_path, + options.user_id, + category or "sponsor" + } + if not legacy then + submit = mp.command_native({name = "subprocess", capture_stdout = true, playback_only = false, args = args}) + else + submit = utils.subprocess({args = args}) + end + if string.match(submit.stdout, "success") then + segment = {a = 0, b = 0, progress = 0, first = true} + mp.osd_message("[sponsorblock] segment submitted") + if options.make_chapters then + clean_chapters() + create_chapter("Submitted segment start", start_time) + create_chapter("Submitted segment end", end_time) + end + elseif string.match(submit.stdout, "error") then + mp.osd_message("[sponsorblock] segment submission failed, server may be down. try again", 5) + elseif string.match(submit.stdout, "502") then + mp.osd_message("[sponsorblock] segment submission failed, server is down. try again", 5) + elseif string.match(submit.stdout, "400") then + mp.osd_message("[sponsorblock] segment submission failed, impossible inputs", 5) + segment = {a = 0, b = 0, progress = 0, first = true} + elseif string.match(submit.stdout, "429") then + mp.osd_message("[sponsorblock] segment submission failed, rate limited. try again", 5) + elseif string.match(submit.stdout, "409") then + mp.osd_message("[sponsorblock] segment already submitted", 3) + segment = {a = 0, b = 0, progress = 0, first = true} + else + mp.osd_message("[sponsorblock] segment submission failed", 5) + end + end +end + +mp.register_event("file-loaded", file_loaded) +mp.add_key_binding("g", "set_segment", set_segment) +mp.add_key_binding("G", "submit_segment", submit_segment) +mp.add_key_binding("h", "upvote_segment", function() return vote("1") end) +mp.add_key_binding("H", "downvote_segment", function() return vote("0") end) +-- Bindings below are for backwards compatibility and could be removed at any time +mp.add_key_binding(nil, "sponsorblock_set_segment", set_segment) +mp.add_key_binding(nil, "sponsorblock_submit_segment", submit_segment) +mp.add_key_binding(nil, "sponsorblock_upvote", function() return vote("1") end) +mp.add_key_binding(nil, "sponsorblock_downvote", function() return vote("0") end) diff --git a/.config/mpv/scripts/sponsorblock_shared/main.lua b/.config/mpv/scripts/sponsorblock_shared/main.lua new file mode 100644 index 0000000..2bbe7a2 --- /dev/null +++ b/.config/mpv/scripts/sponsorblock_shared/main.lua @@ -0,0 +1,3 @@ +-- This is a dummy main.lua +-- required for mpv 0.33 +-- do not delete \ No newline at end of file diff --git a/.config/mpv/scripts/sponsorblock_shared/sponsorblock.py b/.config/mpv/scripts/sponsorblock_shared/sponsorblock.py new file mode 100644 index 0000000..8370a6a --- /dev/null +++ b/.config/mpv/scripts/sponsorblock_shared/sponsorblock.py @@ -0,0 +1,122 @@ +import urllib.request +import urllib.parse +import hashlib +import sqlite3 +import random +import string +import json +import sys +import os + +if sys.argv[1] in ["submit", "stats", "username"]: + if not sys.argv[8]: + if os.path.isfile(sys.argv[7]): + with open(sys.argv[7]) as f: + uid = f.read() + else: + uid = "".join(random.choices(string.ascii_letters + string.digits, k=36)) + with open(sys.argv[7], "w") as f: + f.write(uid) + else: + uid = sys.argv[8] + +opener = urllib.request.build_opener() +opener.addheaders = [("User-Agent", "mpv_sponsorblock/1.0 (https://github.com/po5/mpv_sponsorblock)")] +urllib.request.install_opener(opener) + +if sys.argv[1] == "ranges" and (not sys.argv[2] or not os.path.isfile(sys.argv[2])): + sha = None + if 3 <= int(sys.argv[6]) <= 32: + sha = hashlib.sha256(sys.argv[4].encode()).hexdigest()[:int(sys.argv[6])] + times = [] + try: + response = urllib.request.urlopen(sys.argv[3] + "/api/skipSegments" + ("/" + sha + "?" if sha else "?videoID=" + sys.argv[4] + "&") + urllib.parse.urlencode([("categories", json.dumps(sys.argv[5].split(",")))])) + segments = json.load(response) + for segment in segments: + if sha and sys.argv[4] != segment["videoID"]: + continue + if sha: + for s in segment["segments"]: + times.append(str(s["segment"][0]) + "," + str(s["segment"][1]) + "," + s["UUID"] + "," + s["category"]) + else: + times.append(str(segment["segment"][0]) + "," + str(segment["segment"][1]) + "," + segment["UUID"] + "," + segment["category"]) + print(":".join(times)) + except (TimeoutError, urllib.error.URLError) as e: + print("error") + except urllib.error.HTTPError as e: + if e.code == 404: + print("") + else: + print("error") +elif sys.argv[1] == "ranges": + conn = sqlite3.connect(sys.argv[2]) + conn.row_factory = sqlite3.Row + c = conn.cursor() + times = [] + for category in sys.argv[5].split(","): + c.execute("SELECT startTime, endTime, votes, UUID, category FROM sponsorTimes WHERE videoID = ? AND shadowHidden = 0 AND votes > -1 AND category = ?", (sys.argv[4], category)) + sponsors = c.fetchall() + best = list(sponsors) + dealtwith = [] + similar = [] + for sponsor_a in sponsors: + for sponsor_b in sponsors: + if sponsor_a is not sponsor_b and sponsor_a["startTime"] >= sponsor_b["startTime"] and sponsor_a["startTime"] <= sponsor_b["endTime"]: + similar.append([sponsor_a, sponsor_b]) + if sponsor_a in best: + best.remove(sponsor_a) + if sponsor_b in best: + best.remove(sponsor_b) + for sponsors_a in similar: + if sponsors_a in dealtwith: + continue + group = set(sponsors_a) + for sponsors_b in similar: + if sponsors_b[0] in group or sponsors_b[1] in group: + group.add(sponsors_b[0]) + group.add(sponsors_b[1]) + dealtwith.append(sponsors_b) + best.append(max(group, key=lambda x:x["votes"])) + for time in best: + times.append(str(time["startTime"]) + "," + str(time["endTime"]) + "," + time["UUID"] + "," + time["category"]) + print(":".join(times)) +elif sys.argv[1] == "update": + try: + urllib.request.urlretrieve(sys.argv[3] + "/database.db", sys.argv[2] + ".tmp") + os.replace(sys.argv[2] + ".tmp", sys.argv[2]) + except PermissionError: + print("database update failed, file currently in use", file=sys.stderr) + sys.exit(1) + except ConnectionResetError: + print("database update failed, connection reset", file=sys.stderr) + sys.exit(1) + except TimeoutError: + print("database update failed, timed out", file=sys.stderr) + sys.exit(1) + except urllib.error.URLError: + print("database update failed", file=sys.stderr) + sys.exit(1) +elif sys.argv[1] == "submit": + try: + req = urllib.request.Request(sys.argv[3] + "/api/skipSegments", data=json.dumps({"videoID": sys.argv[4], "segments": [{"segment": [float(sys.argv[5]), float(sys.argv[6])], "category": sys.argv[9]}], "userID": uid}).encode(), headers={"Content-Type": "application/json"}) + response = urllib.request.urlopen(req) + print("success") + except urllib.error.HTTPError as e: + print(e.code) + except: + print("error") +elif sys.argv[1] == "stats": + try: + if sys.argv[6]: + urllib.request.urlopen(sys.argv[3] + "/api/viewedVideoSponsorTime?UUID=" + sys.argv[5]) + if sys.argv[9]: + urllib.request.urlopen(sys.argv[3] + "/api/voteOnSponsorTime?UUID=" + sys.argv[5] + "&userID=" + uid + "&type=" + sys.argv[9]) + except: + pass +elif sys.argv[1] == "username": + try: + data = urllib.parse.urlencode({"userID": uid, "userName": sys.argv[9]}).encode() + req = urllib.request.Request(sys.argv[3] + "/api/setUsername", data=data) + urllib.request.urlopen(req) + except: + pass diff --git a/.config/mpv/scripts/sponsorblock_shared/sponsorblock.txt b/.config/mpv/scripts/sponsorblock_shared/sponsorblock.txt new file mode 100644 index 0000000..9b5e499 --- /dev/null +++ b/.config/mpv/scripts/sponsorblock_shared/sponsorblock.txt @@ -0,0 +1 @@ +2w0fKpa3ZP3LF1GOsREvGfMGUBYbyrJzf24U \ No newline at end of file diff --git a/.config/mpv/scripts/stats-conv.py b/.config/mpv/scripts/stats-conv.py new file mode 120000 index 0000000..a85b900 --- /dev/null +++ b/.config/mpv/scripts/stats-conv.py @@ -0,0 +1 @@ +/usr/share/mpv/scripts/stats-conv.py \ No newline at end of file diff --git a/.config/mpv/scripts/status-line.lua b/.config/mpv/scripts/status-line.lua new file mode 120000 index 0000000..905c3e4 --- /dev/null +++ b/.config/mpv/scripts/status-line.lua @@ -0,0 +1 @@ +/usr/share/mpv/scripts/status-line.lua \ No newline at end of file diff --git a/.config/mpv/scripts/umpv b/.config/mpv/scripts/umpv new file mode 120000 index 0000000..b3e4c90 --- /dev/null +++ b/.config/mpv/scripts/umpv @@ -0,0 +1 @@ +/usr/share/mpv/scripts/umpv \ No newline at end of file diff --git a/.config/mpv/scripts/uosc b/.config/mpv/scripts/uosc new file mode 120000 index 0000000..b2066b1 --- /dev/null +++ b/.config/mpv/scripts/uosc @@ -0,0 +1 @@ +/usr/share/mpv/scripts/uosc \ No newline at end of file