diff --git a/dotfiles/.config/mpv/fonts.conf b/dotfiles/.config/mpv/fonts.conf
new file mode 100644
index 0000000..c8fdfc2
--- /dev/null
+++ b/dotfiles/.config/mpv/fonts.conf
@@ -0,0 +1,103 @@
+
+
+
+
+ Default configuration file
+
+
+
+
+
+ WINDOWSFONTDIR
+ WINDOWSUSERFONTDIR
+
+
+ fonts
+
+ ~/.fonts
+
+
+
+
+ mono
+
+
+ monospace
+
+
+
+
+
+
+ sans serif
+
+
+ sans-serif
+
+
+
+
+
+
+ sans
+
+
+ sans-serif
+
+
+
+
+
+ system ui
+
+
+ system-ui
+
+
+
+
+ conf.d
+
+
+
+ LOCAL_APPDATA_FONTCONFIG_CACHE
+ fontconfig
+
+ ~/.fontconfig
+
+
+
+
+ 30
+
+
+
+
diff --git a/dotfiles/.config/mpv/fonts/uosc_icons.otf b/dotfiles/.config/mpv/fonts/uosc_icons.otf
new file mode 100644
index 0000000..4c4e0dc
Binary files /dev/null and b/dotfiles/.config/mpv/fonts/uosc_icons.otf differ
diff --git a/dotfiles/.config/mpv/fonts/uosc_textures.ttf b/dotfiles/.config/mpv/fonts/uosc_textures.ttf
new file mode 100644
index 0000000..e89f1d8
Binary files /dev/null and b/dotfiles/.config/mpv/fonts/uosc_textures.ttf differ
diff --git a/dotfiles/.config/mpv/input.conf b/dotfiles/.config/mpv/input.conf
new file mode 100644
index 0000000..20a4738
--- /dev/null
+++ b/dotfiles/.config/mpv/input.conf
@@ -0,0 +1,48 @@
+MBTN_LEFT cycle pause;script-message-to uosc flash-pause-indicator #@click
+TAB script-message-to uosc toggle-ui #@press
+TAB script-message-to uosc toggle-ui #@release
+MBTN_LEFT script-binding evafast/speedup #@press
+MBTN_LEFT script-binding evafast/slowdown #@release
+
+d cycle deband
+p script-binding webtorrent/toggle-info
+ctrl+d script-binding autodeint/autodeint
+ALT+b script-binding autosub/download_subs
+MOUSE_BTN2 script-binding uosc/menu-blurred
+ALT+a script-message-to uosc show-submenu-blurred "File"
+ALT+z script-message-to uosc show-submenu-blurred "Audio"
+ALT+x script-message-to uosc show-submenu-blurred "Subtitles"
+ALT+s script-message-to uosc show-submenu-blurred "Video > Shaders"
+
+### UOSC Menu Config ###
+
+h script-binding memo-history #! File > History
+# script-binding uosc/playlist #! File > Playlist
+ALT+c script-binding uosc/chapters #! File > Chapters
+b script-binding uosc/open-file #! File > Open File
+# script-binding uosc/show-in-directory #! File > Open in File Explorer
+
+# apply-profile Deband-Medium #! Video > Filters > Deband (Medium)
+# apply-profile Deband-Strong #! Video > Filters > Deband (Strong)
+
+c change-list glsl-shaders clr all #! Video > Shaders > Clear All
+g cycle interpolation #! Video > Interpolation
+# script-binding uosc/video #! Video > Select Video Track
+
+F1 af toggle "lavfi=[loudnorm=I=-14:TP=-3:LRA=4]" ; show-text "${af}" #! Audio > Dialogue
+# af clr "" #! Audio > Clear Filters
+# script-binding afilter/toggle-eqr #! Audio > Toggle Equalizer
+a cycle audio-normalize-downmix #! Audio > Toggle Normalize
+# script-binding afilter/toggle-dnm #! Audio > Toggle Normalizer
+# script-binding afilter/toggle-drc #! Audio > Toggle Compressor
+# script-binding uosc/audio #! Audio > Select Audio Track
+
+y script-binding uosc/load-subtitles #! Subtitles > Load
+Y script-binding uosc/subtitles #! Subtitles > Select
+ALT+j add sub-scale +0.05 #! Subtitles > Bigger
+ALT+k add sub-scale -0.05 #! Subtitles > Smaller
+z add sub-delay -0.1 #! Subtitles > Decrease Sub Delay
+Z add sub-delay 0.1 #! Subtitles > Increase Sub Delay
+
+/ script-binding console/enable #! Utilities > Console
+# script-binding uosc/open-config-directory #! Utilities > Settings
diff --git a/dotfiles/.config/mpv/mpv.conf b/dotfiles/.config/mpv/mpv.conf
new file mode 100644
index 0000000..1b8442f
--- /dev/null
+++ b/dotfiles/.config/mpv/mpv.conf
@@ -0,0 +1,83 @@
+### Profile ###
+
+include="~~/profiles.conf"
+
+### Video ###
+
+vo=gpu-next
+gpu-api=auto
+gpu-context=auto
+hwdec=auto
+profile=high-quality
+
+deband=no
+deband-iterations=1
+deband-threshold=48
+deband-range=16
+deband-grain=32
+
+temporal-dither=yes
+
+### Audio and Subtitles ###
+
+slang=en,eng,English
+alang=ja,jp,jpn,jap,Japanese,en,eng,English
+
+#sub-blur=0.5
+#sub-scale=0.7
+#sub-margin-y=60
+#sub-color='#d6ffffff'
+#sub-shadow-offset=5.0
+#sub-back-color='#00000000'
+#sub-shadow-color='#00000000'
+#sub-outline-color='#266a678c'
+
+sub-auto=all
+volume-max=150
+sub-fix-timing=yes
+audio-channels=auto
+blend-subtitles=yes
+sub-ass-override=yes
+audio-file-auto=fuzzy
+audio-pitch-correction=yes
+audio-normalize-downmix=yes
+sub-file-paths-append=subtitles
+demuxer-mkv-subtitle-preroll=yes
+sub-file-paths=sub;subs;subtitles
+af=lavfi=[dynaudnorm=g=3:f=250:r=0.9:p=0.9:m=10]
+
+## Audio Filters to Test ##
+
+#lavfi=[loudnorm=i=-10]
+#lavfi=[loudnorm=i=-20]
+#af=speechnorm=e=4:p=0.4
+#af='lavfi=[dynaudnorm=f=200:g=5:r=0.1]'
+#af=lavfi=[loudnorm=I=-16:TP=-2:LRA=11]
+
+# boost speech volume
+#af=@normalize:speechnorm=e=10:r=0.0005:l=1
+# loudnorm works well too, but uses more CPU
+#af=@normalize:loudnorm=I=-10
+
+#af-toggle=@loudnorm:lavfi=[loudnorm=I=-16:TP=-3:LRA=4]
+#af-toggle=@dynaudnorm:lavfi=[dynaudnorm=g=5:f=250:r=0.9:p=0.5]
+
+#af-toggle=@loudnorm:!loudnorm=I=-25:TP=-1.5:LRA=1:linear=false
+#af-toggle=@dynaudnorm:!dynaudnorm=f=500:g=17:r=0.1
+#af-toggle=format:srate=48000
+
+### General ###
+
+osc=no
+fullscreen=no
+snap-window
+keep-open=yes
+save-position-on-quit=no
+watch-later-dir="~~/cache/watch_later"
+
+### OSD ###
+
+border=no
+osd-bar=no
+osd-bold=yes
+osd-font-size=36
diff --git a/dotfiles/.config/mpv/profiles.conf b/dotfiles/.config/mpv/profiles.conf
new file mode 100644
index 0000000..a4a40ca
--- /dev/null
+++ b/dotfiles/.config/mpv/profiles.conf
@@ -0,0 +1,93 @@
+### Profiles ###
+
+[Deband-Medium]
+deband-iterations=2
+deband-threshold=64
+deband-range=16
+deband-grain=24
+
+[Deband-Strong]
+deband-iterations=3
+deband-threshold=64
+deband-range=16
+deband-grain=24
+
+### Conditional Profiles ###
+
+#[HDR]
+#profile-desc=HDR
+#profile-cond=hdr_metadata or (video-params/primaries == "bt.2020" and video-params/gamma == "pq")
+#target-trc=pq
+#target-peak=1000
+#d3d11-output-csp=pq
+#target-prim=bt.2020
+#hdr-compute-peak=yes
+#target-contrast=auto
+#video-output-levels=full
+#target-colorspace-hint=yes
+#d3d11-output-format=rgba32f
+
+[SDR-Gamut]
+#profile-desc=gamut mapping for SDR content
+#profile-cond=get("video-params/primaries") ~= "bt.709" and get("video-params/gamma") == "bt.1886"
+#profile-restore=copy
+#gamut-mapping-mode=clip
+
+[HDR -> SDR]
+#profile-desc=HDR ->SDR mapping parameters
+#tone-mapping=auto
+#tone-mapping-param=1.0
+#tone-mapping-max-boost=1.0
+#gamut-mapping-mode=auto
+#hdr-contrast-recovery=0.30
+#hdr-contrast-smoothness=3.5
+#hdr-compute-peak=auto
+#hdr-peak-percentile=99.995
+#hdr-peak-decay-rate=20
+#hdr-scene-threshold-low=1.0
+#hdr-scene-threshold-high=3.0
+#allow-delayed-peak-detect=no
+
+[SDR -> HDR]
+#profile-desc=SDR -> HDR mapping parameters
+
+linear-downscaling=no
+
+[Downmix-Audio-5.1]
+profile-cond=get("audio-params/channel-count") >= 5 and get("audio-params/channel-count") < 7
+profile-restore=copy-equal
+volume-max=200
+af=lavfi="lowpass=c=LFE:f=120,volume=1.6,pan=stereo|FL=0.5*FC+0.707*FL+0.707*BL+0.5*LFE|FR=0.5*FC+0.707*FR+0.707*BR+0.5*LFE"
+
+[Downmix-Audio-7.1]
+profile-cond=get("audio-params/channel-count") >= 7
+profile-restore=copy-equal
+volume-max=200
+af=lavfi="lowpass=c=LFE:f=120,volume=1.6,pan=stereo|FL=0.5*FC+0.3*FLC+0.3*FL+0.3*BL+0.3*SL+0.5*LFE|FR=0.5*FC+0.3*FRC+0.3*FR+0.3*BR+0.3*SR+0.5*LFE"
+
+[audio-filter]
+profile-desc=Enable the dynamic range adjustment filter when the audio channel is greater than 2, suitable for dual-channel devices
+profile-cond=get("audio-params/channel-count") > 2
+profile-restore=copy-equal
+af-pre=@dynaudnorm:lavfi=[dynaudnorm=g=5:f=250:r=0.9:p=0.5]
+
+## General Anime Profile (Applies to any video in a folder called 'Anime') ##
+
+[Anime]
+profile-cond=require 'mp.utils'.join_path(working_directory, path):match('\\Anime\\')
+profile-restore=copy-equal
+
+deband=yes
+deband-iterations=2
+deband-threshold=35
+deband-range=20
+deband-grain=5
+
+sub-scale=0.75
+
+## Hides unwanted webtorrent entries in memo script ##
+
+[Webtorrent-Entries]
+profile-cond=string.match(string.lower(string.gsub(require "mp.utils".join_path(get("working-directory", ""), get("path", "")), string.gsub(get("filename", ""), "([^%w])", "%%%1").."$", "")), "webtorrent")
+profile-restore=copy-equal
+script-opts-append=memo-enabled=no
diff --git a/dotfiles/.config/mpv/script-opts/console.conf b/dotfiles/.config/mpv/script-opts/console.conf
new file mode 100644
index 0000000..c6e6e56
--- /dev/null
+++ b/dotfiles/.config/mpv/script-opts/console.conf
@@ -0,0 +1 @@
+font=JetBrains Mono
\ No newline at end of file
diff --git a/dotfiles/.config/mpv/script-opts/evafast.conf b/dotfiles/.config/mpv/script-opts/evafast.conf
new file mode 100644
index 0000000..cfa72da
--- /dev/null
+++ b/dotfiles/.config/mpv/script-opts/evafast.conf
@@ -0,0 +1,31 @@
+# How far to jump on press
+seek_distance=5
+
+# Playback speed modifier, applied once every speed_interval until cap is reached
+speed_increase=0.1
+speed_decrease=0.1
+
+# At what interval to apply speed modifiers
+speed_interval=0.05
+
+# Playback speed cap
+speed_cap=2
+
+# Playback speed cap when subtitles are displayed, 'no' for same as speed_cap
+subs_speed_cap=1.8
+
+# Multiply current speed by modifier before adjustment (exponential speedup)
+# Use much lower values than default e.g. speed_increase=0.05, speed_decrease=0.025
+multiply_modifier=no
+
+# Show current speed on the osd (or flash speed if using uosc)
+show_speed=yes
+
+# Show current speed on the osd when toggled (or flash speed if using uosc)
+show_speed_toggled=yes
+
+# Show seek actions on the osd (or flash timeline if using uosc)
+show_seek=yes
+
+# Look ahead for smoother transition when subs_speed_cap is set
+lookahead=no
\ No newline at end of file
diff --git a/dotfiles/.config/mpv/script-opts/memo.conf b/dotfiles/.config/mpv/script-opts/memo.conf
new file mode 100644
index 0000000..d1b9b55
--- /dev/null
+++ b/dotfiles/.config/mpv/script-opts/memo.conf
@@ -0,0 +1,52 @@
+# File path gets expanded, leave empty for in-memory history
+# history_path=~/script-opts/memo-history.log
+
+# How many entries to display in menu
+entries=10
+
+# Display navigation to older/newer entries
+pagination=yes
+
+# Display files only once
+hide_duplicates=yes
+
+# Check if files still exist
+hide_deleted=yes
+
+# Display only the latest file from each directory
+hide_same_dir=no
+
+# Date format https://www.lua.org/pil/22.1.html
+timestamp_format=
+
+# Display titles instead of filenames when available
+use_titles=yes
+
+# Truncate titles to n characters, 0 to disable
+truncate_titles=60
+
+# Meant for use in auto profiles
+enabled=yes
+
+# Keybinds for vanilla menu
+up_binding=UP WHEEL_UP
+down_binding=DOWN WHEEL_DOWN
+select_binding=RIGHT ENTER
+append_binding=Shift+RIGHT Shift+ENTER
+close_binding=LEFT ESC
+
+# Path prefixes for the recent directory menu
+# This can be used to restrict the parent directory relative to which the
+# directories are shown.
+# Syntax
+# Prefixes are separated by | and can use Lua patterns by prefixing
+# them with "pattern:", otherwise they will be treated as plain text.
+# Pattern syntax can be found here https://www.lua.org/manual/5.1/manual.html#5.4.1
+# Example
+# "path_prefixes=My-Movies|pattern:TV Shows/.-/|Anime" will show directories
+# that are direct subdirectories of directories named "My-Movies" as well as
+# "Anime", while for TV Shows the shown directories are one level below that.
+# Opening the file "/data/TV Shows/Comedy/Curb Your Enthusiasm/S4/E06.mkv" will
+# lead to "Curb Your Enthusiasm" to be shown in the directory menu. Opening
+# of that entry will then open that file again.
+path_prefixes=pattern:.*
diff --git a/dotfiles/.config/mpv/script-opts/thumbfast.conf b/dotfiles/.config/mpv/script-opts/thumbfast.conf
new file mode 100644
index 0000000..cda5462
--- /dev/null
+++ b/dotfiles/.config/mpv/script-opts/thumbfast.conf
@@ -0,0 +1,41 @@
+# Socket path (leave empty for auto)
+socket=
+
+# Thumbnail path (leave empty for auto)
+thumbnail=
+
+# Maximum thumbnail generation size in pixels (scaled down to fit)
+# Values are scaled when hidpi is enabled
+max_height=200
+max_width=200
+
+# Scale factor for thumbnail display size (requires mpv 0.38+)
+# Note that this is lower quality than increasing max_height and max_width
+scale_factor=1
+
+# Apply tone-mapping, no to disable
+tone_mapping=yes
+
+# Overlay id
+overlay_id=42
+
+# Spawn thumbnailer on file load for faster initial thumbnails
+spawn_first=yes
+
+# Close thumbnailer process after an inactivity period in seconds, 0 to disable
+quit_after_inactivity=10
+
+# Enable on network playback
+network=yes
+
+# Enable on audio playback
+audio=no
+
+# Enable hardware decoding
+hwdec=yes
+
+# Windows only: use native Windows API to write to pipe (requires LuaJIT)
+direct_io=no
+
+# Custom path to the mpv executable
+mpv_path=mpv
diff --git a/dotfiles/.config/mpv/script-opts/uosc.conf b/dotfiles/.config/mpv/script-opts/uosc.conf
new file mode 100644
index 0000000..1683d18
--- /dev/null
+++ b/dotfiles/.config/mpv/script-opts/uosc.conf
@@ -0,0 +1,245 @@
+# Display style of current position. available: line, bar
+timeline_style=line
+# Line display style config
+timeline_line_width=2
+# Timeline size when fully expanded, in pixels, 0 to disable
+timeline_size=25
+# Comma separated states when element should always be fully visible.
+# Available: paused, audio, image, video, idle, windowed, fullscreen
+timeline_persistency=paused
+# Top border of background color to help visually separate timeline from video
+timeline_border=1
+# When scrolling above timeline, wheel will seek by this amount of seconds.
+# Default uses fast seeking. Add `!` suffix to enable exact seeks. Example: `5!`
+timeline_step=5
+# Render cache indicators for streaming content
+timeline_cache=yes
+
+# When to display an always visible progress bar (minimized timeline). Can be: windowed, fullscreen, always, never
+# Can also be toggled on demand with `toggle-progress` command.
+progress=windowed
+progress_size=2
+progress_line_width=20
+
+# A comma delimited list of controls above the timeline. Set to `never` to disable.
+# Parameter spec: enclosed in `{}` means value, enclosed in `[]` means optional
+# Full item syntax: `[<[!]{disposition1}[,[!]{dispositionN}]>]{element}[:{paramN}][#{badge}[>{limit}]][?{tooltip}]`
+# Common properties:
+# `{icon}` - parameter used to specify an icon name (example: `face`)
+# - pick here: https://fonts.google.com/icons?icon.platform=web&icon.set=Material+Icons&icon.style=Rounded
+# `{element}`s and their parameters:
+# `{shorthand}` - preconfigured shorthands:
+# `play-pause`, `menu`, `subtitles`, `audio`, `video`, `playlist`,
+# `chapters`, `editions`, `stream-quality`, `open-file`, `items`,
+# `next`, `prev`, `first`, `last`, `audio-device`, `fullscreen`,
+# `loop-playlist`, `loop-file`, `shuffle`, `autoload`
+# `speed[:{scale}]` - display speed slider, [{scale}] - factor of controls_size, default: 1.3
+# `command:{icon}:{command}` - button that executes a {command} when pressed
+# `toggle:{icon}:{prop}[@{owner}]` - button that toggles mpv property. shorthand for yes/no cycle below
+# `cycle:{default_icon}:{prop}[@{owner}]:{value1}[={icon1}][!]/{valueN}[={iconN}][!]`
+# - button that cycles mpv property between values, each optionally having different icon and active flag
+# - presence of `!` at the end will style the button as active
+# - `{owner}` is the name of a script that manages this property if any. Set to `uosc` to tap into uosc options.
+# `gap[:{scale}]` - display an empty gap
+# {scale} - factor of controls_size, default: 0.3
+# `space` - fills all available space between previous and next item, useful to align items to the right
+# - multiple spaces divide the available space among themselves, which can be used for centering
+# `button:{name}` - button whose state, look, and click action are managed by external script
+# Item visibility control:
+# `<[!]{disposition1}[,[!]{dispositionN}]>` - optional prefix to control element's visibility
+# - `{disposition}` can be one of:
+# - `idle` - true if mpv is in idle mode (no file loaded)
+# - `image` - true if current file is a single image
+# - `audio` - true for audio only files
+# - `video` - true for files with a video track
+# - `has_many_video` - true for files with more than one video track
+# - `has_image` - true for files with a cover or other image track
+# - `has_audio` - true for files with an audio track
+# - `has_many_audio` - true for files with more than one audio track
+# - `has_sub` - true for files with an subtitle track
+# - `has_many_sub` - true for files with more than one subtitle track
+# - `has_many_edition` - true for files with more than one edition
+# - `has_chapter` - true for files with chapter list
+# - `stream` - true if current file is read from a stream
+# - `has_playlist` - true if current playlist has 2 or more items in it
+# - prefix with `!` to negate the required disposition
+# Examples:
+# - `stream-quality` - show stream quality button only for streams
+# - `audio` - show audio tracks button for all files that have
+# an audio track, but are not exclusively audio only files
+# Place `#{badge}[>{limit}]` after the element params to give it a badge. Available badges:
+# `sub`, `audio`, `video` - track type counters
+# `{mpv_prop}` - any mpv prop that makes sense to you: https://mpv.io/manual/master/#property-list
+# - if prop value is an array it'll display its size
+# `>{limit}` will display the badge only if it's numerical value is above this threshold.
+# Example: `#audio>1`
+# Place `?{tooltip}` after the element config to give it a tooltip.
+# Example implementations:
+# menu = command:menu:script-binding uosc/menu-blurred?Menu
+# subtitles = command:subtitles:script-binding uosc/subtitles#sub?Subtitles
+# fullscreen = cycle:crop_free:fullscreen:no/yes=fullscreen_exit!?Fullscreen
+# loop-playlist = cycle:repeat:loop-playlist:no/inf!?Loop playlist
+# toggle:{icon}:{prop} = cycle:{icon}:{prop}:no/yes!
+controls=menu,gap,subtitles,audio,video,chapters,editions,stream-quality,gap,space,speed,space,gap,command:history:script-binding memo-history?
+controls_size=35
+controls_margin=8
+controls_spacing=2
+controls_persistency=
+
+# Where to display volume controls: none, left, right
+volume=right
+volume_size=39
+volume_border=1
+volume_step=1
+volume_persistency=
+
+# Playback speed widget: mouse drag or wheel to change, click to reset
+speed_step=0.05
+speed_step_is_factor=no
+speed_persistency=
+
+# Controls all menus, such as context menu, subtitle loader/selector, etc
+menu_item_height=35
+menu_min_width=290
+menu_padding=4
+# Determines if `/` or `ctrl+f` is required to activate the search, or if typing
+# any text is sufficient.
+# When enabled, you can no longer toggle a menu off with the same key that opened it, if the key is a unicode character.
+menu_type_to_search=yes
+
+# Top bar with window controls and media title
+# Can be: never, no-border, always
+top_bar=no-border
+top_bar_size=45
+# Can be: `no` (hide), left or right
+top_bar_controls=right
+# Can be: `no` (hide), `yes` (inherit title from mpv.conf), or a custom template string
+top_bar_title=yes
+# Template string to enable alternative top bar title. If alt title matches main title,
+# it'll be hidden. Tip: use `${media-title}` for main, and `${filename}` for alt title.
+top_bar_alt_title=${filename}
+# Can be:
+# `below` => display alt title below the main one
+# `toggle` => toggle the top bar title text between main and alt by clicking
+# the top bar, or calling `toggle-title` binding
+top_bar_alt_title_place=toggle
+# Flash top bar when any of these file types is loaded. Available: audio,video,image,chapter
+top_bar_flash_on=video,audio
+top_bar_persistency=
+
+# Window border drawn in no-border mode
+window_border_size=1
+
+# If there's no playlist and file ends, load next file in directory
+# Uses `load_types` config below to determine what type of file to load next.
+# When enabled, usoc will set mpv config `keep-open` to `yes`, and `keep-open-pause` to `no`.
+autoload=no
+# Enable uosc's playlist/directory shuffle mode
+# This simply makes the next selected playlist or directory item be random, just
+# like any other player in the world. It also has an easily togglable control button.
+shuffle=no
+
+# Scale the interface by this factor
+scale=1
+# Scale in fullscreen
+scale_fullscreen=1
+# Adjust the text scaling to fit your font
+font_scale=1.18
+# Border of text and icons when drawn directly on top of video
+text_border=1.2
+# Border radius of buttons, menus, and all other rectangles
+border_radius=2
+# A comma delimited list of color overrides in RGB HEX format. Defaults:
+# foreground=ffffff,foreground_text=000000,background=000000,background_text=ffffff,curtain=111111,success=a5e075,error=ff616e
+color=
+# A comma delimited list of opacity overrides for various UI element backgrounds and shapes.
+# This does not affect any text, which is always rendered fully opaque. Defaults:
+# timeline=0.9,position=1,chapters=0.8,slider=0.9,slider_gauge=1,controls=0,speed=0.6,menu=1,submenu=0.4,border=1,title=1,tooltip=1,thumbnail=1,curtain=0.8,idle_indicator=0.8,audio_indicator=0.5,buffering_indicator=0.3,playlist_position=0.8
+opacity=timeline=0.8,speed=0,menu=0.84,title=0,tooltip=0.8,curtain=0.13
+# A comma delimited list of features to refine at a cost of some performance impact.
+# text_width - Use a more accurate text width measurement that measures each text string individually
+# instead of just measuring the width of known letters once and adding them up.
+# sorting - Use filename sorting that handles non-english languages better, especially asian ones.
+# At the moment, this is only available on windows, and has no effect on other platforms.
+refine=text_width
+# Duration of animations in milliseconds
+animation_duration=100
+# Execute command for background clicks shorter than this number of milliseconds, 0 to disable
+# Execution always waits for `input-doubleclick-time` to filter out double-clicks
+click_threshold=0
+click_command=cycle pause; script-binding uosc/flash-pause-indicator
+# Flash duration in milliseconds used by `flash-{element}` commands
+flash_duration=1000
+# Distances in pixels below which elements are fully faded in/out
+proximity_in=40
+proximity_out=120
+# Use only bold font weight throughout the whole UI
+font_bold=yes
+# One of `total`, `playtime-remaining` (scaled by the current speed), `time-remaining` (remaining length of file)
+destination_time=total
+# Display sub second fraction in timestamps up to this precision
+time_precision=0
+# Display stream's buffered time in timeline if it's lower than this amount of seconds, 0 to disable
+buffered_time_threshold=60
+# Hide UI when mpv autohides the cursor. Timing is controlled by `cursor-autohide` in `mpv.conf` (in milliseconds).
+autohide=no
+# Can be: flash, static, manual (controlled by flash-pause-indicator and decide-pause-indicator commands)
+pause_indicator=flash
+# Sizes to list in stream quality menu
+stream_quality_options=4320,2160,1440,1080,720,480,360,240,144
+# Types to identify media files
+video_types=3g2,3gp,asf,avi,f4v,flv,h264,h265,m2ts,m4v,mkv,mov,mp4,mp4v,mpeg,mpg,ogm,ogv,rm,rmvb,ts,vob,webm,wmv,y4m
+audio_types=aac,ac3,aiff,ape,au,cue,dsf,dts,flac,m4a,mid,midi,mka,mp3,mp4a,oga,ogg,opus,spx,tak,tta,wav,weba,wma,wv
+image_types=apng,avif,bmp,gif,j2k,jp2,jfif,jpeg,jpg,jxl,mj2,png,svg,tga,tif,tiff,webp
+subtitle_types=aqt,ass,gsub,idx,jss,lrc,mks,pgs,pjs,psb,rt,sbv,slt,smi,sub,sup,srt,ssa,ssf,ttxt,txt,usf,vt,vtt
+playlist_types=m3u,m3u8,pls,url,cue
+# Type pools used by file navigation and `autoload` to determine what type of file to load next
+# Available: video,audio,image,playlist,same. `same` means the same type pool (not just extension) as currently open file.
+load_types=video,audio,image
+# Default open-file menu directory. Use `{drives}` to open drives menu on windows (defaults to `/` on unix).
+default_directory=~/
+# List hidden files when reading directories. Due to environment limitations, this currently only hides
+# files starting with a dot. Doesn't hide hidden files on windows (we have no way to tell they're hidden).
+show_hidden_files=no
+# Move files to trash (recycle bin) when deleting files. Dependencies:
+# - Linux: `sudo apt install trash-cli`
+# - MacOS: `brew install trash`
+use_trash=no
+# Adjusted osd margins based on the visibility of UI elements
+adjust_osd_margins=yes
+
+# Adds chapter range indicators to some common chapter types.
+# Additionally to displaying the start of the chapter as a diamond icon on top of the timeline,
+# the portion of the timeline of that chapter range is also colored based on the config below.
+#
+# The syntax is a comma-delimited list of `{type}:{color}` pairs, where:
+# `{type}` => range type. Currently supported ones are:
+# - `openings`, `endings` => anime openings/endings
+# - `intros`, `outros` => video intros/outros
+# - `ads` => segments created by sponsor-block software like https://github.com/po5/mpv_sponsorblock
+# `{color}` => an RGB(A) HEX color code (`rrggbb`, or `rrggbbaa`)
+#
+# To exclude marking any of the range types, simply remove them from the list.
+chapter_ranges=openings:30abf964,endings:30abf964,ads:c54e4e80
+# Add alternative lua patterns to identify beginnings of simple chapter ranges (except for `ads`)
+# Syntax: `{type}:{pattern}[,{patternN}][;{type}:{pattern}[,{patternN}]]`
+chapter_range_patterns=openings:オープニング;endings:エンディング
+
+# Localization language priority from highest to lowest.
+# Also controls what languages are fetched by `download-subtitles` menu.
+# Built in languages can be found in `uosc/intl`.
+# `slang` is a keyword to inherit values from `--slang` mpv config.
+# Supports paths to custom json files: `languages=~~/custom.json,slang,en`
+languages=slang,en
+# By default, subtitles are downloaded into the directory of currently opened file.
+# If the file is being played from a URL, we use this directory instead (expands to `{mpv_config_dir}/subtitles`)
+# Prefix the path with `!` to force all subtitles to be saved there. Example: `!~~/subtitles`
+subtitles_directory=~~/subtitles
+
+# A comma separated list of element IDs to disable. Available IDs:
+# window_border, top_bar, timeline, controls, volume,
+# idle_indicator, audio_indicator, buffering_indicator, pause_indicator
+disable_elements=
+
+# List of mpv.conf properties respected by uosc:
+# osd-font, osd-playlist-entry, slang
\ No newline at end of file
diff --git a/dotfiles/.config/mpv/script-opts/webtorrent.conf b/dotfiles/.config/mpv/script-opts/webtorrent.conf
new file mode 100644
index 0000000..6e36701
--- /dev/null
+++ b/dotfiles/.config/mpv/script-opts/webtorrent.conf
@@ -0,0 +1,22 @@
+# Path to save downloaded files in. Can be set to "memory" to store all files in RAM.
+path=~/
+# Maximum number of connections.
+maxConns=100
+# Port to use for webtorrent web-server.
+# If it's already in use a random port will be chosen instead.
+port=8888
+# Enable μTP support.
+utp=yes
+# Enable DHT.
+dht=yes
+# Enable local service discovery.
+lsd=yes
+# Download speed limit in bytes/sec.
+downloadLimit=-1
+# Upload speed limit in bytes/sec.
+uploadLimit=-1
+# Specify the node command to use.
+# Usefull if the command is called nodejs on your system.
+node_path=node
+
+# The same text style options as in stats.conf is also available.
\ No newline at end of file
diff --git a/dotfiles/.config/mpv/scripts/autodeint.lua b/dotfiles/.config/mpv/scripts/autodeint.lua
new file mode 100644
index 0000000..df938f8
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/autodeint.lua
@@ -0,0 +1,157 @@
+-- This script uses the lavfi idet filter to automatically insert the
+-- appropriate deinterlacing filter based on a short section of the
+-- currently playing video.
+--
+-- It registers the key-binding ctrl+d, which when pressed, inserts the filters
+-- ``vf=idet,lavfi-pullup,idet``. After 4 seconds, it removes these
+-- filters and decides whether the content is progressive, interlaced, or
+-- telecined and the interlacing field dominance.
+--
+-- Based on this information, it may set mpv's ``deinterlace`` property (which
+-- usually inserts the bwdif filter), or insert the ``pullup`` filter if the
+-- content is telecined. It also sets field dominance with lavfi setfield.
+--
+-- OPTIONS:
+-- The default detection time may be overridden by adding
+--
+-- --script-opts=autodeint.detect_seconds=
+--
+-- to mpv's arguments. This may be desirable to allow idet more
+-- time to collect data.
+--
+-- To see counts of the various types of frames for each detection phase,
+-- the verbosity can be increased with
+--
+-- --msg-level=autodeint=v
+
+require "mp.msg"
+
+local script_name = mp.get_script_name()
+local detect_label = string.format("%s-detect", script_name)
+local pullup_label = string.format("%s", script_name)
+local dominance_label = string.format("%s-dominance", script_name)
+local ivtc_detect_label = string.format("%s-ivtc-detect", script_name)
+local timer
+local progressive, interlaced_tff, interlaced_bff, interlaced = 0, 1, 2, 3
+
+-- number of seconds to gather cropdetect data
+local detect_seconds = tonumber(mp.get_opt(string.format("%s.detect_seconds", script_name)))
+if not detect_seconds then
+ detect_seconds = 4
+end
+
+local function del_filter_if_present(label)
+ -- necessary because mp.command('vf del @label:filter') raises an
+ -- error if the filter doesn't exist
+ local vfs = mp.get_property_native("vf")
+
+ for i,vf in pairs(vfs) do
+ if vf["label"] == label then
+ table.remove(vfs, i)
+ mp.set_property_native("vf", vfs)
+ return true
+ end
+ end
+ return false
+end
+
+local function add_vf(label, filter)
+ return mp.command(('vf add @%s:%s'):format(label, filter))
+end
+
+local function stop_detect()
+ del_filter_if_present(detect_label)
+ del_filter_if_present(ivtc_detect_label)
+ timer = nil
+end
+
+local function judge(label)
+ -- get the metadata
+ local result = mp.get_property_native(string.format("vf-metadata/%s", label))
+ local num_tff = tonumber(result["lavfi.idet.multiple.tff"])
+ local num_bff = tonumber(result["lavfi.idet.multiple.bff"])
+ local num_progressive = tonumber(result["lavfi.idet.multiple.progressive"])
+ local num_undetermined = tonumber(result["lavfi.idet.multiple.undetermined"])
+ local num_interlaced = num_tff + num_bff
+ local num_determined = num_interlaced + num_progressive
+
+ mp.msg.verbose(label.." progressive = "..num_progressive)
+ mp.msg.verbose(label.." interlaced-tff = "..num_tff)
+ mp.msg.verbose(label.." interlaced-bff = "..num_bff)
+ mp.msg.verbose(label.." undetermined = "..num_undetermined)
+
+ if num_determined < num_undetermined then
+ mp.msg.warn("majority undetermined frames")
+ end
+ if num_progressive > 20*num_interlaced then
+ return progressive
+ elseif num_tff > 10*num_bff then
+ return interlaced_tff
+ elseif num_bff > 10*num_tff then
+ return interlaced_bff
+ else
+ return interlaced
+ end
+end
+
+local function select_filter()
+ -- handle the first detection filter results
+ local verdict = judge(detect_label)
+ local ivtc_verdict = judge(ivtc_detect_label)
+ local dominance = "auto"
+ if verdict == progressive then
+ mp.msg.info("progressive: doing nothing")
+ stop_detect()
+ del_filter_if_present(dominance_label)
+ del_filter_if_present(pullup_label)
+ return
+ else
+ if verdict == interlaced_tff then
+ dominance = "tff"
+ add_vf(dominance_label, 'setfield=mode='..dominance)
+ elseif verdict == interlaced_bff then
+ dominance = "bff"
+ add_vf(dominance_label, 'setfield=mode='..dominance)
+ else
+ del_filter_if_present(dominance_label)
+ end
+ end
+
+ -- handle the ivtc detection filter results
+ if ivtc_verdict == progressive then
+ mp.msg.info(string.format("telecined with %s field dominance: using pullup", dominance))
+ stop_detect()
+ else
+ mp.msg.info("interlaced with " .. dominance ..
+ " field dominance: setting deinterlace property")
+ del_filter_if_present(pullup_label)
+ mp.set_property("deinterlace","yes")
+ stop_detect()
+ end
+end
+
+local function start_detect()
+ -- exit if detection is already in progress
+ if timer then
+ mp.msg.warn("already detecting!")
+ return
+ end
+
+ mp.set_property("deinterlace","no")
+ del_filter_if_present(pullup_label)
+ del_filter_if_present(dominance_label)
+
+ -- insert the detection filters
+ if not (add_vf(detect_label, 'idet') and
+ add_vf(dominance_label, 'setfield=mode=auto') and
+ add_vf(pullup_label, 'lavfi-pullup') and
+ add_vf(ivtc_detect_label, 'idet')) then
+ mp.msg.error("failed to insert detection filters")
+ return
+ end
+
+ -- wait to gather data
+ timer = mp.add_timeout(detect_seconds, select_filter)
+end
+
+mp.add_key_binding("ctrl+d", script_name, start_detect)
diff --git a/dotfiles/.config/mpv/scripts/autoload.lua b/dotfiles/.config/mpv/scripts/autoload.lua
new file mode 100644
index 0000000..75aec10
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/autoload.lua
@@ -0,0 +1,415 @@
+-- This script automatically loads playlist entries before and after the
+-- currently played file. It does so by scanning the directory a file is
+-- located in when starting playback. It sorts the directory entries
+-- alphabetically, and adds entries before and after the current file to
+-- the internal playlist. (It stops if it would add an already existing
+-- playlist entry at the same position - this makes it "stable".)
+-- Add at most 5000 * 2 files when starting a file (before + after).
+
+--[[
+To configure this script use file autoload.conf in directory script-opts (the "script-opts"
+directory must be in the mpv configuration directory, typically ~/.config/mpv/).
+
+Option `ignore_patterns` is a comma-separated list of patterns (see lua.org/pil/20.2.html).
+Additionally to the standard lua patterns, you can also escape commas with `%`,
+for example, the option `bak%,x%,,another` will be resolved as patterns `bak,x,` and `another`.
+But it does not mean you need to escape all lua patterns twice,
+so the option `bak%%,%.mp4,` will be resolved as two patterns `bak%%` and `%.mp4`.
+
+Example configuration would be:
+
+disabled=no
+images=no
+videos=yes
+audio=yes
+additional_image_exts=list,of,ext
+additional_video_exts=list,of,ext
+additional_audio_exts=list,of,ext
+ignore_hidden=yes
+same_type=yes
+directory_mode=recursive
+ignore_patterns=^~,^bak-,%.bak$
+
+--]]
+
+local MAX_ENTRIES = 5000
+local MAX_DIR_STACK = 20
+
+local msg = require 'mp.msg'
+local options = require 'mp.options'
+local utils = require 'mp.utils'
+
+local o = {
+ disabled = false,
+ images = true,
+ videos = true,
+ audio = true,
+ additional_image_exts = "",
+ additional_video_exts = "",
+ additional_audio_exts = "",
+ ignore_hidden = true,
+ same_type = false,
+ directory_mode = "auto",
+ ignore_patterns = ""
+}
+
+local function Set(t)
+ local set = {}
+ for _, v in pairs(t) do set[v] = true end
+ return set
+end
+
+local EXTENSIONS_VIDEO_DEFAULT = Set {
+ '3g2', '3gp', 'avi', 'flv', 'm2ts', 'm4v', 'mj2', 'mkv', 'mov',
+ 'mp4', 'mpeg', 'mpg', 'ogv', 'rmvb', 'webm', 'wmv', 'y4m'
+}
+
+local EXTENSIONS_AUDIO_DEFAULT = Set {
+ 'aiff', 'ape', 'au', 'flac', 'm4a', 'mka', 'mp3', 'oga', 'ogg',
+ 'ogm', 'opus', 'wav', 'wma'
+}
+
+local EXTENSIONS_IMAGES_DEFAULT = Set {
+ 'avif', 'bmp', 'gif', 'j2k', 'jp2', 'jpeg', 'jpg', 'jxl', 'png',
+ 'svg', 'tga', 'tif', 'tiff', 'webp'
+}
+
+local EXTENSIONS, EXTENSIONS_VIDEO, EXTENSIONS_AUDIO, EXTENSIONS_IMAGES
+
+local function SetUnion(a, b)
+ for k in pairs(b) do a[k] = true end
+ return a
+end
+
+-- Returns first and last positions in string or past-to-end indices
+local function FindOrPastTheEnd(string, pattern, start_at)
+ local pos1, pos2 = string:find(pattern, start_at)
+ return pos1 or #string + 1,
+ pos2 or #string + 1
+end
+
+local function Split(list)
+ local set = {}
+
+ local item_pos = 1
+ local item = ""
+
+ while item_pos <= #list do
+ local pos1, pos2 = FindOrPastTheEnd(list, "%%*,", item_pos)
+
+ local pattern_length = pos2 - pos1
+ local is_comma_escaped = pattern_length % 2
+
+ local pos_before_escape = pos1 - 1
+ local item_escape_count = pattern_length - is_comma_escaped
+
+ item = item .. string.sub(list, item_pos, pos_before_escape + item_escape_count)
+
+ if is_comma_escaped == 1 then
+ item = item .. ","
+ else
+ set[item] = true
+ item = ""
+ end
+
+ item_pos = pos2 + 1
+ end
+
+ set[item] = true
+
+ -- exclude empty items
+ set[""] = nil
+
+ return set
+end
+
+local function split_option_exts(video, audio, image)
+ if video then o.additional_video_exts = Split(o.additional_video_exts) end
+ if audio then o.additional_audio_exts = Split(o.additional_audio_exts) end
+ if image then o.additional_image_exts = Split(o.additional_image_exts) end
+end
+
+local function split_patterns()
+ o.ignore_patterns = Split(o.ignore_patterns)
+end
+
+local function create_extensions()
+ EXTENSIONS = {}
+ EXTENSIONS_VIDEO = {}
+ EXTENSIONS_AUDIO = {}
+ EXTENSIONS_IMAGES = {}
+ if o.videos then
+ SetUnion(SetUnion(EXTENSIONS_VIDEO, EXTENSIONS_VIDEO_DEFAULT), o.additional_video_exts)
+ SetUnion(EXTENSIONS, EXTENSIONS_VIDEO)
+ end
+ if o.audio then
+ SetUnion(SetUnion(EXTENSIONS_AUDIO, EXTENSIONS_AUDIO_DEFAULT), o.additional_audio_exts)
+ SetUnion(EXTENSIONS, EXTENSIONS_AUDIO)
+ end
+ if o.images then
+ SetUnion(SetUnion(EXTENSIONS_IMAGES, EXTENSIONS_IMAGES_DEFAULT), o.additional_image_exts)
+ SetUnion(EXTENSIONS, EXTENSIONS_IMAGES)
+ end
+end
+
+local function validate_directory_mode()
+ if o.directory_mode ~= "recursive" and o.directory_mode ~= "lazy"
+ and o.directory_mode ~= "ignore" then
+ o.directory_mode = nil
+ end
+end
+
+options.read_options(o, nil, function(list)
+ split_option_exts(list.additional_video_exts, list.additional_audio_exts,
+ list.additional_image_exts)
+ if list.videos or list.additional_video_exts or
+ list.audio or list.additional_audio_exts or
+ list.images or list.additional_image_exts then
+ create_extensions()
+ end
+ if list.directory_mode then
+ validate_directory_mode()
+ end
+ if list.ignore_patterns then
+ split_patterns()
+ end
+end)
+
+split_option_exts(true, true, true)
+split_patterns()
+create_extensions()
+validate_directory_mode()
+
+local function add_files(files)
+ local oldcount = mp.get_property_number("playlist-count", 1)
+ for i = 1, #files do
+ mp.commandv("loadfile", files[i][1], "append")
+ mp.commandv("playlist-move", oldcount + i - 1, files[i][2])
+ end
+end
+
+local function get_extension(path)
+ return path:match("%.([^%.]+)$") or "nomatch"
+end
+
+local function is_ignored(file)
+ for pattern in pairs(o.ignore_patterns) do
+ if file:match(pattern) then
+ return true
+ end
+ end
+ return false
+end
+
+-- alphanum sorting for humans in Lua
+-- http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua
+
+local function alphanumsort(filenames)
+ local function padnum(n, d)
+ return #d > 0 and ("%03d%s%.12f"):format(#n, n, tonumber(d) / (10 ^ #d))
+ or ("%03d%s"):format(#n, n)
+ end
+
+ local tuples = {}
+ for i, f in ipairs(filenames) do
+ tuples[i] = {f:lower():gsub("0*(%d+)%.?(%d*)", padnum), f}
+ end
+ table.sort(tuples, function(a, b)
+ return a[1] == b[1] and #b[2] < #a[2] or a[1] < b[1]
+ end)
+ for i, tuple in ipairs(tuples) do filenames[i] = tuple[2] end
+ return filenames
+end
+
+local autoloaded
+local added_entries = {}
+local autoloaded_dir
+
+local function scan_dir(path, current_file, dir_mode, separator, dir_depth, total_files, extensions)
+ if dir_depth == MAX_DIR_STACK then
+ return
+ end
+ msg.trace("scanning: " .. path)
+ local files = utils.readdir(path, "files") or {}
+ local dirs = dir_mode ~= "ignore" and utils.readdir(path, "dirs") or {}
+ local prefix = path == "." and "" or path
+
+ local function filter(t, iter)
+ for i = #t, 1, -1 do
+ if not iter(t[i]) then
+ table.remove(t, i)
+ end
+ end
+ end
+
+ filter(files, function(v)
+ -- Always accept current file
+ local current = prefix .. v == current_file
+ if current then
+ return true
+ end
+ if o.ignore_hidden and v:match("^%.") then
+ return false
+ end
+ if is_ignored(v) then
+ return false
+ end
+
+ local ext = get_extension(v)
+ return ext and extensions[ext:lower()]
+ end)
+ filter(dirs, function(d)
+ return not (o.ignore_hidden and d:match("^%."))
+ end)
+ alphanumsort(files)
+ alphanumsort(dirs)
+
+ for i, file in ipairs(files) do
+ files[i] = prefix .. file
+ end
+
+ local function append(t1, t2)
+ local t1_size = #t1
+ for i = 1, #t2 do
+ t1[t1_size + i] = t2[i]
+ end
+ end
+
+ append(total_files, files)
+ if dir_mode == "recursive" then
+ for _, dir in ipairs(dirs) do
+ scan_dir(prefix .. dir .. separator, current_file, dir_mode,
+ separator, dir_depth + 1, total_files, extensions)
+ end
+ else
+ for i, dir in ipairs(dirs) do
+ dirs[i] = prefix .. dir
+ end
+ append(total_files, dirs)
+ end
+end
+
+local function find_and_add_entries()
+ local aborted = mp.get_property_native("playback-abort")
+ if aborted then
+ msg.debug("stopping: playback aborted")
+ return
+ end
+
+ local path = mp.get_property("path", "")
+ local dir, filename = utils.split_path(path)
+ msg.trace(("dir: %s, filename: %s"):format(dir, filename))
+ if o.disabled then
+ msg.debug("stopping: autoload disabled")
+ return
+ elseif #dir == 0 then
+ msg.debug("stopping: not a local path")
+ return
+ end
+
+ local pl_count = mp.get_property_number("playlist-count", 1)
+ local this_ext = get_extension(filename)
+ -- check if this is a manually made playlist
+ if pl_count > 1 and autoloaded == nil then
+ msg.debug("stopping: manually made playlist")
+ return
+ elseif pl_count == 1 then
+ autoloaded = true
+ autoloaded_dir = dir
+ added_entries = {}
+ end
+
+ local extensions
+ if o.same_type then
+ if EXTENSIONS_VIDEO[this_ext:lower()] then
+ extensions = EXTENSIONS_VIDEO
+ elseif EXTENSIONS_AUDIO[this_ext:lower()] then
+ extensions = EXTENSIONS_AUDIO
+ elseif EXTENSIONS_IMAGES[this_ext:lower()] then
+ extensions = EXTENSIONS_IMAGES
+ end
+ else
+ extensions = EXTENSIONS
+ end
+ if not extensions then
+ msg.debug("stopping: no matched extensions list")
+ return
+ end
+
+ local pl = mp.get_property_native("playlist", {})
+ local pl_current = mp.get_property_number("playlist-pos-1", 1)
+ msg.trace(("playlist-pos-1: %s, playlist: %s"):format(pl_current,
+ utils.to_string(pl)))
+
+ local files = {}
+ scan_dir(autoloaded_dir, path,
+ o.directory_mode or mp.get_property("directory-mode", "lazy"),
+ mp.get_property_native("platform") == "windows" and "\\" or "/",
+ 0, files, extensions)
+
+ if next(files) == nil then
+ msg.debug("no other files or directories in directory")
+ return
+ end
+
+ -- Find the current pl entry (dir+"/"+filename) in the sorted dir list
+ local current
+ for i = 1, #files do
+ if files[i] == path then
+ current = i
+ break
+ end
+ end
+ if not current then
+ msg.debug("current file not found in directory")
+ return
+ end
+ msg.trace("current file position in files: "..current)
+
+ -- treat already existing playlist entries, independent of how they got added
+ -- as if they got added by autoload
+ for _, entry in ipairs(pl) do
+ added_entries[entry.filename] = true
+ end
+
+ local append = {[-1] = {}, [1] = {}}
+ for direction = -1, 1, 2 do -- 2 iterations, with direction = -1 and +1
+ for i = 1, MAX_ENTRIES do
+ local pos = current + i * direction
+ local file = files[pos]
+ if file == nil or file[1] == "." then
+ break
+ end
+
+ -- skip files that are/were already in the playlist
+ if not added_entries[file] then
+ if direction == -1 then
+ msg.verbose("Prepending " .. file)
+ table.insert(append[-1], 1, {file, pl_current + i * direction + 1})
+ else
+ msg.verbose("Adding " .. file)
+ if pl_count > 1 then
+ table.insert(append[1], {file, pl_current + i * direction - 1})
+ else
+ mp.commandv("loadfile", file, "append")
+ end
+ end
+ added_entries[file] = true
+ end
+ end
+ if pl_count == 1 and direction == -1 and #append[-1] > 0 then
+ local load = append[-1]
+ for i = 1, #load do
+ mp.commandv("loadfile", load[i][1], "append")
+ end
+ mp.commandv("playlist-move", 0, current)
+ end
+ end
+
+ if pl_count > 1 then
+ add_files(append[1])
+ add_files(append[-1])
+ end
+end
+
+mp.register_event("start-file", find_and_add_entries)
diff --git a/dotfiles/.config/mpv/scripts/evafast.lua b/dotfiles/.config/mpv/scripts/evafast.lua
new file mode 100644
index 0000000..b6ca7b2
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/evafast.lua
@@ -0,0 +1,313 @@
+-- evafast.lua
+--
+-- Much speed.
+--
+-- Jumps forwards when right arrow is pressed, speeds up when it's held.
+-- Inspired by bilibili.com's player. Allows you to have both seeking and fast-forwarding on the same key.
+-- Also supports toggling fastforward mode with a keypress.
+-- Adjust --input-ar-delay to define when to start fastforwarding.
+-- Define --hr-seek if you want accurate seeking.
+-- If you just want a nicer fastforward.lua without hybrid key behavior, set seek_distance to 0.
+
+local options = {
+ -- How far to jump on press, set to 0 to disable seeking and force fastforward
+ seek_distance = 5,
+
+ -- Playback speed modifier, applied once every speed_interval until cap is reached
+ speed_increase = 0.1,
+ speed_decrease = 0.1,
+
+ -- At what interval to apply speed modifiers
+ speed_interval = 0.05,
+
+ -- Playback speed cap
+ speed_cap = 2,
+
+ -- Playback speed cap when subtitles are displayed, 'no' for same as speed_cap
+ subs_speed_cap = 1.6,
+
+ -- Multiply current speed by modifier before adjustment (exponential speedup)
+ -- Use much lower values than default e.g. speed_increase=0.05, speed_decrease=0.025
+ multiply_modifier = false,
+
+ -- Show current speed on the osd (or flash speed if using uosc)
+ show_speed = true,
+
+ -- Show current speed on the osd when toggled (or flash speed if using uosc)
+ show_speed_toggled = true,
+
+ -- Show current speed on the osd when speeding up towards a target time (or flash speed if using uosc)
+ show_speed_target = false,
+
+ -- Show seek actions on the osd (or flash timeline if using uosc)
+ show_seek = true,
+
+ -- Look ahead for smoother transition when subs_speed_cap is set
+ lookahead = false
+}
+
+mp.options = require "mp.options"
+mp.options.read_options(options, "evafast")
+
+local uosc_available = false
+local repeated = false
+local speed_timer = nil
+local speedup = true
+local no_speedup = false
+local jumps_reset_speed = true
+local toggle_display = false
+local toggle_state = false
+local freeze = false
+
+local forced_speed_cap = nil
+local use_forced_speed_cap = false
+
+local speedup_target = nil
+
+local function speed_transition(test_speed, target)
+ local time_for_correction = 0
+ if not freeze then
+ while test_speed ~= target do
+ time_for_correction = time_for_correction + options.speed_interval
+ if test_speed <= target then
+ if options.multiply_modifier then
+ test_speed = math.min(test_speed + (test_speed * options.speed_increase), target)
+ else
+ test_speed = math.min(test_speed + options.speed_increase, target)
+ end
+ else
+ if options.multiply_modifier then
+ test_speed = math.max(test_speed - (test_speed * options.speed_decrease), 1)
+ else
+ test_speed = math.max(test_speed - options.speed_decrease, 1)
+ end
+ end
+ if test_speed == 1 then break end
+ end
+ end
+ return time_for_correction
+end
+
+local function adjust_speed()
+ local no_sub_speed = not options.subs_speed_cap or mp.get_property("sub-start") == nil
+ local effective_speed_cap = no_sub_speed and options.speed_cap or options.subs_speed_cap
+ local speed = mp.get_property_number("speed", 1)
+ local old_speed = speed
+
+ if options.lookahead and options.subs_speed_cap and no_sub_speed and not use_forced_speed_cap then
+ local sub_delay = mp.get_property_native("sub-delay")
+ local sub_visible = mp.get_property_bool("sub-visibility")
+ if sub_visible then
+ mp.set_property_bool("sub-visibility", false)
+ end
+ mp.command("no-osd sub-step 1")
+ local sub_next_delay = mp.get_property_native("sub-delay")
+ local sub_next = sub_delay - sub_next_delay
+ mp.set_property("sub-delay", sub_delay)
+ if sub_visible then
+ mp.set_property_bool("sub-visibility", sub_visible)
+ end
+ -- calculate how long it takes to get from current speed to target speed, and use that as threshold for sub_next
+ local time_for_correction = speed_transition(speed, options.subs_speed_cap)
+ if sub_next ~= 0 and sub_next <= (time_for_correction * speed) then
+ effective_speed_cap = options.subs_speed_cap
+ use_forced_speed_cap = true
+ forced_speed_cap = effective_speed_cap
+ end
+ end
+
+ if speedup_target ~= nil then
+ local current_time = mp.get_property_number("time-pos", 0)
+ if current_time >= speedup_target then
+ jumps_reset_speed = true
+ no_speedup = true
+ repeated = false
+ freeze = false
+ else
+ local time_for_correction = speed_transition(speed, math.max(math.min(options.speed_cap, options.subs_speed_cap and options.subs_speed_cap or options.speed_cap), 1.1)) -- not effective_speed_cap because it may lead to huge fluctuations in transition speed
+ if (time_for_correction * speed + current_time) > speedup_target then
+ effective_speed_cap = 1.1 -- >1 so we don't get stuck trying to catch the target
+ use_forced_speed_cap = true
+ forced_speed_cap = effective_speed_cap
+ else
+ forced_speed_cap = nil
+ use_forced_speed_cap = false
+ end
+ end
+ end
+
+ if not freeze then
+ if forced_speed_cap ~= nil then
+ if speed ~= forced_speed_cap or mp.get_property_bool("pause") then
+ use_forced_speed_cap = true
+ end
+ effective_speed_cap = forced_speed_cap
+ end
+ if speedup and not no_speedup and speed <= effective_speed_cap then
+ if options.multiply_modifier then
+ speed = math.min(speed + (speed * options.speed_increase), effective_speed_cap)
+ else
+ speed = math.min(speed + options.speed_increase, effective_speed_cap)
+ end
+ else
+ if options.multiply_modifier then
+ speed = math.max(speed - (speed * options.speed_decrease), 1)
+ else
+ speed = math.max(speed - options.speed_decrease, 1)
+ end
+ end
+ if forced_speed_cap ~= nil and not use_forced_speed_cap then
+ forced_speed_cap = nil
+ end
+ if speed == options.subs_speed_cap then
+ if use_forced_speed_cap then
+ use_forced_speed_cap = false
+ end
+ end
+ end
+
+ if speed ~= old_speed then
+ mp.set_property("speed", speed)
+ if (options.show_speed and not toggle_display) or (options.show_speed_toggled and toggle_display and speedup_target == nil) or (options.show_speed_target and speedup_target ~= nil) then
+ if uosc_available then
+ mp.command("script-binding uosc/flash-speed")
+ else
+ mp.osd_message(("▶▶ x%.1f"):format(speed))
+ end
+ end
+ end
+
+ if speed == 1 and effective_speed_cap ~= 1 then
+ if speed_timer ~= nil and not toggle_state then
+ speed_timer:kill()
+ speed_timer = nil
+ end
+ repeated = false
+ jumps_reset_speed = true
+ toggle_display = false
+ toggle_state = false
+ speedup_target = nil
+ elseif speed_timer == nil then
+ speed_timer = mp.add_periodic_timer(options.speed_interval, adjust_speed)
+ end
+end
+
+local function evafast(keypress)
+ if jumps_reset_speed and not toggle_state and (keypress["event"] == "up" or keypress["event"] == "press") then
+ speedup = false
+ speedup_target = nil
+ end
+
+ if options.seek_distance == 0 then
+ if keypress["event"] == "up" or keypress["event"] == "press" then
+ speedup = false
+ no_speedup = true
+ repeated = false
+ speedup_target = nil
+ end
+ if keypress["event"] == "down" then
+ keypress["event"] = "repeat"
+ end
+ end
+
+ if keypress["event"] == "up" or keypress["event"] == "press" then
+ toggle_display = toggle_state
+ if toggle_state and jumps_reset_speed then
+ speedup = false
+ speedup_target = nil
+ end
+ if speed_timer ~= nil and not toggle_state and mp.get_property_number("speed") == 1 and ((not options.subs_speed_cap or mp.get_property("sub-start") == nil) and options.speed_cap or options.subs_speed_cap) ~= 1 then
+ speed_timer:kill()
+ speed_timer = nil
+ jumps_reset_speed = true
+ toggle_display = false
+ toggle_state = false
+ speedup_target = nil
+ end
+ freeze = false
+ end
+
+ if keypress["event"] == "down" then
+ repeated = false
+ speedup = true
+ freeze = true
+ toggle_display = false
+ if options.show_seek and not repeated and not uosc_available then
+ mp.osd_message("▶▶")
+ end
+ elseif (keypress["event"] == "up" and (not repeated or speedup_target)) or keypress["event"] == "press" then
+ if options.seek_distance ~= 0 then
+ mp.commandv("seek", options.seek_distance)
+ if options.show_seek and uosc_available then
+ mp.command("script-binding uosc/flash-timeline")
+ end
+ end
+ repeated = false
+ if jumps_reset_speed and not toggle_state then
+ no_speedup = true
+ end
+ elseif keypress["event"] == "repeat" then
+ freeze = false
+ speedup = true
+ no_speedup = false
+ if not repeated then
+ adjust_speed()
+ end
+ repeated = true
+ end
+end
+
+local function evafast_speedup()
+ no_speedup = false
+ speedup = true
+ jumps_reset_speed = false
+ toggle_display = true
+ toggle_state = true
+ evafast({event = "repeat"})
+end
+
+local function evafast_slowdown()
+ jumps_reset_speed = true
+ no_speedup = true
+ repeated = false
+ freeze = false
+ speedup_target = nil
+end
+
+local function evafast_toggle()
+ if (repeated or not jumps_reset_speed) and speedup then
+ evafast_slowdown()
+ else
+ evafast_speedup()
+ end
+end
+
+mp.register_script_message("uosc-version", function(version)
+ uosc_available = true
+end)
+
+mp.register_script_message("speedup-target", function(time)
+ time = tonumber(time) or 0
+ if mp.get_property_number("time-pos", 0) >= time then
+ if speedup_target ~= nil then
+ use_forced_speed_cap = false
+ forced_speed_cap = nil
+ speedup_target = nil
+ evafast_slowdown()
+ end
+ return
+ end
+ speedup_target = time
+ evafast_speedup()
+end)
+
+mp.register_script_message("get-version", function(script)
+ mp.commandv("script-message-to", script, "evafast-version", "1.0")
+end)
+
+mp.add_key_binding("RIGHT", "evafast", evafast, {repeatable = true, complex = true})
+mp.add_key_binding(nil, "speedup", evafast_speedup)
+mp.add_key_binding(nil, "slowdown", evafast_slowdown)
+mp.add_key_binding(nil, "toggle", evafast_toggle)
+
+mp.commandv("script-message-to", "uosc", "get-version", mp.get_script_name())
diff --git a/dotfiles/.config/mpv/scripts/inputevent.lua b/dotfiles/.config/mpv/scripts/inputevent.lua
new file mode 100644
index 0000000..7e2e011
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/inputevent.lua
@@ -0,0 +1,500 @@
+-- InputEvent
+-- https://github.com/natural-harmonia-gropius/input-event
+
+local utils = require("mp.utils")
+local options = require("mp.options")
+
+local o = {
+ configs = "input.conf",
+ prefix = "@",
+}
+
+local bind_map = {}
+
+local event_pattern = {
+ { to = "penta_click", from = "down,up,down,up,down,up,down,up,down,up", length = 10 },
+ { to = "quatra_click", from = "down,up,down,up,down,up,down,up", length = 8 },
+ { to = "triple_click", from = "down,up,down,up,down,up", length = 6 },
+ { to = "double_click", from = "down,up,down,up", length = 4 },
+ { to = "click", from = "down,up", length = 2 },
+ { to = "press", from = "down", length = 1 },
+ { to = "release", from = "up", length = 1 },
+}
+
+local supported_events = {
+ ["repeat"] = true
+}
+for _, value in ipairs(event_pattern) do
+ supported_events[value.to] = true
+end
+
+-- https://mpv.io/manual/master/#input-command-prefixes
+local prefixes = { "osd-auto", "no-osd", "osd-bar", "osd-msg", "osd-msg-bar", "raw", "expand-properties",
+ "repeatable", "nonrepeatable", "async", "sync" }
+
+-- https://mpv.io/manual/master/#list-of-input-commands
+local commands = { "set", "cycle", "add", "multiply" }
+
+function table:push(element)
+ self[#self + 1] = element
+ return self
+end
+
+function table:assign(source)
+ for key, value in pairs(source) do
+ self[key] = value
+ end
+ return self
+end
+
+function table:has(element)
+ for _, value in ipairs(self) do
+ if value == element then
+ return true
+ end
+ end
+ return false
+end
+
+function table:filter(filter)
+ local nt = {}
+ for index, value in ipairs(self) do
+ if (filter(index, value)) then
+ nt = table.push(nt, value)
+ end
+ end
+ return nt
+end
+
+function table:join(separator)
+ local result = ""
+ for i, v in ipairs(self) do
+ local value = type(v) == "string" and v or tostring(v)
+ local semi = i == #self and "" or separator
+ result = result .. value .. semi
+ end
+ return result
+end
+
+function string:trim()
+ return (self:gsub("^%s*(.-)%s*$", "%1"))
+end
+
+function string:replace(pattern, replacement)
+ local result, n = self:gsub(pattern, replacement)
+ return result
+end
+
+function string:split(separator)
+ local fields = {}
+ local separator = separator or ":"
+ local pattern = string.format("([^%s]+)", separator)
+ local copy = self:gsub(pattern, function(c) fields[#fields + 1] = c end)
+ return fields
+end
+
+function debounce(func, wait)
+ func = type(func) == "function" and func or function() end
+ wait = type(wait) == "number" and wait / 1000 or 0
+
+ local timer = nil
+ local timer_end = function()
+ if timer then
+ timer:kill()
+ timer = nil
+ end
+ func()
+ end
+
+ return function()
+ if timer then
+ timer:kill()
+ end
+ timer = mp.add_timeout(wait, timer_end)
+ end
+end
+
+function now()
+ return mp.get_time() * 1000
+end
+
+function command(command)
+ if not command or command == '' then return true end
+ return mp.command(command)
+end
+
+function command_split(command)
+ local separator = { ";" }
+ local escape = { "\\" }
+ local quotation = { '"', "'" }
+ local quotation_stack = {}
+ local result = {}
+ local temp = ""
+
+ for i = 1, #command do
+ local char = command:sub(i, i)
+
+ if table.has(separator, char) and #quotation_stack == 0 then
+ result = table.push(result, temp)
+ temp = ""
+ elseif table.has(quotation, char) and not table.has(escape, temp:sub(#temp, #temp)) then
+ temp = temp .. char
+ if quotation_stack[#quotation_stack] == char then
+ quotation_stack = table.filter(quotation_stack, function(i, v) return i ~= #quotation_stack end)
+ else
+ quotation_stack = table.push(quotation_stack, char)
+ end
+ else
+ temp = temp .. char
+ end
+ end
+
+ if #temp then
+ result = table.push(result, temp)
+ end
+
+ return result
+end
+
+function command_invert(command)
+ local invert = ""
+ local command_list = command_split(command)
+ for i, v in ipairs(command_list) do
+ local trimed = v:trim()
+ local subs = trimed:split("%s*")
+ local prefix = ""
+ local command = ""
+ local property = ""
+
+ for index, value in ipairs(subs) do
+ if command == "" and table.has(prefixes, value) then
+ prefix = prefix .. value .. " "
+ elseif command == "" then
+ command = value
+ elseif property == "" then
+ property = value
+ end
+ end
+
+ local value = mp.get_property(property)
+ local semi = i == #command_list and "" or ";"
+
+ if table.has(commands, command) then
+ invert = invert .. prefix .. "set " .. property .. " " .. value .. semi
+ else
+ mp.msg.warn("\"" .. trimed .. "\" doesn't support auto restore.")
+ end
+ end
+ return invert
+end
+
+local InputEvent = {}
+
+function InputEvent:new(key, on)
+ local Instance = {}
+ setmetatable(Instance, self);
+ self.__index = self;
+
+ Instance.key = key
+ Instance.on = table.assign({ click = "" }, on)
+ Instance.queue = {}
+ Instance.queue_max = { length = 0 }
+ Instance.duration = mp.get_property_number("input-doubleclick-time", 300)
+ Instance.ignored = {}
+
+ for _, event in ipairs(event_pattern) do
+ if Instance.on[event.to] and event.length > 1 then
+ Instance.queue_max = { event = event.to, length = event.length }
+ break
+ end
+ end
+
+ for event, cmd in pairs(Instance.on) do
+ if type(cmd) == "table" then
+ for index, cmd_part in ipairs(cmd) do
+ if type(cmd_part) == "table" then
+ Instance.on[event][index] = table.concat(cmd_part, " ")
+ end
+ end
+ Instance.on[event] = table.concat(Instance.on[event], ";")
+ end
+ end
+
+ return Instance
+end
+
+function InputEvent:emit(event)
+ if self.ignored[event] then
+ if now() - self.ignored[event] < self.duration then
+ return
+ end
+
+ self.ignored[event] = nil
+ end
+
+ if event == "press" and self.on["release"] == "ignore" then
+ self.on["release-auto"] = command_invert(self.on["press"])
+ end
+
+ if event == "release" and self.on[event] == "ignore" then
+ event = "release-auto"
+ end
+
+ if event == "repeat" and self.on[event] == "ignore" then
+ event = "click"
+ end
+
+ local cmd = self.on[event]
+ if not cmd or cmd == "" then
+ return
+ end
+
+ local expand = mp.command_native({ 'expand-text', cmd })
+ if #command_split(cmd) == #command_split(expand) then
+ cmd = mp.command_native({ 'expand-text', cmd })
+ else
+ mp.msg.warn("Unsafe property-expansion: " .. cmd)
+ end
+
+ command(cmd)
+end
+
+function InputEvent:handler(event)
+ if event == "press" then
+ self:handler("down")
+ self:handler("up")
+ return
+ end
+
+ if event == "down" then
+ self:ignore("repeat")
+ end
+
+ if event == "repeat" then
+ self:emit(event)
+ return
+ end
+
+ if event == "up" then
+ if #self.queue == 0 then
+ self:emit("release")
+ return
+ end
+
+ if #self.queue + 1 == self.queue_max.length then
+ self.queue = {}
+ self:emit(self.queue_max.event)
+ return
+ end
+ end
+
+ if event == "cancel" then
+ if #self.queue == 0 then
+ self:emit("release")
+ return
+ end
+
+ table.remove(self.queue)
+ return
+ end
+
+ self.queue = table.push(self.queue, event)
+ self.exec_debounced()
+end
+
+function InputEvent:exec()
+ if #self.queue == 0 then
+ return
+ end
+
+ local separator = ","
+
+ local queue_string = table.join(self.queue, separator)
+ for _, v in ipairs(event_pattern) do
+ if self.on[v.to] then
+ queue_string = queue_string:replace(v.from, v.to)
+ end
+ end
+
+ self.queue = queue_string:split(separator)
+ for _, event in ipairs(self.queue) do
+ self:emit(event)
+ end
+
+ self.queue = {}
+end
+
+function InputEvent:ignore(event, timeout)
+ timeout = timeout or 0
+
+ self.ignored[event] = now() + timeout
+end
+
+function InputEvent:bind()
+ self.exec_debounced = debounce(function() self:exec() end, self.duration)
+ mp.add_forced_key_binding(self.key, self.key, function(e)
+ local event = e.canceled and "cancel" or e.event
+ self:handler(event)
+ end, { complex = true })
+end
+
+function InputEvent:unbind()
+ mp.remove_key_binding(self.key)
+end
+
+function InputEvent:rebind(diff)
+ if type(diff) == "table" then
+ self = table.assign(self, diff)
+ end
+
+ self:unbind()
+ self:bind()
+end
+
+function bind(key, on)
+ key = #key == 1 and key or key:upper()
+
+ if type(on) == "string" then
+ on = utils.parse_json(on)
+ end
+
+ if bind_map[key] then
+ on = table.assign(bind_map[key].on, on)
+ bind_map[key]:unbind()
+ end
+
+ bind_map[key] = InputEvent:new(key, on)
+ bind_map[key]:bind()
+end
+
+function unbind(key)
+ local binding = bind_map[key]
+ if binding then
+ binding:unbind()
+ bind_map[key] = nil
+ end
+end
+
+function bind_from_conf(conf)
+ local kv = {}
+ for _, line in pairs(conf:split("\n")) do
+ line = line:trim()
+ if line ~= "" and line:sub(1, 1) ~= "#" then
+ local key, cmd, comment = line:trim():match("^([%S]+)%s+(.-)%s+#%s*(.-)$")
+ if comment then
+ local comments = {}
+ for _, item in ipairs(comment:split("#")) do
+ item = item:trim()
+ local prefix, value = item:match("^(.-)%s*:%s*(.-)$")
+ if not prefix then
+ prefix, value = item:match("^(%p)%s*(.-)$")
+ end
+ if prefix then
+ comments[prefix] = value
+ end
+ end
+
+ local event = comments[o.prefix]
+ if event and event ~= "" and supported_events[event] then
+ if not kv[key] then
+ kv[key] = {}
+ end
+ kv[key][event] = cmd
+ end
+ end
+ end
+ end
+
+ local parsed = {}
+ for key, on in pairs(kv) do
+ table.insert(parsed, { key = key, on = on })
+ end
+
+ return parsed
+end
+
+function bind_from_json(json)
+ local parsed = utils.parse_json(json)
+ return parsed
+end
+
+function bind_from_options_configs()
+ for key, value in pairs(bind_map) do
+ unbind(key)
+ end
+
+ for index, value in ipairs(o.configs:split(",")) do
+ local path = value:trim()
+ local content = ""
+ local extension = ""
+ if path == "input.conf" then
+ local input_conf = mp.get_property_native("input-conf")
+ path = input_conf == "" and "~~/input.conf" or input_conf
+ end
+ if (path:match("^memory://")) then
+ content = path:replace("^memory://", "")
+ extension = "conf"
+ else
+ path = mp.command_native({ "expand-path", path })
+ local meta, meta_error = utils.file_info(path)
+ if meta and meta.is_file then
+ local file = io.open(path, "r")
+ if file then
+ content = file:read("*all")
+ file:close()
+ extension = path:match("^.+%.(.+)$")
+ end
+ end
+ end
+
+ local parsed = {}
+ if extension == "conf" then
+ parsed = bind_from_conf(content)
+ elseif extension == "json" then
+ parsed = bind_from_json(content)
+ end
+ if #parsed ~= 0 then
+ for _, v in ipairs(parsed) do
+ if v.key and v.on then
+ unbind(v.key)
+ bind(v.key, v.on)
+ else
+ mp.msg.error("Invalidated config: " .. path)
+ end
+ end
+ end
+ end
+end
+
+function on_options_update(list)
+ if (list.configs) then
+ bind_from_options_configs()
+ end
+end
+
+function on_input_doubleclick_time_update(_, duration)
+ for _, binding in pairs(bind_map) do
+ binding:rebind({ duration = duration })
+ end
+end
+
+function on_focused_update(_, focused)
+ if not focused then
+ return
+ end
+
+ local binding = bind_map["MBTN_LEFT"]
+ if not binding then
+ return
+ end
+
+ binding:ignore("click", binding.duration)
+end
+
+mp.register_script_message("bind", bind)
+mp.register_script_message("unbind", unbind)
+mp.observe_property("input-doubleclick-time", "native", on_input_doubleclick_time_update)
+mp.observe_property("focused", "native", on_focused_update)
+options.read_options(o, _, on_options_update)
+
+bind_from_options_configs()
diff --git a/dotfiles/.config/mpv/scripts/memo.lua b/dotfiles/.config/mpv/scripts/memo.lua
new file mode 100644
index 0000000..6327dd0
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/memo.lua
@@ -0,0 +1,1439 @@
+-- memo.lua
+--
+-- A recent files menu for mpv
+
+local options = {
+ -- File path gets expanded, leave empty for in-memory history
+ history_path = "~~/memo-history.log",
+
+ -- How many entries to display in menu
+ entries = 10,
+
+ -- Display navigation to older/newer entries
+ pagination = true,
+
+ -- Display files only once
+ hide_duplicates = true,
+
+ -- Check if files still exist
+ hide_deleted = true,
+
+ -- Display only the latest file from each directory
+ hide_same_dir = false,
+
+ -- Date format https://www.lua.org/pil/22.1.html
+ timestamp_format = "%Y-%m-%d %H:%M:%S",
+
+ -- Display titles instead of filenames when available
+ use_titles = true,
+
+ -- Truncate titles to n characters, 0 to disable
+ truncate_titles = 60,
+
+ -- Meant for use in auto profiles
+ enabled = true,
+
+ -- Keybinds for vanilla menu
+ up_binding = "UP WHEEL_UP",
+ down_binding = "DOWN WHEEL_DOWN",
+ select_binding = "RIGHT ENTER",
+ append_binding = "Shift+RIGHT Shift+ENTER",
+ close_binding = "LEFT ESC",
+
+ -- Path prefixes for the recent directory menu
+ -- This can be used to restrict the parent directory relative to which the
+ -- directories are shown.
+ -- Syntax
+ -- Prefixes are separated by | and can use Lua patterns by prefixing
+ -- them with "pattern:", otherwise they will be treated as plain text.
+ -- Pattern syntax can be found here https://www.lua.org/manual/5.1/manual.html#5.4.1
+ -- Example
+ -- "path_prefixes=My-Movies|pattern:TV Shows/.-/|Anime" will show directories
+ -- that are direct subdirectories of directories named "My-Movies" as well as
+ -- "Anime", while for TV Shows the shown directories are one level below that.
+ -- Opening the file "/data/TV Shows/Comedy/Curb Your Enthusiasm/S4/E06.mkv" will
+ -- lead to "Curb Your Enthusiasm" to be shown in the directory menu. Opening
+ -- of that entry will then open that file again.
+ path_prefixes = "pattern:.*"
+}
+
+function parse_path_prefixes(path_prefixes)
+ local patterns = {}
+ for prefix in path_prefixes:gmatch("([^|]+)") do
+ if prefix:find("pattern:", 1, true) == 1 then
+ patterns[#patterns + 1] = {pattern = prefix:sub(9)}
+ else
+ patterns[#patterns + 1] = {pattern = prefix, plain = true}
+ end
+ end
+ return patterns
+end
+
+local script_name = mp.get_script_name()
+
+mp.utils = require "mp.utils"
+mp.options = require "mp.options"
+mp.options.read_options(options, "memo", function(list)
+ if list.path_prefixes then
+ options.path_prefixes = parse_path_prefixes(options.path_prefixes)
+ end
+end)
+options.path_prefixes = parse_path_prefixes(options.path_prefixes)
+
+local assdraw = require "mp.assdraw"
+
+local osd = mp.create_osd_overlay("ass-events")
+osd.z = 2000
+local osd_update = nil
+local width, height = 0, 0
+local margin_top, margin_bottom = 0, 0
+local font_size = mp.get_property_number("osd-font-size") or 55
+
+local fakeio = {data = "", cursor = 0, offset = 0, file = nil}
+function fakeio:setvbuf(mode) end
+function fakeio:flush()
+ self.cursor = self.offset + #self.data
+end
+function fakeio:read(format)
+ local out = ""
+ if self.cursor < self.offset then
+ self.file:seek("set", self.cursor)
+ out = self.file:read(format)
+ format = format - #out
+ self.cursor = self.cursor + #out
+ end
+ if format > 0 then
+ out = out .. self.data:sub(self.cursor - self.offset, self.cursor - self.offset + format)
+ self.cursor = self.cursor + format
+ end
+ return out
+end
+function fakeio:seek(whence, offset)
+ local base = 0
+ offset = offset or 0
+ if whence == "end" then
+ base = self.offset + #self.data
+ end
+ self.cursor = base + offset
+ return self.cursor
+end
+function fakeio:write(...)
+ local args = {...}
+ for i, v in ipairs(args) do
+ self.data = self.data .. v
+ end
+end
+
+local history, history_path
+
+if options.history_path ~= "" then
+ history_path = mp.command_native({"expand-path", options.history_path})
+ history = io.open(history_path, "a+b")
+end
+if history == nil then
+ if history_path then
+ mp.msg.warn("cannot write to history file " .. options.history_path .. ", new entries will not be saved to disk")
+ history = io.open(history_path, "rb")
+ if history then
+ fakeio.offset = history:seek("end")
+ fakeio.file = history
+ end
+ end
+ history = fakeio
+end
+history:setvbuf("full")
+
+local event_loop_exhausted = false
+local uosc_available = false
+local dyn_menu = nil
+local menu_shown = false
+local last_state = nil
+local menu_data = nil
+local palette = false
+local search_words = nil
+local search_query = nil
+local dir_menu = false
+local dir_menu_prefixes = nil
+local new_loadfile = nil
+local normalize_path = nil
+
+local data_protocols = {
+ edl = true,
+ data = true,
+ null = true,
+ memory = true,
+ hex = true,
+ fd = true,
+ fdclose = true,
+ mf = true
+}
+
+local stacked_protocols = {
+ ffmpeg = true,
+ lavf = true,
+ appending = true,
+ file = true,
+ archive = true,
+ slice = true
+}
+
+local device_protocols = {
+ bd = true,
+ br = true,
+ bluray = true,
+ cdda = true,
+ dvb = true,
+ dvd = true,
+ dvdnav = true
+}
+
+function utf8_char_bytes(str, i)
+ local char_byte = str:byte(i)
+ local max_bytes = #str - i + 1
+ if char_byte < 0xC0 then
+ return math.min(max_bytes, 1)
+ elseif char_byte < 0xE0 then
+ return math.min(max_bytes, 2)
+ elseif char_byte < 0xF0 then
+ return math.min(max_bytes, 3)
+ elseif char_byte < 0xF8 then
+ return math.min(max_bytes, 4)
+ else
+ return math.min(max_bytes, 1)
+ end
+end
+
+function utf8_iter(str)
+ local byte_start = 1
+ return function()
+ local start = byte_start
+ if #str < start then return nil end
+ local byte_count = utf8_char_bytes(str, start)
+ byte_start = start + byte_count
+ return start, str:sub(start, byte_start - 1)
+ end
+end
+
+function utf8_table(str)
+ local t = {}
+ local width = 0
+ for _, char in utf8_iter(str) do
+ width = width + (#char > 2 and 2 or 1)
+ table.insert(t, char)
+ end
+ return t, width
+end
+
+function utf8_subwidth(t, start_index, end_index)
+ local index = 1
+ local substr = ""
+ for _, char in ipairs(t) do
+ if start_index <= index and index <= end_index then
+ local width = #char > 2 and 2 or 1
+ index = index + width
+ substr = substr .. char
+ end
+ end
+ return substr, index
+end
+
+function utf8_subwidth_back(t, num_chars)
+ local index = 0
+ local substr = ""
+ for i = #t, 1, -1 do
+ if num_chars > index then
+ local width = #t[i] > 2 and 2 or 1
+ index = index + width
+ substr = t[i] .. substr
+ end
+ end
+ return substr
+end
+
+function utf8_to_unicode(str, i)
+ local byte_count = utf8_char_bytes(str, i)
+ local char_byte = str:byte(i)
+ local unicode = char_byte
+ if byte_count ~= 1 then
+ local shift = 2 ^ (8 - byte_count)
+ char_byte = char_byte - math.floor(0xFF / shift) * shift
+ unicode = char_byte * (2 ^ 6) ^ (byte_count - 1)
+ end
+ for j = 2, byte_count do
+ char_byte = str:byte(i + j - 1) - 0x80
+ unicode = unicode + char_byte * (2 ^ 6) ^ (byte_count - j)
+ end
+ return math.floor(unicode + 0.5)
+end
+
+function ass_clean(str)
+ str = str:gsub("\\", "\\\239\187\191")
+ str = str:gsub("{", "\\{")
+ str = str:gsub("}", "\\}")
+ return str
+end
+
+-- Extended from https://stackoverflow.com/a/73283799 with zero-width handling from uosc
+function unaccent(str)
+ local unimask = "[%z\1-\127\194-\244][\128-\191]*"
+
+ -- "Basic Latin".."Latin-1 Supplement".."Latin Extended-A".."Latin Extended-B"
+ local charmap =
+ "AÀÁÂÃÄÅĀĂĄǍǞǠǺȀȂȦȺAEÆǢǼ"..
+ "BßƁƂƄɃ"..
+ "CÇĆĈĊČƆƇȻ"..
+ "DÐĎĐƉƊDZƻDŽDZDzDžDz"..
+ "EÈÉÊËĒĔĖĘĚƎƏƐȄȆȨɆ"..
+ "FƑ"..
+ "GĜĞĠĢƓǤǦǴ"..
+ "HĤĦȞHuǶ"..
+ "IÌÍÎÏĨĪĬĮİƖƗǏȈȊIJIJ"..
+ "JĴɈ"..
+ "KĶƘǨ"..
+ "LĹĻĽĿŁȽLJLJLjLj"..
+ "NÑŃŅŇŊƝǸȠNJNJNjNj"..
+ "OÒÓÔÕÖØŌŎŐƟƠǑǪǬǾȌȎȪȬȮȰOEŒOIƢOUȢ"..
+ "PÞƤǷ"..
+ "QɊ"..
+ "RŔŖŘȐȒɌ"..
+ "SŚŜŞŠƧƩƪƼȘ"..
+ "TŢŤŦƬƮȚȾ"..
+ "UÙÚÛÜŨŪŬŮŰŲƯƱƲȔȖɄǓǕǗǙǛ"..
+ "VɅ"..
+ "WŴƜ"..
+ "YÝŶŸƳȜȲɎ"..
+ "ZŹŻŽƵƷƸǮȤ"..
+ "aàáâãäåāăąǎǟǡǻȁȃȧaeæǣǽ"..
+ "bƀƃƅ"..
+ "cçćĉċčƈȼ"..
+ "dðƌƋƍȡďđdbȸdzdždz"..
+ "eèéêëēĕėęěǝȅȇȩɇ"..
+ "fƒ"..
+ "gĝğġģƔǥǧǵ"..
+ "hĥħȟhvƕ"..
+ "iìíîïĩīĭįıǐȉȋijij"..
+ "jĵǰȷɉ"..
+ "kķĸƙǩ"..
+ "lĺļľŀłƚƛȴljlj"..
+ "nñńņňʼnŋƞǹȵnjnj"..
+ "oòóôõöøōŏőơǒǫǭǿȍȏȫȭȯȱoeœoiƣouȣ"..
+ "pþƥƿ"..
+ "qɋqpȹ"..
+ "rŕŗřƦȑȓɍ"..
+ "sśŝşšſƨƽșȿ"..
+ "tţťŧƫƭțȶtsƾ"..
+ "uùúûüũūŭůűųưǔǖǘǚǜȕȗ"..
+ "wŵ"..
+ "yýÿŷƴȝȳɏ"..
+ "zźżžƶƹƺǯȥɀ"
+
+ local zero_width_blocks = {
+ {0x0000, 0x001F}, -- C0
+ {0x007F, 0x009F}, -- Delete + C1
+ {0x034F, 0x034F}, -- combining grapheme joiner
+ {0x061C, 0x061C}, -- Arabic Letter Strong
+ {0x200B, 0x200F}, -- {zero-width space, zero-width non-joiner, zero-width joiner, left-to-right mark, right-to-left mark}
+ {0x2028, 0x202E}, -- {line separator, paragraph separator, Left-to-Right Embedding, Right-to-Left Embedding, Pop Directional Format, Left-to-Right Override, Right-to-Left Override}
+ {0x2060, 0x2060}, -- word joiner
+ {0x2066, 0x2069}, -- {Left-to-Right Isolate, Right-to-Left Isolate, First Strong Isolate, Pop Directional Isolate}
+ {0xFEFF, 0xFEFF}, -- zero-width non-breaking space
+ -- Some other characters can also be combined https://en.wikipedia.org/wiki/Combining_character
+ {0x0300, 0x036F}, -- Combining Diacritical Marks 0 BMP Inherited
+ {0x1AB0, 0x1AFF}, -- Combining Diacritical Marks Extended 0 BMP Inherited
+ {0x1DC0, 0x1DFF}, -- Combining Diacritical Marks Supplement 0 BMP Inherited
+ {0x20D0, 0x20FF}, -- Combining Diacritical Marks for Symbols 0 BMP Inherited
+ {0xFE20, 0xFE2F}, -- Combining Half Marks 0 BMP Cyrillic (2 characters), Inherited (14 characters)
+ -- Egyptian Hieroglyph Format Controls and Shorthand format Controls
+ {0x13430, 0x1345F}, -- Egyptian Hieroglyph Format Controls 1 SMP Egyptian Hieroglyphs
+ {0x1BCA0, 0x1BCAF}, -- Shorthand Format Controls 1 SMP Common
+ -- not sure how to deal with those https://en.wikipedia.org/wiki/Spacing_Modifier_Letters
+ {0x02B0, 0x02FF}, -- Spacing Modifier Letters 0 BMP Bopomofo (2 characters), Latin (14 characters), Common (64 characters)
+ }
+
+ return str:gsub(unimask, function(unichar)
+ local unicode = utf8_to_unicode(unichar, 1)
+ for _, block in ipairs(zero_width_blocks) do
+ if unicode >= block[1] and unicode <= block[2] then
+ return ""
+ end
+ end
+
+ return unichar:match("%a") or charmap:match("(%a+)[^%a]-"..(unichar:gsub("[%(%)%.%%%+%-%*%?%[%^%$]", "%%%1")))
+ end)
+end
+
+function shallow_copy(t)
+ local t2 = {}
+ for k,v in pairs(t) do
+ t2[k] = v
+ end
+ return t2
+end
+
+function has_protocol(path)
+ return path:find("^%a[%w.+-]-://") or path:find("^%a[%w.+-]-:%?")
+end
+
+function normalize(path)
+ if normalize_path ~= nil then
+ if normalize_path then
+ -- don't normalize magnet-style paths
+ local protocol_start, protocol_end, protocol = path:find("^(%a[%w.+-]-):%?")
+ if not protocol_end then
+ path = mp.command_native({"normalize-path", path})
+ end
+ else
+ -- TODO: implement the basics of path normalization ourselves for mpv 0.38.0 and under
+ local directory = mp.get_property("working-directory", "")
+ if not has_protocol(path) then
+ path = mp.utils.join_path(directory, path)
+ end
+ end
+ return path
+ end
+
+ normalize_path = false
+
+ local commands = mp.get_property_native("command-list", {})
+ for _, command in ipairs(commands) do
+ if command.name == "loadfile" then
+ for _, arg in ipairs(command.args) do
+ if arg.name == "index" then
+ new_loadfile = true
+ break
+ end
+ end
+ end
+ if command.name == "normalize-path" then
+ normalize_path = true
+ break
+ end
+ end
+ return normalize(path)
+end
+
+function loadfile_compat(path)
+ if new_loadfile ~= nil then
+ if new_loadfile then
+ return {"-1", path}
+ end
+ return {path}
+ end
+
+ new_loadfile = false
+
+ local commands = mp.get_property_native("command-list", {})
+ for _, command in ipairs(commands) do
+ if command.name == "loadfile" then
+ for _, arg in ipairs(command.args) do
+ if arg.name == "index" then
+ new_loadfile = true
+ return {"-1", path}
+ end
+ end
+ return {path}
+ end
+ end
+ return {path}
+end
+
+function menu_json(menu_items, page)
+ local title = (search_query or (dir_menu and "Directories" or "History")) .. ""
+ if options.pagination or page ~= 1 then
+ title = title .. " - Page " .. page
+ end
+
+ local menu = {
+ type = "memo-history",
+ title = title,
+ items = menu_items,
+ on_search = {"script-message-to", script_name, "memo-search-uosc:"},
+ on_close = {"script-message-to", script_name, "memo-clear"},
+ palette = palette, -- TODO: remove on next uosc release
+ search_style = palette and "palette" or nil
+ }
+
+ return menu
+end
+
+function uosc_update()
+ local json = mp.utils.format_json(menu_data) or "{}"
+ mp.commandv("script-message-to", "uosc", menu_shown and "update-menu" or "open-menu", json)
+end
+
+function update_dimensions()
+ width, height = mp.get_osd_size()
+ osd.res_x = width
+ osd.res_y = height
+ draw_menu()
+end
+
+if mp.utils.shared_script_property_set then
+ function update_margins()
+ local shared_props = mp.get_property_native("shared-script-properties")
+ local val = shared_props["osc-margins"]
+ if val then
+ -- formatted as "%f,%f,%f,%f" with left, right, top, bottom, each
+ -- value being the border size as ratio of the window size (0.0-1.0)
+ local vals = {}
+ for v in string.gmatch(val, "[^,]+") do
+ vals[#vals + 1] = tonumber(v)
+ end
+ margin_top = vals[3] -- top
+ margin_bottom = vals[4] -- bottom
+ else
+ margin_top = 0
+ margin_bottom = 0
+ end
+ draw_menu()
+ end
+else
+ function update_margins()
+ local val = mp.get_property_native("user-data/osc/margins")
+ if val then
+ margin_top = val.t
+ margin_bottom = val.b
+ else
+ margin_top = 0
+ margin_bottom = 0
+ end
+ draw_menu()
+ end
+end
+
+function bind_keys(keys, name, func, opts)
+ if not keys then
+ mp.add_forced_key_binding(keys, name, func, opts)
+ return
+ end
+ local i = 1
+ for key in keys:gmatch("[^%s]+") do
+ local prefix = i == 1 and "" or i
+ mp.add_forced_key_binding(key, name .. prefix, func, opts)
+ i = i + 1
+ end
+end
+
+function unbind_keys(keys, name)
+ if not keys then
+ mp.remove_key_binding(name)
+ return
+ end
+ local i = 1
+ for key in keys:gmatch("[^%s]+") do
+ local prefix = i == 1 and "" or i
+ mp.remove_key_binding(name .. prefix)
+ i = i + 1
+ end
+end
+
+function close_menu()
+ mp.unobserve_property(update_dimensions)
+ mp.unobserve_property(update_margins)
+ unbind_keys(options.up_binding, "move_up")
+ unbind_keys(options.down_binding, "move_down")
+ unbind_keys(options.select_binding, "select")
+ unbind_keys(options.append_binding, "append")
+ unbind_keys(options.close_binding, "close")
+ last_state = nil
+ menu_data = nil
+ search_words = nil
+ search_query = nil
+ dir_menu = false
+ menu_shown = false
+ palette = false
+ osd:update()
+ osd.hidden = true
+ osd:update()
+end
+
+function open_menu()
+ menu_shown = true
+
+ update_dimensions()
+ mp.observe_property("osd-dimensions", "native", update_dimensions)
+ mp.observe_property("video-out-params", "native", update_dimensions)
+ local margin_prop = mp.utils.shared_script_property_set and "shared-script-properties" or "user-data/osc/margins"
+ mp.observe_property(margin_prop, "native", update_margins)
+
+ local function select_item(append)
+ local item = menu_data.items[last_state.selected_index]
+ if not item then return end
+ if not item.keep_open then
+ close_menu()
+ end
+ if append and item.value[1] == "loadfile" then
+ -- bail if file is already in playlist
+ local playlist = mp.get_property_native("playlist", {})
+ for i = 1, #playlist do
+ local playlist_file = playlist[i].filename
+ local display_path, save_path, effective_path, effective_protocol, is_remote, file_options = path_info(playlist_file)
+ if not is_remote then
+ playlist_file = normalize(save_path)
+ end
+ if item.value[2] == playlist_file then
+ return
+ end
+ end
+ item.value[3] = "append-play"
+ end
+ mp.commandv(unpack(item.value))
+ end
+
+ bind_keys(options.up_binding, "move_up", function()
+ last_state.selected_index = math.max(last_state.selected_index - 1, 1)
+ draw_menu()
+ end, { repeatable = true })
+ bind_keys(options.down_binding, "move_down", function()
+ last_state.selected_index = math.min(last_state.selected_index + 1, #menu_data.items)
+ draw_menu()
+ end, { repeatable = true })
+ bind_keys(options.select_binding, "select", select_item)
+ bind_keys(options.append_binding, "append", function()
+ select_item(true)
+ end)
+ bind_keys(options.close_binding, "close", close_menu)
+ osd.hidden = false
+ draw_menu()
+end
+
+function draw_menu()
+ if not menu_data then return end
+ if not menu_shown then
+ open_menu()
+ end
+
+ local num_options = #menu_data.items > 0 and #menu_data.items + 1 or 1
+ last_state.selected_index = math.min(last_state.selected_index, #menu_data.items)
+
+ local function get_scrolled_lines()
+ local output_height = height - margin_top * height - margin_bottom * height - 0.2 * font_size + 0.5
+ local screen_lines = math.max(math.floor(output_height / font_size), 1)
+ local max_scroll = math.max(num_options - screen_lines, 0)
+ return math.min(math.max(last_state.selected_index - math.ceil(screen_lines / 2), 0), max_scroll) - 1
+ end
+
+ local ass = assdraw.ass_new()
+ local curtain_opacity = 0.7
+
+ local alpha = 255 - math.ceil(255 * curtain_opacity)
+ ass.text = string.format("{\\pos(0,0)\\rDefault\\an7\\1c&H000000&\\alpha&H%X&}", alpha)
+ ass:draw_start()
+ ass:rect_cw(0, 0, width, height)
+ ass:draw_stop()
+ ass:new_event()
+
+ ass:append("{\\rDefault\\pos("..(0.3 * font_size).."," .. (margin_top * height + 0.1 * font_size) .. ")\\an7\\fs" .. font_size .. "\\bord2\\q2\\b1}" .. ass_clean(menu_data.title) .. "{\\b0}")
+ ass:new_event()
+
+ local scrolled_lines = get_scrolled_lines() - 1
+ local pos_y = margin_top * height - scrolled_lines * font_size + 0.2 * font_size + 0.5
+ local clip_top = math.floor(margin_top * height + font_size + 0.2 * font_size + 0.5)
+ local clip_bottom = math.floor((1 - margin_bottom) * height + 0.5)
+ local clipping_coordinates = "0," .. clip_top .. "," .. width .. "," .. clip_bottom
+
+ if #menu_data.items > 0 then
+ local menu_index = 0
+ for i = 1, #menu_data.items do
+ local item = menu_data.items[i]
+ if item.title then
+ local icon
+ local separator = last_state.selected_index == i and "{\\alpha&HFF&}●{\\alpha&H00&} - " or "{\\alpha&HFF&}●{\\alpha&H00&} - "
+ if item.icon == "spinner" then
+ separator = "⟳ "
+ elseif item.icon == "navigate_next" then
+ icon = last_state.selected_index == i and "▶" or "▷"
+ elseif item.icon == "navigate_before" then
+ icon = last_state.selected_index == i and "◀" or "◁"
+ else
+ icon = last_state.selected_index == i and "●" or "○"
+ end
+ ass:new_event()
+ ass:pos(0.3 * font_size, pos_y + menu_index * font_size)
+ ass:append("{\\rDefault\\fnmonospace\\an1\\fs" .. font_size .. "\\bord2\\q2\\clip(" .. clipping_coordinates .. ")}"..separator.."{\\rDefault\\an7\\fs" .. font_size .. "\\bord2\\q2}" .. ass_clean(item.title))
+ if icon then
+ ass:new_event()
+ ass:pos(0.6 * font_size, pos_y + menu_index * font_size)
+ ass:append("{\\rDefault\\fnmonospace\\an2\\fs" .. font_size .. "\\bord2\\q2\\clip(" .. clipping_coordinates .. ")}" .. icon)
+ end
+ menu_index = menu_index + 1
+ end
+ end
+ else
+ ass:pos(0.3 * font_size, pos_y)
+ ass:append("{\\rDefault\\an1\\fs" .. font_size .. "\\bord2\\q2\\clip(" .. clipping_coordinates .. ")}")
+ ass:append("No entries")
+ end
+
+ osd_update = nil
+ osd.data = ass.text
+ osd:update()
+end
+
+function get_full_path()
+ local path = mp.get_property("path")
+ if path == nil or path == "-" or path == "/dev/stdin" then return end
+
+ local display_path, save_path, effective_path, effective_protocol, is_remote, file_options = path_info(path)
+
+ if not is_remote then
+ path = normalize(save_path)
+ end
+
+ return path, display_path, save_path, effective_path, effective_protocol, is_remote, file_options
+end
+
+function path_info(full_path)
+ local function resolve(effective_path, save_path, display_path, last_protocol, is_remote)
+ local protocol_start, protocol_end, protocol = display_path:find("^(%a[%w.+-]-)://")
+
+ if protocol == "ytdl" then
+ -- for direct video access ytdl://videoID and ytsearch:
+ is_remote = true
+ elseif protocol and not stacked_protocols[protocol] then
+ local input_path, file_options
+ if device_protocols[protocol] then
+ input_path, file_options = display_path:match("(.-) %-%-opt=(.+)")
+ effective_path = file_options and file_options:match(".+=(.*)")
+ if protocol == "dvb" then
+ is_remote = true
+ if not effective_path then
+ effective_path = display_path
+ input_path = display_path:sub(protocol_end + 1)
+ end
+ end
+ display_path = input_path or display_path
+ else
+ is_remote = true
+ display_path = display_path:sub(protocol_end + 1)
+ end
+ return display_path, save_path, effective_path, protocol, is_remote, file_options
+ end
+
+ if not protocol_end then
+ if last_protocol == "ytdl" then
+ display_path = "ytdl://" .. display_path
+ end
+ return display_path, save_path, effective_path, last_protocol, is_remote, nil
+ end
+
+ display_path = display_path:sub(protocol_end + 1)
+
+ if protocol == "archive" then
+ local main_path, archive_path, filename = display_path:gsub("%%7C", "|"):match("(.-)(|.-[\\/])(.+)")
+ if not main_path then
+ local main_path = display_path:match("(.-)|")
+ effective_path = normalize(main_path or display_path)
+ _, save_path, effective_path, protocol, is_remote, file_options = resolve(effective_path, save_path, display_path, protocol, is_remote)
+ effective_path = normalize(effective_path)
+ save_path = "archive://" .. (save_path or effective_path)
+ if main_path then
+ save_path = save_path .. display_path:match("|(.-)")
+ end
+ else
+ display_path, save_path, _, protocol, is_remote, file_options = resolve(main_path, save_path, main_path, protocol, is_remote)
+ effective_path = normalize(display_path)
+ save_path = save_path or effective_path
+ save_path = "archive://" .. save_path .. (save_path:find("archive://") and archive_path:gsub("|", "%%7C") or archive_path) .. filename
+ _, main_path = mp.utils.split_path(main_path)
+ _, filename = mp.utils.split_path(filename)
+ display_path = main_path .. ": " .. filename
+ end
+ elseif protocol == "slice" then
+ if effective_path then
+ effective_path = effective_path:match(".-@(.*)") or effective_path
+ end
+ display_path = display_path:match(".-@(.*)") or display_path
+ end
+
+ return resolve(effective_path, save_path, display_path, protocol, is_remote)
+ end
+
+ -- don't resolve magnet-style paths
+ local protocol_start, protocol_end, protocol = full_path:find("^(%a[%w.+-]-):%?")
+ if protocol_end then
+ return full_path, full_path, protocol, true, nil
+ end
+
+ local display_path, save_path, effective_path, effective_protocol, is_remote, file_options = resolve(nil, nil, full_path, nil, false)
+ effective_path = effective_path or display_path
+ save_path = save_path or effective_path
+ if is_remote and not file_options then
+ display_path = display_path:gsub("%%(%x%x)", function(hex)
+ return string.char(tonumber(hex, 16))
+ end)
+ end
+
+ return display_path, save_path, effective_path, effective_protocol, is_remote, file_options
+end
+
+function write_history(display)
+ local full_path, display_path, save_path, effective_path, effective_protocol, is_remote, file_options = get_full_path()
+ if full_path == nil then
+ mp.msg.debug("cannot get full path to file")
+ if display then
+ mp.osd_message("[memo] cannot get full path to file")
+ end
+ return
+ end
+
+ if data_protocols[effective_protocol] then
+ mp.msg.debug("not logging file with " .. effective_protocol .. " protocol")
+ if display then
+ mp.osd_message("[memo] not logging file with " .. effective_protocol .. " protocol")
+ end
+ return
+ end
+
+ if effective_protocol == "bd" or effective_protocol == "br" or effective_protocol == "bluray" then
+ full_path = full_path .. " --opt=bluray-device=" .. mp.get_property("bluray-device", "")
+ elseif effective_protocol == "cdda" then
+ full_path = full_path .. " --opt=cdrom-device=" .. mp.get_property("cdrom-device", "")
+ elseif effective_protocol == "dvb" then
+ local dvb_program = mp.get_property("dvbin-prog", "")
+ if dvb_program ~= "" then
+ full_path = full_path .. " --opt=dvbin-prog=" .. dvb_program
+ end
+ elseif effective_protocol == "dvd" or effective_protocol == "dvdnav" then
+ full_path = full_path .. " --opt=dvd-angle=" .. mp.get_property("dvd-angle", "1") .. ",dvd-device=" .. mp.get_property("dvd-device", "")
+ end
+
+ mp.msg.debug("logging file " .. full_path)
+ if display then
+ mp.osd_message("[memo] logging file " .. full_path)
+ end
+
+ local playlist_pos = mp.get_property_number("playlist-pos") or -1
+ local title = playlist_pos > -1 and mp.get_property("playlist/"..playlist_pos.."/title") or ""
+ local title_length = #title
+ local timestamp = os.time()
+
+ -- format: ,,,,
+ local entry = timestamp .. "," .. (title_length > 0 and title_length or "") .. "," .. title .. "," .. full_path
+ local entry_length = #entry
+
+ history:seek("end")
+ history:write(entry .. "," .. entry_length, "\n")
+ history:flush()
+
+ if dyn_menu then
+ dyn_menu_update()
+ end
+end
+
+function show_history(entries, next_page, prev_page, update, return_items)
+ if event_loop_exhausted then return end
+ event_loop_exhausted = true
+
+ local should_close = menu_shown and not prev_page and not next_page and not update
+ if should_close then
+ memo_close()
+ if not return_items then
+ return
+ end
+ end
+
+ local max_digits_length = 4 + 2
+ local retry_offset = 512
+ local menu_items = {}
+ local state = (prev_page or next_page) and last_state or {
+ known_dirs = {},
+ known_files = {},
+ existing_files = {},
+ cursor = history:seek("end"),
+ retry = 0,
+ pages = {},
+ current_page = 1,
+ selected_index = 1
+ }
+
+ if update then
+ state.pages = {}
+ end
+
+ if last_state then
+ if prev_page then
+ if state.current_page == 1 then return end
+ state.current_page = state.current_page - 1
+ elseif next_page then
+ if state.cursor == 0 and not state.pages[state.current_page + 1] then return end
+ if options.entries < 1 then return end
+ state.current_page = state.current_page + 1
+ end
+ end
+
+ last_state = state
+
+ if state.pages[state.current_page] then
+ menu_data = menu_json(state.pages[state.current_page], state.current_page)
+
+ if uosc_available then
+ uosc_update()
+ else
+ draw_menu()
+ end
+ return
+ end
+
+ local function find_path_prefix(path, path_prefixes)
+ for _, prefix in ipairs(path_prefixes) do
+ local start, stop = path:find(prefix.pattern, 1, prefix.plain)
+ if start then
+ return start, stop
+ end
+ end
+ end
+
+ -- all of these error cases can only happen if the user messes with the history file externally
+ local function read_line()
+ history:seek("set", state.cursor - max_digits_length)
+ local tail = history:read(max_digits_length)
+ if not tail then
+ mp.msg.debug("error could not read entry length @ " .. state.cursor - max_digits_length)
+ return
+ end
+
+ local entry_length_str, whitespace = tail:match("(%d+)(%s*)$")
+ if not entry_length_str then
+ mp.msg.debug("invalid entry length @ " .. state.cursor)
+ state.cursor = math.max(state.cursor - retry_offset, 0)
+ history:seek("set", state.cursor)
+ local retry = history:read(retry_offset)
+ if not retry then
+ mp.msg.debug("retry failed @ " .. state.cursor)
+ state.cursor = 0
+ return
+ end
+ local last_valid = string.match(retry, ".*(%d+\n.*)")
+ local offset = last_valid and #last_valid or retry_offset
+ state.cursor = state.cursor + retry_offset - offset + 1
+ if state.cursor == state.retry then
+ mp.msg.debug("bailing")
+ state.cursor = 0
+ return
+ end
+ state.retry = state.cursor
+ mp.msg.debug("retrying @ " .. state.cursor)
+ return
+ end
+
+ local entry_length = tonumber(entry_length_str)
+ state.cursor = state.cursor - entry_length - #entry_length_str - #whitespace - 1
+ history:seek("set", state.cursor)
+
+ local entry = history:read(entry_length)
+ if not entry then
+ mp.msg.debug("unreadable entry data @ " .. state.cursor)
+ return
+ end
+ local timestamp_str, title_length_str, file_info = entry:match("([^,]*),(%d*),(.*)")
+ if not timestamp_str then
+ mp.msg.debug("invalid entry data @ " .. state.cursor)
+ return
+ end
+
+ local timestamp = tonumber(timestamp_str)
+ timestamp = timestamp and os.date(options.timestamp_format, timestamp) or timestamp_str
+
+ local title_length = title_length_str ~= "" and tonumber(title_length_str) or 0
+ local full_path = file_info:sub(title_length + 2)
+
+ local display_path, save_path, effective_path, effective_protocol, is_remote, file_options = path_info(full_path)
+ local cache_key = effective_path .. display_path .. (file_options or "")
+
+ if options.hide_duplicates and state.known_files[cache_key] then
+ return
+ end
+
+ if dir_menu and is_remote then
+ return
+ end
+
+ if search_words and not options.use_titles then
+ for _, word in ipairs(search_words) do
+ if unaccent(display_path):lower():find(word, 1, true) == nil then
+ return
+ end
+ end
+ end
+
+ local dirname, basename
+
+ if is_remote then
+ state.existing_files[cache_key] = true
+ state.known_files[cache_key] = true
+ elseif options.hide_same_dir or dir_menu then
+ dirname, basename = mp.utils.split_path(display_path)
+ if dir_menu then
+ if dirname == "." then return end
+ local unix_dirname = dirname:gsub("\\", "/")
+ local parent, _ = mp.utils.split_path(unix_dirname:sub(1, -2))
+ local start, stop = find_path_prefix(parent, dir_menu_prefixes)
+ if not start then
+ return
+ end
+ basename = unix_dirname:match("/(.-)/", stop)
+ if basename == nil then return end
+ start, stop = dirname:find(basename, stop, true)
+ dirname = dirname:sub(1, stop + 1)
+ end
+ if state.known_dirs[dirname] then
+ return
+ end
+ if dirname ~= "." then
+ state.known_dirs[dirname] = true
+ end
+ end
+
+ if options.hide_deleted and not (search_words and options.use_titles) then
+ if state.known_files[cache_key] and not state.existing_files[cache_key] then
+ return
+ end
+ if not state.known_files[cache_key] then
+ local stat = mp.utils.file_info(effective_path)
+ if stat then
+ state.existing_files[cache_key] = true
+ elseif dir_menu then
+ state.known_files[cache_key] = true
+ local dir = mp.utils.split_path(effective_path)
+ if dir == "." then
+ return
+ end
+ stat = mp.utils.readdir(dir, "files")
+ if stat and next(stat) ~= nil then
+ full_path = dir
+ else
+ return
+ end
+ else
+ state.known_files[cache_key] = true
+ return
+ end
+ end
+ end
+
+ local title = file_info:sub(1, title_length)
+ if not options.use_titles then
+ title = ""
+ end
+
+ if dir_menu then
+ title = basename
+ elseif title == "" then
+ if is_remote then
+ title = display_path
+ else
+ local effective_display_path = display_path
+ if file_options then
+ effective_display_path = file_options
+ end
+ if not dirname then
+ dirname, basename = mp.utils.split_path(effective_display_path)
+ end
+ title = basename ~= "" and basename or display_path
+ if file_options then
+ title = display_path .. " " .. title
+ end
+ end
+ end
+
+ title = title:gsub("\n", " ")
+
+ if search_words and options.use_titles then
+ for _, word in ipairs(search_words) do
+ if unaccent(title):lower():find(word, 1, true) == nil then
+ return
+ end
+ end
+ end
+
+ if options.hide_deleted and (search_words and options.use_titles) then
+ if state.known_files[cache_key] and not state.existing_files[cache_key] then
+ return
+ end
+ if not state.known_files[cache_key] then
+ local stat = mp.utils.file_info(effective_path)
+ if stat then
+ state.existing_files[cache_key] = true
+ elseif dir_menu then
+ state.known_files[cache_key] = true
+ local dir = mp.utils.split_path(effective_path)
+ if dir == "." then
+ return
+ end
+ stat = mp.utils.readdir(dir, "files")
+ if stat and next(stat) ~= nil then
+ full_path = dir
+ else
+ return
+ end
+ else
+ state.known_files[cache_key] = true
+ return
+ end
+ end
+ end
+
+ if options.truncate_titles > 0 then
+ local title_chars, title_width = utf8_table(title)
+ if title_width > options.truncate_titles then
+ local extension = string.match(title, "%.([^.][^.][^.]?[^.]?)$") or ""
+ local extra = #extension + 4
+ local title_sub, end_index = utf8_subwidth(title_chars, 1, options.truncate_titles - 3 - extra)
+ local title_trim = title_sub:gsub("[] ._'()?![]+$", "")
+ local around_extension = ""
+ if title_trim == "" then
+ title_trim = utf8_subwidth(title_chars, 1, options.truncate_titles - 3)
+ else
+ extra = extra + #title_sub - #title_trim
+ around_extension = utf8_subwidth_back(title_chars, extra)
+ end
+ if title_trim == "" then
+ title = utf8_subwidth(title_chars, 1, options.truncate_titles)
+ else
+ title = title_trim .. "..." .. around_extension
+ end
+ end
+ end
+
+ state.known_files[cache_key] = true
+
+ local command = {"loadfile", full_path, "replace"}
+
+ if file_options then
+ command[2] = display_path
+ for _, arg in ipairs(loadfile_compat(file_options)) do
+ table.insert(command, arg)
+ end
+ end
+
+ table.insert(menu_items, {title = title, hint = timestamp, value = command})
+ end
+
+ local item_count = -1
+ local attempts = 0
+
+ while #menu_items < entries do
+ if state.cursor - max_digits_length <= 0 then
+ break
+ end
+
+ if osd_update then
+ local time = mp.get_time()
+ if time > osd_update then
+ draw_menu()
+ end
+ end
+
+ if not return_items and (attempts > 0 or not (prev_page or next_page)) and attempts % options.entries == 0 and #menu_items ~= item_count then
+ item_count = #menu_items
+ local temp_items = {unpack(menu_items)}
+ for i = 1, options.entries - item_count do
+ table.insert(temp_items, {value = {"ignore"}, keep_open = true})
+ end
+
+ table.insert(temp_items, {title = "Loading...", value = {"ignore"}, italic = "true", muted = "true", icon = "spinner", keep_open = true})
+
+ if next_page and state.current_page ~= 1 then
+ table.insert(temp_items, {value = {"ignore"}, keep_open = true})
+ end
+
+ menu_data = menu_json(temp_items, state.current_page)
+
+ if uosc_available then
+ uosc_update()
+ menu_shown = true
+ else
+ osd_update = mp.get_time() + 0.1
+ end
+ end
+
+ read_line()
+
+ attempts = attempts + 1
+ end
+
+ if return_items then
+ return menu_items
+ end
+
+ if options.pagination then
+ if #menu_items > 0 and state.cursor - max_digits_length > 0 then
+ table.insert(menu_items, {title = "Older entries", value = {"script-binding", "memo-next"}, italic = "true", muted = "true", icon = "navigate_next", keep_open = true})
+ end
+ if state.current_page ~= 1 then
+ table.insert(menu_items, {title = "Newer entries", value = {"script-binding", "memo-prev"}, italic = "true", muted = "true", icon = "navigate_before", keep_open = true})
+ end
+ end
+
+ menu_data = menu_json(menu_items, state.current_page)
+ state.pages[state.current_page] = menu_items
+ last_state = state
+
+ if uosc_available then
+ uosc_update()
+ else
+ draw_menu()
+ end
+
+ menu_shown = true
+end
+
+function file_load()
+ if options.enabled then
+ write_history()
+ elseif dyn_menu then
+ dyn_menu_update()
+ end
+
+ if menu_shown and last_state and last_state.current_page == 1 then
+ show_history(options.entries, false, false, true)
+ end
+end
+
+function idle()
+ event_loop_exhausted = false
+ if osd_update then
+ osd_update = nil
+ osd:update()
+ end
+end
+
+mp.register_script_message("uosc-version", function(version)
+ local function semver_comp(v1, v2)
+ local v1_iterator = v1:gmatch("%d+")
+ local v2_iterator = v2:gmatch("%d+")
+ for v2_num_str in v2_iterator do
+ local v1_num_str = v1_iterator()
+ if not v1_num_str then return true end
+ local v1_num = tonumber(v1_num_str)
+ local v2_num = tonumber(v2_num_str)
+ if v1_num < v2_num then return true end
+ if v1_num > v2_num then return false end
+ end
+ return false
+ end
+
+ local min_version = "5.0.0"
+ uosc_available = not semver_comp(version, min_version)
+end)
+
+mp.register_script_message("menu-ready", function(client_name)
+ dyn_menu = client_name
+ dyn_menu_update()
+end)
+
+function memo_close()
+ menu_shown = false
+ palette = false
+ if uosc_available then
+ mp.commandv("script-message-to", "uosc", "close-menu", "memo-history")
+ else
+ close_menu()
+ end
+end
+
+function memo_clear()
+ last_state = nil
+ search_words = nil
+ search_query = nil
+ menu_shown = false
+ palette = false
+ dir_menu = false
+end
+
+function memo_prev()
+ show_history(options.entries, false, true)
+end
+
+function memo_next()
+ show_history(options.entries, true)
+end
+
+function memo_search(...)
+ -- close REPL
+ mp.commandv("keypress", "ESC")
+
+ local words = {...}
+ if #words > 0 then
+ query = table.concat(words, " ")
+
+ if query ~= "" then
+ for i, word in ipairs(words) do
+ words[i] = unaccent(word):lower()
+ end
+ search_query = query
+ search_words = words
+ else
+ search_query = nil
+ search_words = nil
+ end
+ end
+
+ show_history(options.entries, false)
+end
+
+function parse_query_parts(query)
+ local pos, len, parts = query:find("%S"), query:len(), {}
+ while pos and pos <= len do
+ local first_char, part, pos_end = query:sub(pos, pos)
+ if first_char == '"' or first_char == "'" then
+ pos_end = query:find(first_char, pos + 1, true)
+ if not pos_end or pos_end ~= len and not query:find("^%s", pos_end + 1) then
+ parts[#parts + 1] = query:sub(pos + 1)
+ return parts
+ end
+ part = query:sub(pos + 1, pos_end - 1)
+ else
+ pos_end = query:find("%S%s", pos) or len
+ part = query:sub(pos, pos_end)
+ end
+ parts[#parts + 1] = part
+ pos = query:find("%S", pos_end + 2)
+ end
+ return parts
+end
+
+function memo_search_uosc(query)
+ if query ~= "" then
+ search_query = query
+ search_words = parse_query_parts(unaccent(query):lower())
+ else
+ search_query = nil
+ search_words = nil
+ end
+ event_loop_exhausted = false
+ show_history(options.entries, false, false, menu_shown and last_state)
+end
+
+-- update menu in mpv-menu-plugin
+function dyn_menu_update()
+ search_words = nil
+ event_loop_exhausted = false
+ local items = show_history(options.entries, false, false, false, true)
+ event_loop_exhausted = false
+
+ local menu = {
+ type = "submenu",
+ submenu = {}
+ }
+
+ if not options.enabled then
+ menu.submenu = {{title = "Add current file to memo", cmd = "script-binding memo-log"}, {type = "separator"}}
+ end
+
+ if items and #items > 0 then
+ local full_path, display_path, save_path, effective_path, effective_protocol, is_remote, file_options = get_full_path()
+ for _, item in ipairs(items) do
+ local cmd = string.format("%s \"%s\" %s %s %s",
+ item.value[1],
+ item.value[2]:gsub("\\", "\\\\"):gsub("\"", "\\\""),
+ item.value[3],
+ (item.value[4] or ""):gsub("\\", "\\\\"):gsub("\"", "\\\""):gsub("^(.+)$", "\"%1\""),
+ (item.value[5] or ""):gsub("\\", "\\\\"):gsub("\"", "\\\""):gsub("^(.+)$", "\"%1\"")
+ )
+ menu.submenu[#menu.submenu + 1] = {
+ title = item.title,
+ cmd = cmd,
+ shortcut = item.hint,
+ state = full_path == item.value[2] and {"checked"} or {}
+ }
+ end
+ if last_state.cursor > 0 then
+ menu.submenu[#menu.submenu + 1] = {title = "...", cmd = "script-binding memo-next"}
+ end
+ else
+ menu.submenu[#menu.submenu + 1] = {
+ title = "No entries",
+ state = {"disabled"}
+ }
+ end
+
+ mp.commandv("script-message-to", dyn_menu, "update", "memo", mp.utils.format_json(menu))
+end
+
+mp.register_script_message("memo-clear", memo_clear)
+mp.register_script_message("memo-search:", memo_search)
+mp.register_script_message("memo-search-uosc:", memo_search_uosc)
+
+mp.add_key_binding(nil, "memo-next", memo_next)
+mp.add_key_binding(nil, "memo-prev", memo_prev)
+mp.add_key_binding(nil, "memo-log", function()
+ write_history(true)
+
+ if menu_shown and last_state and last_state.current_page == 1 then
+ show_history(options.entries, false, false, true)
+ end
+end)
+mp.add_key_binding(nil, "memo-last", function()
+ if event_loop_exhausted then return end
+
+ local items
+ if last_state and last_state.current_page == 1 and options.hide_duplicates and options.hide_deleted and options.entries >= 2 and not search_words and not dir_menu then
+ -- menu is open and we for sure have everything we need
+ items = last_state.pages[1]
+ last_state = nil
+ show_history(0, false, false, false, true)
+ else
+ -- menu is closed or we may not have everything
+ local options_bak = shallow_copy(options)
+ options.pagination = false
+ options.hide_duplicates = true
+ options.hide_deleted = true
+ last_state = nil
+ search_words = nil
+ dir_menu = false
+ items = show_history(2, false, false, false, true)
+ options = options_bak
+ end
+ if items then
+ local item
+ local full_path, display_path, save_path, effective_path, effective_protocol, is_remote, file_options = get_full_path()
+ if #items >= 1 and not items[1].keep_open then
+ if items[1].value[2] ~= full_path then
+ item = items[1]
+ elseif #items >= 2 and not items[2].keep_open and items[2].value[2] ~= full_path then
+ item = items[2]
+ end
+ end
+
+ if item then
+ mp.commandv(unpack(item.value))
+ return
+ end
+ end
+ mp.osd_message("[memo] no recent files to open")
+end)
+mp.add_key_binding(nil, "memo-search", function()
+ if uosc_available then
+ palette = true
+ show_history(options.entries, false, false, true)
+ return
+ end
+ if menu_shown then
+ memo_close()
+ end
+ mp.commandv("script-message-to", "console", "type", "script-message memo-search: ")
+end)
+mp.add_key_binding("h", "memo-history", function()
+ if event_loop_exhausted then return end
+ last_state = nil
+ search_words = nil
+ dir_menu = false
+ show_history(options.entries, false)
+end)
+mp.register_script_message("memo-dirs", function(path_prefixes)
+ if event_loop_exhausted then return end
+ last_state = nil
+ search_words = nil
+ dir_menu = true
+ if path_prefixes then
+ dir_menu_prefixes = parse_path_prefixes(path_prefixes)
+ else
+ dir_menu_prefixes = options.path_prefixes
+ end
+ show_history(options.entries, false)
+end)
+
+mp.register_event("file-loaded", file_load)
+mp.register_idle(idle)
diff --git a/dotfiles/.config/mpv/scripts/thumbfast.lua b/dotfiles/.config/mpv/scripts/thumbfast.lua
new file mode 100644
index 0000000..2e2eb22
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/thumbfast.lua
@@ -0,0 +1,951 @@
+-- thumbfast.lua
+--
+-- High-performance on-the-fly thumbnailer
+--
+-- Built for easy integration in third-party UIs.
+
+--[[
+This Source Code Form is subject to the terms of the Mozilla Public
+License, v. 2.0. If a copy of the MPL was not distributed with this
+file, You can obtain one at https://mozilla.org/MPL/2.0/.
+]]
+
+local options = {
+ -- Socket path (leave empty for auto)
+ socket = "",
+
+ -- Thumbnail path (leave empty for auto)
+ thumbnail = "",
+
+ -- Maximum thumbnail generation size in pixels (scaled down to fit)
+ -- Values are scaled when hidpi is enabled
+ max_height = 200,
+ max_width = 200,
+
+ -- Scale factor for thumbnail display size (requires mpv 0.38+)
+ -- Note that this is lower quality than increasing max_height and max_width
+ scale_factor = 1,
+
+ -- Apply tone-mapping, no to disable
+ tone_mapping = "auto",
+
+ -- Overlay id
+ overlay_id = 42,
+
+ -- Spawn thumbnailer on file load for faster initial thumbnails
+ spawn_first = false,
+
+ -- Close thumbnailer process after an inactivity period in seconds, 0 to disable
+ quit_after_inactivity = 0,
+
+ -- Enable on network playback
+ network = false,
+
+ -- Enable on audio playback
+ audio = false,
+
+ -- Enable hardware decoding
+ hwdec = false,
+
+ -- Windows only: use native Windows API to write to pipe (requires LuaJIT)
+ direct_io = false,
+
+ -- Custom path to the mpv executable
+ mpv_path = "mpv"
+}
+
+mp.utils = require "mp.utils"
+mp.options = require "mp.options"
+mp.options.read_options(options, "thumbfast")
+
+local properties = {}
+local pre_0_30_0 = mp.command_native_async == nil
+local pre_0_33_0 = true
+local support_media_control = mp.get_property_native("media-controls") ~= nil
+
+function subprocess(args, async, callback)
+ callback = callback or function() end
+
+ if not pre_0_30_0 then
+ if async then
+ return mp.command_native_async({name = "subprocess", playback_only = true, args = args, env = "PATH="..os.getenv("PATH")}, callback)
+ else
+ return mp.command_native({name = "subprocess", playback_only = false, capture_stdout = true, args = args, env = "PATH="..os.getenv("PATH")})
+ end
+ else
+ if async then
+ return mp.utils.subprocess_detached({args = args}, callback)
+ else
+ return mp.utils.subprocess({args = args})
+ end
+ end
+end
+
+local winapi = {}
+if options.direct_io then
+ local ffi_loaded, ffi = pcall(require, "ffi")
+ if ffi_loaded then
+ winapi = {
+ ffi = ffi,
+ C = ffi.C,
+ bit = require("bit"),
+ socket_wc = "",
+
+ -- WinAPI constants
+ CP_UTF8 = 65001,
+ GENERIC_WRITE = 0x40000000,
+ OPEN_EXISTING = 3,
+ FILE_FLAG_WRITE_THROUGH = 0x80000000,
+ FILE_FLAG_NO_BUFFERING = 0x20000000,
+ PIPE_NOWAIT = ffi.new("unsigned long[1]", 0x00000001),
+
+ INVALID_HANDLE_VALUE = ffi.cast("void*", -1),
+
+ -- don't care about how many bytes WriteFile wrote, so allocate something to store the result once
+ _lpNumberOfBytesWritten = ffi.new("unsigned long[1]"),
+ }
+ -- cache flags used in run() to avoid bor() call
+ winapi._createfile_pipe_flags = winapi.bit.bor(winapi.FILE_FLAG_WRITE_THROUGH, winapi.FILE_FLAG_NO_BUFFERING)
+
+ ffi.cdef[[
+ void* __stdcall CreateFileW(const wchar_t *lpFileName, unsigned long dwDesiredAccess, unsigned long dwShareMode, void *lpSecurityAttributes, unsigned long dwCreationDisposition, unsigned long dwFlagsAndAttributes, void *hTemplateFile);
+ bool __stdcall WriteFile(void *hFile, const void *lpBuffer, unsigned long nNumberOfBytesToWrite, unsigned long *lpNumberOfBytesWritten, void *lpOverlapped);
+ bool __stdcall CloseHandle(void *hObject);
+ bool __stdcall SetNamedPipeHandleState(void *hNamedPipe, unsigned long *lpMode, unsigned long *lpMaxCollectionCount, unsigned long *lpCollectDataTimeout);
+ int __stdcall MultiByteToWideChar(unsigned int CodePage, unsigned long dwFlags, const char *lpMultiByteStr, int cbMultiByte, wchar_t *lpWideCharStr, int cchWideChar);
+ ]]
+
+ winapi.MultiByteToWideChar = function(MultiByteStr)
+ if MultiByteStr then
+ local utf16_len = winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, MultiByteStr, -1, nil, 0)
+ if utf16_len > 0 then
+ local utf16_str = winapi.ffi.new("wchar_t[?]", utf16_len)
+ if winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, MultiByteStr, -1, utf16_str, utf16_len) > 0 then
+ return utf16_str
+ end
+ end
+ end
+ return ""
+ end
+
+ else
+ options.direct_io = false
+ end
+end
+
+local file
+local file_bytes = 0
+local spawned = false
+local disabled = false
+local force_disabled = false
+local spawn_waiting = false
+local spawn_working = false
+local script_written = false
+
+local dirty = false
+
+local x, y
+local last_x, last_y
+
+local last_seek_time
+
+local effective_w, effective_h = options.max_width, options.max_height
+local real_w, real_h
+local last_real_w, last_real_h
+
+local script_name
+
+local show_thumbnail = false
+
+local filters_reset = {["lavfi-crop"]=true, ["crop"]=true}
+local filters_runtime = {["hflip"]=true, ["vflip"]=true}
+local filters_all = {["hflip"]=true, ["vflip"]=true, ["lavfi-crop"]=true, ["crop"]=true}
+
+local tone_mappings = {["none"]=true, ["clip"]=true, ["linear"]=true, ["gamma"]=true, ["reinhard"]=true, ["hable"]=true, ["mobius"]=true}
+local last_tone_mapping
+
+local last_vf_reset = ""
+local last_vf_runtime = ""
+
+local last_rotate = 0
+
+local par = ""
+local last_par = ""
+
+local last_crop = nil
+
+local last_has_vid = 0
+local has_vid = 0
+
+local file_timer
+local file_check_period = 1/60
+
+local allow_fast_seek = true
+
+local client_script = [=[
+#!/usr/bin/env bash
+MPV_IPC_FD=0; MPV_IPC_PATH="%s"
+trap "kill 0" EXIT
+while [[ $# -ne 0 ]]; do case $1 in --mpv-ipc-fd=*) MPV_IPC_FD=${1/--mpv-ipc-fd=/} ;; esac; shift; done
+if echo "print-text thumbfast" >&"$MPV_IPC_FD"; then echo -n > "$MPV_IPC_PATH"; tail -f "$MPV_IPC_PATH" >&"$MPV_IPC_FD" & while read -r -u "$MPV_IPC_FD" 2>/dev/null; do :; done; fi
+]=]
+
+local function get_os()
+ local raw_os_name = ""
+
+ if jit and jit.os and jit.arch then
+ raw_os_name = jit.os
+ else
+ if package.config:sub(1,1) == "\\" then
+ -- Windows
+ local env_OS = os.getenv("OS")
+ if env_OS then
+ raw_os_name = env_OS
+ end
+ else
+ raw_os_name = subprocess({"uname", "-s"}).stdout
+ end
+ end
+
+ raw_os_name = (raw_os_name):lower()
+
+ local os_patterns = {
+ ["windows"] = "windows",
+ ["linux"] = "linux",
+
+ ["osx"] = "darwin",
+ ["mac"] = "darwin",
+ ["darwin"] = "darwin",
+
+ ["^mingw"] = "windows",
+ ["^cygwin"] = "windows",
+
+ ["bsd$"] = "darwin",
+ ["sunos"] = "darwin"
+ }
+
+ -- Default to linux
+ local str_os_name = "linux"
+
+ for pattern, name in pairs(os_patterns) do
+ if raw_os_name:match(pattern) then
+ str_os_name = name
+ break
+ end
+ end
+
+ return str_os_name
+end
+
+local os_name = mp.get_property("platform") or get_os()
+
+local path_separator = os_name == "windows" and "\\" or "/"
+
+if options.socket == "" then
+ if os_name == "windows" then
+ options.socket = "thumbfast"
+ else
+ options.socket = "/tmp/thumbfast"
+ end
+end
+
+if options.thumbnail == "" then
+ if os_name == "windows" then
+ options.thumbnail = os.getenv("TEMP").."\\thumbfast.out"
+ else
+ options.thumbnail = "/tmp/thumbfast.out"
+ end
+end
+
+local unique = mp.utils.getpid()
+
+options.socket = options.socket .. unique
+options.thumbnail = options.thumbnail .. unique
+
+if options.direct_io then
+ if os_name == "windows" then
+ winapi.socket_wc = winapi.MultiByteToWideChar("\\\\.\\pipe\\" .. options.socket)
+ end
+
+ if winapi.socket_wc == "" then
+ options.direct_io = false
+ end
+end
+
+options.scale_factor = math.floor(options.scale_factor)
+
+local mpv_path = options.mpv_path
+local frontend_path
+
+if mpv_path == "mpv" and os_name == "windows" then
+ frontend_path = mp.get_property_native("user-data/frontend/process-path")
+ mpv_path = frontend_path or mpv_path
+end
+
+if mpv_path == "mpv" and os_name == "darwin" and unique then
+ -- TODO: look into ~~osxbundle/
+ mpv_path = string.gsub(subprocess({"ps", "-o", "comm=", "-p", tostring(unique)}).stdout, "[\n\r]", "")
+ if mpv_path ~= "mpv" then
+ mpv_path = string.gsub(mpv_path, "/mpv%-bundle$", "/mpv")
+ local mpv_bin = mp.utils.file_info("/usr/local/mpv")
+ if mpv_bin and mpv_bin.is_file then
+ mpv_path = "/usr/local/mpv"
+ else
+ local mpv_app = mp.utils.file_info("/Applications/mpv.app/Contents/MacOS/mpv")
+ if mpv_app and mpv_app.is_file then
+ mp.msg.warn("symlink mpv to fix Dock icons: `sudo ln -s /Applications/mpv.app/Contents/MacOS/mpv /usr/local/mpv`")
+ else
+ mp.msg.warn("drag to your Applications folder and symlink mpv to fix Dock icons: `sudo ln -s /Applications/mpv.app/Contents/MacOS/mpv /usr/local/mpv`")
+ end
+ end
+ end
+end
+
+local function vo_tone_mapping()
+ local passes = mp.get_property_native("vo-passes")
+ if passes and passes["fresh"] then
+ for k, v in pairs(passes["fresh"]) do
+ for k2, v2 in pairs(v) do
+ if k2 == "desc" and v2 then
+ local tone_mapping = string.match(v2, "([0-9a-z.-]+) tone map")
+ if tone_mapping then
+ return tone_mapping
+ end
+ end
+ end
+ end
+ end
+end
+
+local function vf_string(filters, full)
+ local vf = ""
+ local vf_table = properties["vf"]
+
+ if (properties["video-crop"] or "") ~= "" then
+ vf = "lavfi-crop="..string.gsub(properties["video-crop"], "(%d*)x?(%d*)%+(%d+)%+(%d+)", "w=%1:h=%2:x=%3:y=%4")..","
+ local width = properties["video-out-params"] and properties["video-out-params"]["dw"]
+ local height = properties["video-out-params"] and properties["video-out-params"]["dh"]
+ if width and height then
+ vf = string.gsub(vf, "w=:h=:", "w="..width..":h="..height..":")
+ end
+ end
+
+ if vf_table and #vf_table > 0 then
+ for i = #vf_table, 1, -1 do
+ if filters[vf_table[i].name] then
+ local args = ""
+ for key, value in pairs(vf_table[i].params) do
+ if args ~= "" then
+ args = args .. ":"
+ end
+ args = args .. key .. "=" .. value
+ end
+ vf = vf .. vf_table[i].name .. "=" .. args .. ","
+ end
+ end
+ end
+
+ if (full and options.tone_mapping ~= "no") or options.tone_mapping == "auto" then
+ if properties["video-params"] and properties["video-params"]["primaries"] == "bt.2020" then
+ local tone_mapping = options.tone_mapping
+ if tone_mapping == "auto" then
+ tone_mapping = last_tone_mapping or properties["tone-mapping"]
+ if tone_mapping == "auto" and properties["current-vo"] == "gpu-next" then
+ tone_mapping = vo_tone_mapping()
+ end
+ end
+ if not tone_mappings[tone_mapping] then
+ tone_mapping = "hable"
+ end
+ last_tone_mapping = tone_mapping
+ vf = vf .. "zscale=transfer=linear,format=gbrpf32le,tonemap="..tone_mapping..",zscale=transfer=bt709,"
+ end
+ end
+
+ if full then
+ vf = vf.."scale=w="..effective_w..":h="..effective_h..par..",pad=w="..effective_w..":h="..effective_h..":x=-1:y=-1,format=bgra"
+ end
+
+ return vf
+end
+
+local function calc_dimensions()
+ local width = properties["video-out-params"] and properties["video-out-params"]["dw"]
+ local height = properties["video-out-params"] and properties["video-out-params"]["dh"]
+ if not width or not height then return end
+
+ local scale = properties["display-hidpi-scale"] or 1
+
+ if width / height > options.max_width / options.max_height then
+ effective_w = math.floor(options.max_width * scale + 0.5)
+ effective_h = math.floor(height / width * effective_w + 0.5)
+ else
+ effective_h = math.floor(options.max_height * scale + 0.5)
+ effective_w = math.floor(width / height * effective_h + 0.5)
+ end
+
+ local v_par = properties["video-out-params"] and properties["video-out-params"]["par"] or 1
+ if v_par == 1 then
+ par = ":force_original_aspect_ratio=decrease"
+ else
+ par = ""
+ end
+end
+
+local info_timer = nil
+
+local function info(w, h)
+ local rotate = properties["video-params"] and properties["video-params"]["rotate"]
+ local image = properties["current-tracks/video"] and properties["current-tracks/video"]["image"]
+ local albumart = image and properties["current-tracks/video"]["albumart"]
+
+ disabled = (w or 0) == 0 or (h or 0) == 0 or
+ has_vid == 0 or
+ (properties["demuxer-via-network"] and not options.network) or
+ (albumart and not options.audio) or
+ (image and not albumart) or
+ force_disabled
+
+ if info_timer then
+ info_timer:kill()
+ info_timer = nil
+ elseif has_vid == 0 or (rotate == nil and not disabled) then
+ info_timer = mp.add_timeout(0.05, function() info(w, h) end)
+ end
+
+ local json, err = mp.utils.format_json({width=w * options.scale_factor, height=h * options.scale_factor, scale_factor=options.scale_factor, disabled=disabled, available=true, socket=options.socket, thumbnail=options.thumbnail, overlay_id=options.overlay_id})
+ if pre_0_30_0 then
+ mp.command_native({"script-message", "thumbfast-info", json})
+ else
+ mp.command_native_async({"script-message", "thumbfast-info", json}, function() end)
+ end
+end
+
+local function remove_thumbnail_files()
+ if file then
+ file:close()
+ file = nil
+ file_bytes = 0
+ end
+ os.remove(options.thumbnail)
+ os.remove(options.thumbnail..".bgra")
+end
+
+local activity_timer
+
+local function spawn(time)
+ if disabled then return end
+
+ local path = properties["path"]
+ if path == nil then return end
+
+ if options.quit_after_inactivity > 0 then
+ if show_thumbnail or activity_timer:is_enabled() then
+ activity_timer:kill()
+ end
+ activity_timer:resume()
+ end
+
+ local open_filename = properties["stream-open-filename"]
+ local ytdl = open_filename and properties["demuxer-via-network"] and path ~= open_filename
+ if ytdl then
+ path = open_filename
+ end
+
+ remove_thumbnail_files()
+
+ local vid = properties["vid"]
+ has_vid = vid or 0
+
+ local args = {
+ mpv_path, "--no-config", "--msg-level=all=no", "--idle", "--pause", "--keep-open=always", "--really-quiet", "--no-terminal",
+ "--load-scripts=no", "--osc=no", "--ytdl=no", "--load-stats-overlay=no", "--load-osd-console=no", "--load-auto-profiles=no",
+ "--edition="..(properties["edition"] or "auto"), "--vid="..(vid or "auto"), "--no-sub", "--no-audio",
+ "--start="..time, allow_fast_seek and "--hr-seek=no" or "--hr-seek=yes",
+ "--ytdl-format=worst", "--demuxer-readahead-secs=0", "--demuxer-max-bytes=128KiB",
+ "--vd-lavc-skiploopfilter=all", "--vd-lavc-software-fallback=1", "--vd-lavc-fast", "--vd-lavc-threads=2", "--hwdec="..(options.hwdec and "auto" or "no"),
+ "--vf="..vf_string(filters_all, true),
+ "--sws-scaler=fast-bilinear",
+ "--video-rotate="..last_rotate,
+ "--ovc=rawvideo", "--of=image2", "--ofopts=update=1", "--o="..options.thumbnail
+ }
+
+ if not pre_0_30_0 then
+ table.insert(args, "--sws-allow-zimg=no")
+ end
+
+ if support_media_control then
+ table.insert(args, "--media-controls=no")
+ end
+
+ if os_name == "darwin" and properties["macos-app-activation-policy"] then
+ table.insert(args, "--macos-app-activation-policy=accessory")
+ end
+
+ if os_name == "windows" or pre_0_33_0 then
+ table.insert(args, "--input-ipc-server="..options.socket)
+ elseif not script_written then
+ local client_script_path = options.socket..".run"
+ local script = io.open(client_script_path, "w+")
+ if script == nil then
+ mp.msg.error("client script write failed")
+ return
+ else
+ script_written = true
+ script:write(string.format(client_script, options.socket))
+ script:close()
+ subprocess({"chmod", "+x", client_script_path}, true)
+ table.insert(args, "--scripts="..client_script_path)
+ end
+ else
+ local client_script_path = options.socket..".run"
+ table.insert(args, "--scripts="..client_script_path)
+ end
+
+ table.insert(args, "--")
+ table.insert(args, path)
+
+ spawned = true
+ spawn_waiting = true
+
+ subprocess(args, true,
+ function(success, result)
+ if spawn_waiting and (success == false or (result.status ~= 0 and result.status ~= -2)) then
+ spawned = false
+ spawn_waiting = false
+ options.tone_mapping = "no"
+ mp.msg.error("mpv subprocess create failed")
+ if not spawn_working then -- notify users of required configuration
+ if options.mpv_path == "mpv" then
+ if properties["current-vo"] == "libmpv" then
+ if options.mpv_path == mpv_path then -- attempt to locate ImPlay
+ mpv_path = "ImPlay"
+ spawn(time)
+ else -- ImPlay not in path
+ if os_name ~= "darwin" then
+ force_disabled = true
+ info(real_w or effective_w, real_h or effective_h)
+ end
+ mp.commandv("show-text", "thumbfast: ERROR! cannot create mpv subprocess", 5000)
+ mp.commandv("script-message-to", "implay", "show-message", "thumbfast initial setup", "Set mpv_path=PATH_TO_ImPlay in thumbfast config:\n" .. string.gsub(mp.command_native({"expand-path", "~~/script-opts/thumbfast.conf"}), "[/\\]", path_separator).."\nand restart ImPlay")
+ end
+ else
+ mp.commandv("show-text", "thumbfast: ERROR! cannot create mpv subprocess", 5000)
+ if os_name == "windows" and frontend_path == nil then
+ mp.commandv("script-message-to", "mpvnet", "show-text", "thumbfast: ERROR! install standalone mpv, see README", 5000, 20)
+ mp.commandv("script-message", "mpv.net", "show-text", "thumbfast: ERROR! install standalone mpv, see README", 5000, 20)
+ end
+ end
+ else
+ mp.commandv("show-text", "thumbfast: ERROR! cannot create mpv subprocess", 5000)
+ -- found ImPlay but not defined in config
+ mp.commandv("script-message-to", "implay", "show-message", "thumbfast", "Set mpv_path=PATH_TO_ImPlay in thumbfast config:\n" .. string.gsub(mp.command_native({"expand-path", "~~/script-opts/thumbfast.conf"}), "[/\\]", path_separator).."\nand restart ImPlay")
+ end
+ end
+ elseif success == true and (result.status == 0 or result.status == -2) then
+ if not spawn_working and properties["current-vo"] == "libmpv" and options.mpv_path ~= mpv_path then
+ mp.commandv("script-message-to", "implay", "show-message", "thumbfast initial setup", "Set mpv_path=ImPlay in thumbfast config:\n" .. string.gsub(mp.command_native({"expand-path", "~~/script-opts/thumbfast.conf"}), "[/\\]", path_separator).."\nand restart ImPlay")
+ end
+ spawn_working = true
+ spawn_waiting = false
+ end
+ end
+ )
+end
+
+local function run(command)
+ if not spawned then return end
+
+ if options.direct_io then
+ local hPipe = winapi.C.CreateFileW(winapi.socket_wc, winapi.GENERIC_WRITE, 0, nil, winapi.OPEN_EXISTING, winapi._createfile_pipe_flags, nil)
+ if hPipe ~= winapi.INVALID_HANDLE_VALUE then
+ local buf = command .. "\n"
+ winapi.C.SetNamedPipeHandleState(hPipe, winapi.PIPE_NOWAIT, nil, nil)
+ winapi.C.WriteFile(hPipe, buf, #buf + 1, winapi._lpNumberOfBytesWritten, nil)
+ winapi.C.CloseHandle(hPipe)
+ end
+
+ return
+ end
+
+ local command_n = command.."\n"
+
+ if os_name == "windows" then
+ if file and file_bytes + #command_n >= 4096 then
+ file:close()
+ file = nil
+ file_bytes = 0
+ end
+ if not file then
+ file = io.open("\\\\.\\pipe\\"..options.socket, "r+b")
+ end
+ elseif pre_0_33_0 then
+ subprocess({"/usr/bin/env", "sh", "-c", "echo '" .. command .. "' | socat - " .. options.socket})
+ return
+ elseif not file then
+ file = io.open(options.socket, "r+")
+ end
+ if file then
+ file_bytes = file:seek("end")
+ file:write(command_n)
+ file:flush()
+ end
+end
+
+local function draw(w, h, script)
+ if not w or not show_thumbnail then return end
+ if x ~= nil then
+ local scale_w, scale_h = options.scale_factor ~= 1 and (w * options.scale_factor) or nil, options.scale_factor ~= 1 and (h * options.scale_factor) or nil
+ if pre_0_30_0 then
+ mp.command_native({"overlay-add", options.overlay_id, x, y, options.thumbnail..".bgra", 0, "bgra", w, h, (4*w), scale_w, scale_h})
+ else
+ mp.command_native_async({"overlay-add", options.overlay_id, x, y, options.thumbnail..".bgra", 0, "bgra", w, h, (4*w), scale_w, scale_h}, function() end)
+ end
+ elseif script then
+ local json, err = mp.utils.format_json({width=w, height=h, scale_factor=options.scale_factor, x=x, y=y, socket=options.socket, thumbnail=options.thumbnail, overlay_id=options.overlay_id})
+ mp.commandv("script-message-to", script, "thumbfast-render", json)
+ end
+end
+
+local function real_res(req_w, req_h, filesize)
+ local count = filesize / 4
+ local diff = (req_w * req_h) - count
+
+ if (properties["video-params"] and properties["video-params"]["rotate"] or 0) % 180 == 90 then
+ req_w, req_h = req_h, req_w
+ end
+
+ if diff == 0 then
+ return req_w, req_h
+ else
+ local threshold = 5 -- throw out results that change too much
+ local long_side, short_side = req_w, req_h
+ if req_h > req_w then
+ long_side, short_side = req_h, req_w
+ end
+ for a = short_side, short_side - threshold, -1 do
+ if count % a == 0 then
+ local b = count / a
+ if long_side - b < threshold then
+ if req_h < req_w then return b, a else return a, b end
+ end
+ end
+ end
+ return nil
+ end
+end
+
+local function move_file(from, to)
+ if os_name == "windows" then
+ os.remove(to)
+ end
+ -- move the file because it can get overwritten while overlay-add is reading it, and crash the player
+ os.rename(from, to)
+end
+
+local function seek(fast)
+ if last_seek_time then
+ run("async seek " .. last_seek_time .. (fast and " absolute+keyframes" or " absolute+exact"))
+ end
+end
+
+local seek_period = 3/60
+local seek_period_counter = 0
+local seek_timer
+seek_timer = mp.add_periodic_timer(seek_period, function()
+ if seek_period_counter == 0 then
+ seek(allow_fast_seek)
+ seek_period_counter = 1
+ else
+ if seek_period_counter == 2 then
+ if allow_fast_seek then
+ seek_timer:kill()
+ seek()
+ end
+ else seek_period_counter = seek_period_counter + 1 end
+ end
+end)
+seek_timer:kill()
+
+local function request_seek()
+ if seek_timer:is_enabled() then
+ seek_period_counter = 0
+ else
+ seek_timer:resume()
+ seek(allow_fast_seek)
+ seek_period_counter = 1
+ end
+end
+
+local function check_new_thumb()
+ -- the slave might start writing to the file after checking existance and
+ -- validity but before actually moving the file, so move to a temporary
+ -- location before validity check to make sure everything stays consistant
+ -- and valid thumbnails don't get overwritten by invalid ones
+ local tmp = options.thumbnail..".tmp"
+ move_file(options.thumbnail, tmp)
+ local finfo = mp.utils.file_info(tmp)
+ if not finfo then return false end
+ spawn_waiting = false
+ local w, h = real_res(effective_w, effective_h, finfo.size)
+ if w then -- only accept valid thumbnails
+ move_file(tmp, options.thumbnail..".bgra")
+
+ real_w, real_h = w, h
+ if real_w and (real_w ~= last_real_w or real_h ~= last_real_h) then
+ last_real_w, last_real_h = real_w, real_h
+ info(real_w, real_h)
+ end
+ if not show_thumbnail then
+ file_timer:kill()
+ end
+ return true
+ end
+
+ return false
+end
+
+file_timer = mp.add_periodic_timer(file_check_period, function()
+ if check_new_thumb() then
+ draw(real_w, real_h, script_name)
+ end
+end)
+file_timer:kill()
+
+local function clear()
+ file_timer:kill()
+ seek_timer:kill()
+ if options.quit_after_inactivity > 0 then
+ if show_thumbnail or activity_timer:is_enabled() then
+ activity_timer:kill()
+ end
+ activity_timer:resume()
+ end
+ last_seek_time = nil
+ show_thumbnail = false
+ last_x = nil
+ last_y = nil
+ if script_name then return end
+ if pre_0_30_0 then
+ mp.command_native({"overlay-remove", options.overlay_id})
+ else
+ mp.command_native_async({"overlay-remove", options.overlay_id}, function() end)
+ end
+end
+
+local function quit()
+ activity_timer:kill()
+ if show_thumbnail then
+ activity_timer:resume()
+ return
+ end
+ run("quit")
+ spawned = false
+ real_w, real_h = nil, nil
+ clear()
+end
+
+activity_timer = mp.add_timeout(options.quit_after_inactivity, quit)
+activity_timer:kill()
+
+local function thumb(time, r_x, r_y, script)
+ if disabled then return end
+
+ time = tonumber(time)
+ if time == nil then return end
+
+ if r_x == "" or r_y == "" then
+ x, y = nil, nil
+ else
+ x, y = math.floor(r_x + 0.5), math.floor(r_y + 0.5)
+ end
+
+ script_name = script
+ if last_x ~= x or last_y ~= y or not show_thumbnail then
+ show_thumbnail = true
+ last_x, last_y = x, y
+ draw(real_w, real_h, script)
+ end
+
+ if options.quit_after_inactivity > 0 then
+ if show_thumbnail or activity_timer:is_enabled() then
+ activity_timer:kill()
+ end
+ activity_timer:resume()
+ end
+
+ if time == last_seek_time then return end
+ last_seek_time = time
+ if not spawned then spawn(time) end
+ request_seek()
+ if not file_timer:is_enabled() then file_timer:resume() end
+end
+
+local function watch_changes()
+ if not dirty or not properties["video-out-params"] then return end
+ dirty = false
+
+ local old_w = effective_w
+ local old_h = effective_h
+
+ calc_dimensions()
+
+ local vf_reset = vf_string(filters_reset)
+ local rotate = properties["video-rotate"] or 0
+
+ local resized = old_w ~= effective_w or
+ old_h ~= effective_h or
+ last_vf_reset ~= vf_reset or
+ (last_rotate % 180) ~= (rotate % 180) or
+ par ~= last_par or last_crop ~= properties["video-crop"]
+
+ if resized then
+ last_rotate = rotate
+ info(effective_w, effective_h)
+ elseif last_has_vid ~= has_vid and has_vid ~= 0 then
+ info(effective_w, effective_h)
+ end
+
+ if spawned then
+ if resized then
+ -- mpv doesn't allow us to change output size
+ local seek_time = last_seek_time
+ run("quit")
+ clear()
+ spawned = false
+ spawn(seek_time or mp.get_property_number("time-pos", 0))
+ file_timer:resume()
+ else
+ if rotate ~= last_rotate then
+ run("set video-rotate "..rotate)
+ end
+ local vf_runtime = vf_string(filters_runtime)
+ if vf_runtime ~= last_vf_runtime then
+ run("vf set "..vf_string(filters_all, true))
+ last_vf_runtime = vf_runtime
+ end
+ end
+ else
+ last_vf_runtime = vf_string(filters_runtime)
+ end
+
+ last_vf_reset = vf_reset
+ last_rotate = rotate
+ last_par = par
+ last_crop = properties["video-crop"]
+ last_has_vid = has_vid
+
+ if not spawned and not disabled and options.spawn_first and resized then
+ spawn(mp.get_property_number("time-pos", 0))
+ file_timer:resume()
+ end
+end
+
+local function update_property(name, value)
+ properties[name] = value
+end
+
+local function update_property_dirty(name, value)
+ properties[name] = value
+ dirty = true
+ if name == "tone-mapping" then
+ last_tone_mapping = nil
+ end
+end
+
+local function update_tracklist(name, value)
+ -- current-tracks shim
+ for _, track in ipairs(value) do
+ if track.type == "video" and track.selected then
+ properties["current-tracks/video"] = track
+ return
+ end
+ end
+end
+
+local function sync_changes(prop, val)
+ update_property(prop, val)
+ if val == nil then return end
+
+ if type(val) == "boolean" then
+ if prop == "vid" then
+ has_vid = 0
+ last_has_vid = 0
+ info(effective_w, effective_h)
+ clear()
+ return
+ end
+ val = val and "yes" or "no"
+ end
+
+ if prop == "vid" then
+ has_vid = 1
+ end
+
+ if not spawned then return end
+
+ run("set "..prop.." "..val)
+ dirty = true
+end
+
+local function file_load()
+ clear()
+ spawned = false
+ real_w, real_h = nil, nil
+ last_real_w, last_real_h = nil, nil
+ last_tone_mapping = nil
+ last_seek_time = nil
+ if info_timer then
+ info_timer:kill()
+ info_timer = nil
+ end
+
+ calc_dimensions()
+ info(effective_w, effective_h)
+end
+
+local function shutdown()
+ run("quit")
+ remove_thumbnail_files()
+ if os_name ~= "windows" then
+ os.remove(options.socket)
+ os.remove(options.socket..".run")
+ end
+end
+
+local function on_duration(prop, val)
+ allow_fast_seek = (val or 30) >= 30
+end
+
+mp.observe_property("current-tracks/video", "native", function(name, value)
+ if pre_0_33_0 then
+ mp.unobserve_property(update_tracklist)
+ pre_0_33_0 = false
+ end
+ update_property(name, value)
+end)
+
+mp.observe_property("track-list", "native", update_tracklist)
+mp.observe_property("display-hidpi-scale", "native", update_property_dirty)
+mp.observe_property("video-out-params", "native", update_property_dirty)
+mp.observe_property("video-params", "native", update_property_dirty)
+mp.observe_property("vf", "native", update_property_dirty)
+mp.observe_property("tone-mapping", "native", update_property_dirty)
+mp.observe_property("demuxer-via-network", "native", update_property)
+mp.observe_property("stream-open-filename", "native", update_property)
+mp.observe_property("macos-app-activation-policy", "native", update_property)
+mp.observe_property("current-vo", "native", update_property)
+mp.observe_property("video-rotate", "native", update_property)
+mp.observe_property("video-crop", "native", update_property)
+mp.observe_property("path", "native", update_property)
+mp.observe_property("vid", "native", sync_changes)
+mp.observe_property("edition", "native", sync_changes)
+mp.observe_property("duration", "native", on_duration)
+
+mp.register_script_message("thumb", thumb)
+mp.register_script_message("clear", clear)
+
+mp.register_event("file-loaded", file_load)
+mp.register_event("shutdown", shutdown)
+
+mp.register_idle(watch_changes)
diff --git a/dotfiles/.config/mpv/scripts/uosc/char-conv/zh.json b/dotfiles/.config/mpv/scripts/uosc/char-conv/zh.json
new file mode 100644
index 0000000..d1ada3b
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/char-conv/zh.json
@@ -0,0 +1,405 @@
+{
+ "a": "阿啊呵腌嗄锕錒",
+ "ai": "爱唉挨碍哀矮埃哎艾癌隘蔼嗳皑霭捱暧瑷娭砹锿嫒薆䔽㤅鴱㗨藹㕌磑礙硋䑂愛壒㘷叆靉䨠毐塧靄璦㱯䶣瞹䀳濭溰溾曖昹啀噯嘊㗒㝶䝽敱敳賹懓懝㢊銰鑀鱫鎄皧皚馤躷䅬㿄凒娾嬡伌㑸僾餲䬵譪譺",
+ "an": "安按案暗岸氨俺铵胺鞍黯庵桉谙鮟鹌咹犴广厂埯揞菴蓭荌萻葊隌㸩䮗㱘鵪豻貋腤雸堓垵䎨玵䁆洝晻唵啽㽢罯㟁屵䯥峖鞌䅁錌銨䅖馣痷鶕闇媕㜝盫儑侒䬓偣韽盦䎏䜙諳誝",
+ "ang": "昂肮盎䩕䒢䭹䭺䀚昻㦹㼜骯岇醠㭿枊䍩仰",
+ "ao": "傲熬凹遨嗷奥拗澳袄懊坳敖翱螯鳌鏖岙媪鏊骜艹聱獒廒蔜芺隞隩䮯厫磝䦋奡镺䐿䞝垇㘬墺㘭璈嗸嶅䫨㥿驁鰲鷔䵅摮嫯鼇謷䁱滶澚㕭軪䯠㟼㠂㠗岰慠㤇爊襖䥝獓翶擙抝䚫梎柪翺嶴䴈奧㿰㜜媼㜩㑃謸䜒",
+ "ba": "把八吧巴爸罢拔霸坝叭芭扒跋疤靶耙粑笆钯伯茇菝灞岜鲅捌魃䩻䩗夿䳊䃻㔜胈鼥壩垻豝玐㶚蚆㖠跁䟦哵罷軷㞎炦鈀鲃䥯䰾鮁䳁䱝魞釟抜㧊扷覇柭欛朳叐矲䆉羓䇑丷妭颰癹仈弝詙",
+ "bai": "白百摆败拜柏呗掰伯稗捭佰薭䒔㼣㓦瓸㗗㗑贁㠔䢙敗韛粨庍粺䙓襬猈拝㼟擺䳆挀㿟㧳栢䴽竡絔",
+ "ban": "办般半班板伴版搬斑扮颁瓣拌扳绊坂阪舨瘢柈钣癍靽䕰㚘瓪湴昄蝂岅肦怑粄䉽魬鈑鉡㩯㸞秚褩螌辦㪵闆辬姅頒鳻攽䬳絆斒",
+ "bang": "帮邦膀棒傍榜绑梆磅蚌旁镑谤浜蒡䧛䂜幚邫垹鞤幇幫䎧塝玤蜯䖫䟺髈䰷鎊挷捠搒㯁㭋㮄棓牓稖艕㔙㾦綁縍謗彭",
+ "bao": "保报包胞宝暴抱薄剥炮爆饱堡孢豹瀑刨鲍苞雹葆曝褒鸨褓煲龅趵勹蕔菢藵犦髱䨌䨔㙅靌報堢㙸虣珤㻄齙㵡㿺曓㫧蚫骲鳵怉䎂寳寚寶䴐宲窇䥤鑤㲒鮑䳈鉋铇勽䤖枹䈏㲏忁笣䪨闁媬儤賲䳰佨飹飽䭋駂鴇剝緥袌裦襃",
+ "bei": "被北备背倍悲贝杯辈臂卑碑呗狈惫钡悖孛蓓焙陂碚褙勃鞴鐾庳鹎邶䔒鞁藣苝犕㸬㸽牬盃愂䎬䎱琲㰆珼䁅輩䩀㻗㶔昁蛽䠙唄䡶㽡郥骳貝㤳糒禙鋇䰽狽㔨鉳㼎鵯揹柸桮梖椑㸢㓈㾱鄁軰㛝僃備憊㷶偹俻偝㣁䋳誖",
+ "ben": "本奔笨苯贲坌夯畚锛奙逩坋㱵渀漰泍㤓㡷錛撪㨧捹桳㮺翉楍栟犇倴䬱",
+ "beng": "崩蹦绷甭迸泵嘣甏菶䩬鞛奟䳞埲䨻塴埄琫㱶琣嗙嵭㷯䙀祊鏰镚甮揼逬䭰痭閍伻㑟綳䋽繃絣蚌",
+ "bi": "比必笔毕避壁秘闭鼻币彼逼辟臂泌碧弊蔽鄙毙弼痹庇陛璧婢敝匕俾裨荸吡哔蓖贲襞铋秕毖愎髀篦睥畀妣筚薜萆芘荜滗濞跸嬖狴箅舭鞸䩛蓽㳼萞苾䕗䎵聛䧗驆駜䮡夶髲䭮觱㗉皕䏢腷毴貏䏶賁堛䟆㙄㘩㻫豍珌㱸㻶㹃睤䁹䀣湢滭幤㵥獘斃鄨幣鷩潷䨆沘㡀畢鷝㪤䖩螕蜌啚蹕䟤躃䠋嗶咇罼奰㘠貱䯗畁㡙㠲贔赑怶愊韠䪐躄繴㵨鼊怭屄邲煏熚廦䊧粃襅袐襣禆䃾鲾鏎鐴鉍鰏鮅獙鎞㧙魓㪏柀楅䣥㯇㮿柲榌㮰䵄朼梐䫁篳馝䇷箆筆䄶閉閇㓖閟痺䦘疕疪㚰妼鵖嬶佊偪朇佖䬛䫾饆飶㢰䋔㢶弻彃縪鄪䌟綼㿫毞坒粊㢸䘡詖诐佛拂",
+ "bian": "变便边遍编辩辨鞭扁贬辫蝙匾卞鳊汴砭弁苄碥忭煸褊窆笾缏䒪鞕䛒藊萹䪻鴘㺹玣㻞䁵㳎覍汳㝸㴜昪䡢峅貶惼炞糄鯾鯿獱猵鍽㣐抃揙㭓牑邉邊釆籩艑䉸箯徧稨閞㵷㦚辡辧辮辯緶編㲢甂変諚變",
+ "biao": "表标彪裱婊飚飙镖膘鳔俵骠镳飑髟瘭䔸藨蔈骉驫飆猋嫑磦脿爂臕墂㯱滮淲瀌贆幖㟽㠒飊熛褾錶鑣鏢㧼摽㯹標檦穮儦飇颩颮颷飈諘謤䞄",
+ "bie": "别憋瘪鳖蹩虌莂䏟蟞鱉龞彆鼈蛂䠥別襒䋢徶䭱䉲癟㿜㢼",
+ "bin": "宾滨斌彬濒缤鬓槟殡摈膑邠玢份频髌豳镔傧髩鬢鬂臏䐔霦豩璸瑸殯頻虨瀕濱濵汃髕賓顮䚔賔鑌擯檳梹椕儐繽",
+ "bing": "并病兵冰饼丙柄炳秉禀屏槟摒邴鞆鞞苪䓑陃靐垪眪昞昺蛃䗒怲庰寎窉鈵鮩鋲鉼掤抦㨀棅栤䴵幷䈂並竝偋倂併仒傡餠餅仌䋑氷稟誁",
+ "bo": "波伯播剥博玻勃拨柏脖卜搏泊驳膊舶簿渤簸菠箔跛薄钵铂僰帛礴饽钹亳啵檗鹁踣擘䪇葧萡蘗蔔㹀䂍䮂䮀駁駮驋礡盋䰊䫊㝿砵䶈肑胉䑈䞳郣鵓㪍䢌㱟碆浡㴾溊淿謈㬧㬍䗚䟛蹳嚗㗘㖕䯋髉髆㟑嶓懪孹糪愽㶿煿袹襮袯䙏襏鑮䥬鈸鋍鲌馎鮊鱍鉑鎛镈鉢狛猼瓟瓝㩭挬㩧撥欂桲秡䢪缽簙牔䭯馛馞䒄艊䍨䪬䍸癷侼癶仢僠䭦䬪餑餺紴䊿袰譒佛",
+ "bu": "不部步布补捕卜哺埔怖簿埠钚卟逋晡钸醭瓿䪁荹蔀䏽㘵埗㙛㻉歨歩堡䴝鳪䀯㳍吥咘踄轐峬䝵䪔悑庯䊇廍補鈈鈽錻鸔抪捗㨐柨鵏䴺䍌䒈䑰篰㾟勏郶䳝㚴佈䬏餔餢䋠誧",
+ "ca": "擦嚓礤遪礸䟃䵽攃",
+ "cai": "才采材财彩菜裁猜蔡踩睬䰂毝䐆埰䞗啋跴財㥒寀採棌䴭䣋㒲婇倸偲䌨綵䌽纔縩",
+ "can": "参残餐惨蚕灿掺惭璨孱粲骖黪薒䣟朁蠶叄參㕘叅驂䏼蝅蠺䗞䘉殘㱚㻮㣓䝳㛑澯湌㘔喰㽩黲慙䳻㨻㥇憯慘慚㦧燦爘䙁䗝㺑穇䅟䑶㿊䍼飡䫮㜗嬠傪儏䬫謲䛹",
+ "cang": "藏苍仓舱沧臧伧蒼㶓㵴濸滄螥嵢賶鑶獊欌艙䅮凔仺鸧傖倉鶬䢢",
+ "cao": "草操曹槽糙嘈漕螬艚蓸䏆艸騲䐬鼜䎭曺鄵嶆愺慅慒懆褿襙䄚鏪撡㯥肏䒑㜖",
+ "ce": "策测侧册厕栅恻拆䔴荝萴萗蓛厠䜺測畟冊㥽夨惻憡廁粣䊂㨲拺㩍敇筴䇲䈟笧筞簎箣側",
+ "cen": "参岑涔䃡䨙埁㻸嵾䯔㞥䲋䤁䅾篸笒",
+ "ceng": "层曾蹭噌驓䁬㬝嶒層䉕㣒竲",
+ "cha": "差察查茶插叉诧岔刹喳茬嚓楂杈碴汊搽衩姹槎馇镲锸猹檫靫䕓䓭䒲䰈䑘垞䶪䁟嗏䟕蹅嵖㣾㤞㢒㢎㢉䆛銟䲦䤩鑔鍤扠剎挿揷査臿䊬秅䑡艖䡨疀奼侘偛餷詧紁㪯詫㛳㫅",
+ "chai": "差柴拆钗豺侪虿瘥茝芆䓱蠆䘍袃肞㼮祡㳗囆喍釵犲㾹儕㑪訍",
+ "chan": "产颤阐缠禅铲掺潺馋蝉搀蟾忏谄孱谗巉廛羼崭蒇骣觇澶躔冁婵单剗蕆䩶韂䵐苂䧯㹌䣑硟䐮䑎壥㙻㶣㙴刬䀡覘㢟䂁湹瀍瀺潹㵌灛滻浐㬄蟐蟬螹旵䠨囅丳嚵䡲磛䡪幝幨辿嵼懴䪜㦃懺煘鄽㢆燀裧襜䥀酁劖毚䤫䱿鑱镵㹽鋋鋓獑㺥鏟摻摲攙摌醦䤘䊲棎欃梴㯆䴼㸥艬簅闡閳産剷嬋儳饞儃緾繟纏纒產譂顫諂䜛讒誗讇斺",
+ "chang": "长常场厂唱昌肠偿尝倡畅倘敞淌猖怅嫦娼氅菖昶徜鲳惝苌鬯阊伥萇䩨䕋長䯴镸瓺兏厰腸膓㙊場鼚塲瑺瑒琩玚仧淐甞嘗㦂䗅暢㫤䠆䠀嚐畼悵韔廠焻裮鋹鲿錩锠鱨鯧椙閶倀仩僘償誯裳",
+ "chao": "朝超潮吵巢抄嘲剿炒钞绰晁焯耖怊焣㷅䏚䎐眧巣漅鼌鼂罺轈巐䬤煼㶤窲窼觘鈔䰫樔麨牊䄻鄛欩仯仦弨謿訬",
+ "che": "车彻撤扯澈掣尺屮砗坼莗㱌䧪聅䨁硨䞣䰩頙迠瞮䁤㵔蛼㬚唓車㥉爡烢䚢撦㨋硩㿭㯙䑲䒆勶徹㾝偖伡俥㔭䋲䛸䜠",
+ "chen": "称陈沉晨臣尘趁衬辰嗔琛抻伧谶碜宸郴谌忱龀榇茞蔯莀䢻莐薼䒞陳螴敶磣䣅㲀㫳䢈敐硶䫖夦䢅䐜墋趂霃齓齔瞋㴴鷐迧曟踸䟢趻㕴嚫軙贂賝䞋愖煁麎塵襯䆣鍖鈂䤟捵栕樄桭梣棽櫬䚘䑣瘎疢㽸㧱儭諶䜟謓訦諃讖沈",
+ "cheng": "成程称城承诚呈乘惩撑澄秤橙逞丞骋盛瞠铛塍柽埕琤净抢蛏裎铖酲枨荿䔲䧕阷㞼騁䮪騬郕䫆㼩碀脭爯頳䞓赪赬塖堘珹靗珵睈䀕䁎洆浾泟澂㲂牚瀓溗蟶晿䗊畻峸憆悜憕庱宬窚竀䆑䆵䆸䄇鋮鐣鏿鯎掁摚撐挰㨃揨檉棖檙橕棦朾乗筬稱罉穪䇸徎懲娍偁侱㐼饓僜絾緽誠椉",
+ "chi": "吃持池尺赤迟斥齿翅驰耻痴弛炽哧侈嗤叱敕啻饬笞踟柢呎茌褫鸱勅墀蚩蚩豉眵螭魑匙篪瘛媸傺荎䠠㔑䔟䧝妛恥欼馳䮻䮈肔胵腟胣䐤趩赿䞾灻垑漦雴鵄䜵䜻彨彲銐殦䶔齝齒歯瞝懘㳏湁蚇蚳喫噄䟷翤叺㽚㞿㞴貾㟂恜翄㓾遲翨杘遅遟憏迡烾㢁㶴粚㡿㢋熾裭䙙袳鴟㱀䤲鍉卶鉹㺈鉓瓻䰡抶摛攡㮛鶒慗鷘遫㓼麶勑䳵竾䑛䇪箎筂䈕䇼黐䪧痸癡侙䶵伬䬜飭㒆㘜䊼絺㢮訵㙜䜄謘袲誺䛂",
+ "chong": "重充冲虫崇涌宠憧忡舂铳种茺艟隀憃埫珫沖漴浺㳘蟲蝩蹖嘃罿㓽翀爞崈寵褈銃摏揰㧤䳯䖝衝㹐緟䌬",
+ "chou": "抽筹仇丑愁臭酬畴瞅绸稠踌惆帱瘳俦雠䓓薵菗䔏遚魗㦞㐜殠矁㵞躊吜疇幬㤽懤燽䊭裯䲖鮘㿧㩅皗搊㨶梼檮醻酧醜椆杽栦籌䇺臰篘䪮嬦㛶丒儔䀺偢犨讐雔雦犫讎䌧綢紬䌷絒詶",
+ "chu": "出处除初础助楚触畜储厨锄橱雏躇矗搐刍蜍怵滁黜绌杵蹰亍憷樗楮蒢蒭䢺㔘欪䧁䮞犓㕑㕏礎貙臅㙇埱趎耡䎤㼥䎝豠豖珿䜴璴齣齭齼敊䖏處泏濋㶆滀蟵䟞䠧躕䟣嘼㗰歜㡡幮岀㤕㤘廚䊰䙘䙕禇鶵芻雛鋤鉏㐥觸㹼摴斶櫉櫥䠂椘檚榋篨䅳処䦌竐竌閦媰俶儊儲傗絀諔鄐",
+ "chuai": "揣啜踹膪搋膗㪜㪓䦤䦟䦷",
+ "chuan": "传船穿川串喘椽氚钏舛遄舡巛荈堾玔瑏㱛䁣汌暷踳圌輲歂㼷賗釧猭㯌篅舩僢傳剶鶨",
+ "chuang": "创床窗闯疮怆磢䃥䚎刱䎫㵂噇䡴㡖愴窓窻摐牎牕䇬剙剏闖䚒牀瘡刅傸䭚創幢",
+ "chui": "吹垂锤椎炊捶槌陲棰菙㝽腄䞼䶴㓃錘鎚搥桘㩾䳠䍋埀䄲箠㥨龡倕顀",
+ "chun": "春纯唇醇蠢淳椿莼鹑蝽䔚䓐萶萅蓴蒓陙㸪犉脣䫃惷䐏䏝䐇䏛旾瑃睶㵮浱漘滣湻暙㖺輴賰䞐䄝䥎鰆鯙錞㿤鶞槆杶䣩䣨醕櫄橁箺䦮媋偆純㝄鶉㝇",
+ "chuo": "戳绰辍龊啜淖踔辶䓎歠䮕磭䃗辵趠繛齪逴涰嚽踀哾輟惙䆯鑡㚟㲋擉酫䂐䄪䍳䇍婥娖娕餟䋘綽",
+ "ci": "此次差词刺磁辞雌慈兹瓷赐伺疵呲糍祠茨鹚䓧㹂茦莿薋䦻㤵辝䰍䯸䂣礠㓨辭辤蛓趀䨏珁玼刾䧳㘹䖪㠿鮆鴜䳄飺泚濨蠀䗹螆跐㘂骴髊賜䛐㞖䲿㡹庛㢀皉㩞朿柌栨䆅䈘齹垐䳐餈鶿鷀甆嬨佌偨佽䭣縒絘詞",
+ "cong": "从匆丛聪葱囱淙熜琮苁骢璁枞藂䕺茐蔥蓯孮聦聰聡騘驄瑽瞛潈潀灇潨漗漎蟌暰䟲賩悰愡憁爜叢賨錝鍯怱鏓鏦欉樷樬樅棇徔悤囪徖䉘篵従從䳷㼻婃忩繱誴謥",
+ "cou": "凑腠辏楱湊㫶輳",
+ "cu": "促粗簇醋卒蹙猝蹴徂趣趋蔟殂䓚觕㗤顣䃚䢐脨趗鼀䠞踧踿䠓噈怚䎌憱麤䙯䥄麁䥘䟟㰗橻瘄瘯媨麄䬨縬蹵䛤誎",
+ "cuan": "窜篡攒蹿撺爨镩汆䰖㸑殩㵀躥㠝巑熶竄䆘鑹攛櫕欑㭫簒穳",
+ "cui": "催翠脆粹崔摧萃悴瘁璀啐淬毳榱䃀磪䂱膵膬䄟㯔臎脃脺趡墔琗㧘㱖㵏漼濢㳃啛嵟慛㥞忰翆㷃䊫粋㷪焠㝮襊竁鏙皠㯜槯䧽䆊凗疩伜倅紣縗缞綷顇衰",
+ "cun": "存村寸忖皴吋刌壿邨膥澊踆籿拵䍎竴侟",
+ "cuo": "错措挫搓撮磋锉蹉矬厝脞鹾鹾嵯痤蔖剒逪莡蒫莝遳蓌䂳䐣瑳䣜虘鹺睉䠡䟶㽨嵳㟇錯䱜鎈銼醝䴾酂酇㿷剉夎",
+ "da": "大打达答搭瘩嗒哒鞑沓耷惮靼跶褡怛笪妲荙韃䩢薘剳荅䃮迖羍迏䐛䐊垯墶㙮逹達溚蟽噠迚呾咑䵣䳴眔㟷燵炟匒鎝鐽鎉撘㯚笚䑽龖龘㾑㜓㿯畣繨詚亣畗",
+ "dai": "代带待袋戴呆贷逮歹岱傣玳怠黛殆迨甙棣呔诒埭毒大绐帶䒫貣㞭黱叇霴靆瑇帯㻖瀻蝳㫹曃蚮蹛跢軩軑轪軚獃懛廗襶䚟䚞鴏㯂簤艜䈆㿃垈帒貸柋㐲侢㶡紿緿",
+ "dan": "但单担弹蛋淡胆丹旦氮诞耽郸掸惮疸眈赕澹啖箪膻石萏聃殚瘅儋蓞䩥匰耼聸馾駳髧砃䃫㽎腅膽䨢霮䨵玬殫頕㴷単泹㵅鴠㫜啿㗖鄲單噉㕪啗嘾唌嚪黮黕黵帎賧贉刐饏疍憚憺㡺瓭沊㱽褝襌衴窞禫甔觛䱋狚㺗撣㲷抌擔撢酖柦䄷䉞蜑簞䉷躭癉癚媅妉僤伔䭛餤弾彈紞繵訑勯亶㔊誕",
+ "dang": "当党荡挡档裆铛宕噹菪砀凼谠蘯蕩礑碭䑗雼圵趤壋垱璫珰瞊澢灙盪璗䣊䣣當黨瓽潒逿蟷嵣氹愓襠鐺擋攩檔欓簜簹筜艡䦒闣㜭婸儅譡讜",
+ "dao": "到道导倒岛刀蹈稻盗捣叨悼祷焘氘捯纛刂忉菿陦隯﨩隝䧂䲽壔翿燾瓙盜螩翢嶹嶌嶋禱禂鱽島㠀魛釖擣搗椡槝檤朷稲軇艔衜舠衟㿒導噵䆃辺䌦",
+ "de": "的地得德嘚底锝㤫悳惪㥁䙸䙷淂㝵㥀鍀㯖棏徳恴",
+ "dei": "得嘚",
+ "deng": "等灯登邓瞪凳澄蹬噔磴戥嶝镫簦䒭隥䮴墱璒䠬燈鐙櫈艠竳嬁鄧㲪覴豋",
+ "di": "的地第提低底敌帝弟抵递滴迪堤蒂缔笛涤狄嘀谛娣嫡邸诋砥棣碲柢睇骶荻觌坻氐镝籴羝蔕䩘鞮靮䩚蔋苖菧慸遰菂苐蔐藡隄聜阺墬埅䮤馰牴㹍髢䯼磾厎奃䂡腣坘䞶趆覿䨤埞墑䶍豴玓珶眱䴞䀿坔滌螮蝃㼵䗖蝭旳踶䟡蹢嚁呧唙啲䵠軧䍕頔嶳埊廸岻怟鸐䊮䣌㡳焍袛祶禘鉪㪆䢑釱觝䏑鯳䱃䱱鏑摕逓遞掋拞䀸梊杕枤㭽梑樀楴㰅㣙彽秪䑭䑯糴䨀媂僀仾俤偙弤㢩締詆啇敵甋遆諦翟",
+ "dia": "嗲",
+ "dian": "点电典店淀颠殿垫奠甸碘佃滇惦巅癫掂踮玷靛钿癜阽坫簟蒧蕇䓦䧃驔厧磹㼭䟍顛㒹電墊琔齻奌敁㓠澱㵤㶘蜔蹎跕嚸㸃點敟巔嵮巓壂㞟㥆㝪鈿攧槙椣橂槇䍄癲瘨㚲婰婝傎顚扂",
+ "diao": "掉雕吊钓刁叼调碉凋貂鲷屌铞铫藋䔙蓧䂽奝䂪鼦雿琱㪕瞗汈蛁虭䵲彫鵰䘟窎窵鋽銱錭鑃鯛魡鮉銚釣㹿鈟扚䠼簓䉆竨瘹刟鳭㒛伄弔盄弴調訋",
+ "die": "爹跌叠蝶迭碟谍喋牒堞蹀垤耋鲽瓞㦶戜苵㲲䴑䮢镻胅䏲臷趃䞕耊褺䠟䲀䞇㻡殜眰眣蜨曡㬪螲㫼昳哋咥跮疂氎疊疉畳嵽峌幉㥈惵㦅恎㷸褋䘭㲳鰈䳀挕㩹㩸楪㭯鴩艓牃㑙絰绖諜詄佚",
+ "ding": "定顶丁订钉盯叮鼎锭啶腚仃町铤酊疔碇耵玎靪薡萣艼聢䦺矴磸碠鼑濎㴿㫀蝊虰帄嵿忊顁㝎鐤饤錠釘頂㼗㐉椗奵飣訂",
+ "diu": "丢铥丟銩",
+ "dong": "动东冬洞懂冻董咚栋侗峒恫胴氡鸫硐胨垌岽菄苳蕫駧䂢腖霘鼕䞒埬涷湩蝀昸㖦㗢戙迵㢥崬崠鯟鮗挏氭㨂東㼯鶇鶫棟動徚䅍箽笗㐑䳉䵔㓊凍䍶嬞姛㜱娻㑈倲働諌",
+ "dou": "都斗读豆抖兜陡逗窦蚪痘渎吋蔸篼钭䕱荳䕆阧脰郖毭㪷㐙鬦鬪鬥鬬鬭浢唗唞吺斣㞳㢄㷆竇䄈饾鈄㨮兠梪酘橷枓乧闘閗㛒餖䬦䛠",
+ "du": "度独读毒督渡杜肚堵赌嘟笃睹妒都镀竺犊渎牍蠹黩阇芏髑椟靯韇䪅匵䓯荰犢㸿騳䮷䀾䐗皾䢱蠧䲧覩剢瓄琽㱩殰殬裻錖瀆涜䟻黷䫳賭厾韣韥䙱䄍鑟鍍獨贕櫝醏螙篤牘䅊秺䈞凟闍㾄妬嬻豄讀讟読",
+ "duan": "断段短端锻缎煅椴簖葮碫腶塅㱭瑖躖䠪耑褍鍴鍛毈籪媏偳緞斷㫁",
+ "dui": "对队堆兑碓敦追怼镦憝䔪薱隊陮磓䨺䨴垖塠㙂㳔㵽濧瀩㬣轛䯟㠚㟋憞䊚對懟祋鐓䇤頧鴭痽䇏兊兌䬈䬽綐鐜対譵譈",
+ "dun": "盾顿吨蹲敦钝墩囤沌遁盹炖趸惇砘礅躉驐犜碷遯㬿逇頓潡蜳噸踲蹾㥫庉燉鈍䤜獤撴伅墪撉",
+ "duo": "多夺朵躲踱度堕惰哆舵跺垛咄掇铎剁哚柁裰缍䩔䩣䒳墮陏陊刴朶敠毲剟鵽敪鬌奲尮奪䐾垜㙍趓㙐埵㻧㻔畓㖼跥䠤喥嚉崜憜墯㥩剫䙃䙟䙤䤻鐸饳鈬䫂䤪挅㧷挆柮桗椯㔍軃躱䅜䑨㣞敚凙䍴痥㛆夛㛊敓飿綞嚲亸䯬隋",
+ "e": "额恶俄饿呃鹅扼厄蛾娥峨愕鳄鄂遏萼腭颚讹噩谔婀锷垩轭屙阿咹鹗苊莪锇䩹䳬䓊㼢蕚䔾䕏阨鵈娿阸騀頋阏砐砈㕎礘磀硆砨㼂妿䞩堨堮迗䝈豟堊蝁惡琧悪䫷㱦珴齶歺睋湂涐蚅歞噁卾㓵顎咢鶚遌覨㗁䣞遻㖾吪呝軛囮軶岋㡋崿㟧㠋㟯峉峩㦍㷈廅額頞䆓䄉鈪匎㔩鑩鍔䱮鰪鱷鰐䳗䳘魤鋨鈋擜搹㩵㼰皒搤㧖枙櫮㮙䙳齃頟䖸鵝鵞䑥䑪閼妸姶僫偔餓餩譌讍䛖諤戹誐訛哦",
+ "ei": "诶欸",
+ "en": "嗯恩摁蒽奀峎䊐煾䅰䭡䭓䬶",
+ "er": "而二儿尔耳饵迩洱贰鲕珥鸸铒佴荋貳弍薾聏陑毦隭刵䎶駬䮘髶髵耏鴯䏪胹兒趰弐貮邇爾児洏咡㖇唲輀轜峏粫袻鉺鮞㧫樲栮㮕栭䣵尓衈㛅䎟㜨㚷䎠㒃侕尒餌䋙䌺㢽䋩誀",
+ "fa": "发法乏罚伐阀筏砝珐垡䒥藅茷蕟髪髮䂲坺㘺墢琺沷㳒灋浌㕹罸罰峜彂鍅瞂䣹栰橃笩䇅冹疺閥㛲姂佱発發傠",
+ "fan": "反范饭犯翻繁凡泛番烦返贩帆藩梵樊蕃矾幡钒畈璠蘩燔蹯匥薠䒦㝃軬䮳颿䭵膰䐪墦䪤凣䀟㴀䀀氾滼瀿盕汎噃㕨輽䡊轓軓㠶販䪛㤆憣忛煩籵畨䊩襎㼝鱕㸋鐇㺕釩払䣲礬蠜䫶鐢棥橎柉杋笲䉊笵籓範勫飜鷭䉒舧舤凢瀪緐䌓㶗䋣㽹羳嬎㜶嬏奿仮飯飰繙䋦䛀旙旛訉拚",
+ "fang": "方放房防仿访芳纺妨肪坊彷舫鲂钫匚枋邡㯐牥䦈髣眆淓汸昘昉蚄趽㕫㤃錺魴䲱鈁㧍堏㑂倣鶭紡瓬䢍鴋旊訪",
+ "fei": "非飞费肥废肺匪菲沸啡妃吠斐翡诽绯蜚扉霏腓痱悱芾榧狒淝鲱镄镄篚萉蕜䕁䕠陫騑騛䰁厞朏蜰䑔鼣胇靅奜猆靟䩁剕㐟䨽棐婓餥渄濷㵒蟦暃昲曊䠊胐㥱屝飛飝䨾廃廢裶䚨䤵鯡鐨㩌杮㭭櫠䈈馡䆏䉬癈疿婔俷緋㔗費誹䛍",
+ "fen": "分份奋粉纷愤氛芬粪坟焚吩酚忿汾雰玢鼢瀵鲼棼偾蕡䩿棻蒶隫㸮奮膹朌鼖䴅墳豮豶瞓濆昐蚡㖹轒幩帉岎憤翂燌黺糞黂㥹衯鐼鱝魵獖鈖㮥橨梤燓㷊枌馩馚躮秎羵㿎朆竕羒妢僨弅餴饙蚠炃紛䯨訜",
+ "feng": "风封丰锋峰奉凤缝蜂冯逢疯讽枫沣烽俸砜葑唪酆䒠䩼飌蘴碸䏎堼犎霻靊堸鴌琒盽湗灃溄浲漨㵯沨渢䟪鄷豐崶㡝賵赗峯㦀焨煈寷䙜鎽鋒鏠猦摓檒桻覂楓麷夆蠭㷭篈艂馮瘋妦仹凮凨凬鳳僼鳯風偑綘縫諷",
+ "fo": "佛坲梻仏",
+ "fou": "否不缶鴀䳕雬殕缹缻妚紑",
+ "fu": "复服夫富府父负副福妇附符付幅伏浮腐腹傅扶辐肤抚覆辅赋赴甫缚弗咐俯俘孵拂斧敷脯腑袱芙氟孚蝠阜匐麸釜涪馥凫驸茯讣蝮蚨苻呋罘稃芾跗拊茀趺伕鄜莩菔莩阝砩郛滏蜉呒幞赙赙怫黻黼祓鳆鲋桴绂艴绋荂芣葍䕎䓛䔰萯荴蕧䧞䮛駙䭸䯱㬼䯽髴砆䩉㕊䂤㚕鵩胕䨗䞜䞯䞸䞞韨䘄㙏䨱垘坿䝾邞琈豧玞畐㽬鶝鬴巿玸鳺䫍膚虙㐢㜑澓洑泭㳇㫙蝜蜅蚹䗄蚥哹踾䟔䟮嘸㕮咈罦輻畉䡍䍖輔輹㟊賦帗賻㠅岪翇㤱䪙韍㤔烰粰糐焤炥冨䘠袚褔衭襆複袝襥䃽禣祔鍢鈇頫負鰒鳧鮲鮒鮄鍑鳬鉜鉘䎅捬撫郙棴尃酜枎盙乶椨榑椱覄栿柎麬麩麱柫旉懯箙筟㓡䫝甶䠵䘀蛗峊鴔簠秿復稪艀䒇䒀䑧䵗彿笰乀竎㵗癁䦣㾈娐妋嬔婏媍婦䵾怤姇釡俛偩俌颫紱綒綍䋹䌿刜㪄縛䌗緮䋨絥弣紨紼諨訃㚆詂佛",
+ "ga": "嘎伽尬噶旮咖夹尕尜钆嘠錷釓魀玍",
+ "gai": "改该概盖钙溉芥丐垓赅戤陔葢蓋荄䏗瓂豥㕢䀭漑晐畡乢峐賅䪱忋祴鈣匃匄㧉摡槩槪㮣姟侅絠絯郂㱾賌該",
+ "gan": "感赶敢甘杆干肝乾柑竿赣尴苷秆橄坩擀绀酐泔玕灨旰矸澉淦疳䔈芉皯䃭尷尲趕幹榦倝迀鳱䲺攼尶盰澸漧㽏汵䵟骭䯎忓粓衦鳡鱤㺂魐檊桿䇞簳稈筸贑䤗贛凎仠凲紺詌",
+ "gang": "刚钢港纲岗杠缸冈扛肛戆罡筻犅牨矼堽堈䴚㽘㟵崗㟠剛岡焵焹釭䚗鎠鋼摃㧏掆槓㭎棡罁疘冮戅戇綱",
+ "gao": "高告搞稿膏糕羔镐篙睾皋诰槁藁锆杲缟槔郜菒䔌藳㚏夰䗣鼛櫜峼韟祮祰禞鋯鎬鷎㚖皐槹橰檺勂吿臯鷱筶㾸餻縞髙槀稾稁誥",
+ "ge": "个合各革格歌哥隔割葛阁戈胳颌鸽搁咯疙蛤骼铬膈嗝镉圪鬲硌盖哿塥虼袼搿舸䪂䩐鞈䕻戓㦴茖呄䧄牫騔㷴䐙肐䨣䘁䪺䫦臵鞷㵧滆滒䗘蛒㗆嗰轕輵㠷愅韚韐裓㝓䆟觡鎘亇饹鴚鮯鎶獦鉻犵匌挌㨰擱槅戨㢦櫊䈓㪾敋箇笴閣鴿䢔個佫佮彁諽䛋䛿謌",
+ "gei": "给給",
+ "gen": "根跟亘艮茛哏亙㫔揯搄㮓䫀",
+ "geng": "更耕耿庚梗哽埂羹赓颈鲠绠莄菮堩刯郠浭畊骾峺焿鹒賡鶊䱍䱎鯁䱭䱴挭椩㾘羮絚綆䌄緪縆䋁",
+ "gong": "工公共功供攻宫贡巩弓恭拱躬龚汞蚣珙肱红廾觥龷慐貢㔶䢼拲㭟䂬鞏䡗㧬㼦碽厷髸塨䢚㺬㫒唝嗊輁幊愩㤨熕宮觵匔匑栱㯯杛篢躳䇨㓋龏龔侊糼糿",
+ "gou": "构够句购狗沟勾钩拘苟垢篝枸媾佝诟笱岣鞲遘觏彀缑冓覯芶䃓豿撀㜌㝅㨌坸耇耉耈玽溝㳶蚼㗕啂㽛購䝭䞀韝煹㝤褠袧雊鈎鉤夠㺃搆構簼䑦痀姤緱訽詬",
+ "gu": "古故固顾姑骨鼓股谷孤估雇咕呱辜菇沽锢贾钴梏臌箍蛄汩蛊轱诂牯崮鸪鹘瞽痼鲴毂菰牿嘏罟觚酤巭薣盬㠬䓢蓇苽巬㠫夃㚉䜼䮩尳鴣㼋䀇脵皷鼔堌㯏䅽皼榖穀糓轂䍍䐨䶜䀦䵻䀰濲瀔淈泒蠱啒唃唂軲䡩䍛罛軱鶻崓愲祻鈷錮馉鮕鯝鈲䀜㧽扢橭棝榾柧杚箛稒笟篐㒴㽽凅㾶羖嫴傦餶逧僱䊺縎詁顧",
+ "gua": "挂瓜寡刮褂呱卦剐胍鸹括栝诖䒷劀騧趏坬颪啩踻叧罣冎剮歄㒷煱掛桰鴰䈑颳絓緺詿",
+ "guai": "怪拐乖䂯㽇罫恠叏夬㷇㧔柺枴箉䊽",
+ "guan": "关观管官惯馆贯冠灌罐棺斡倌纶矜盥莞鳏鹳掼涫䩪䪀鸛觀雚蒄覌礶瓘璭琯矔卝泴㴦潅丱䗆䗰躀輨䏓䎚悺慣爟㮡悹䙮䘾䙛窤祼鑵鳤鱹鱞鰥䲘錧鏆摜欟樌罆観筦䦎癏瘝痯関關闗舘館䌯遦貫毌䝺",
+ "guang": "光广逛胱犷潢咣桄茪黆炗垙珖洸㫛炚輄臦臩廣烡広灮炛銧獷姯僙俇",
+ "gui": "规贵归鬼桂轨柜硅龟跪瑰闺诡傀匮圭刽桧鲑癸皈炅鳜珪匦眭晷刿庋宄簋妫茥鞼匭蓕蘬㔳陒雟㸵騩䰎厬胿䝿㙺攰邽㪈郌䳏䞨垝昋鬹規槼嫢璝鬶椝瓌劌瞡瞆瞶䁛氿湀㲹蟡蛫螝貴䠩軌䯣䞈巂嶲恑庪廆袿䙆襘祪禬鑎䣀㩻觤亀鐀鱖鮭䲅鱥䤥猤摫撌㨳㧪櫃槻樻槶椢櫷檜筀歸龜䇈攱閨䍷䍯癐䐴嬀姽媯劊佹䌆詭帰",
+ "gun": "滚棍辊衮磙丨鲧绲蓘蔉䎾䃂㙥㯻睔滾䵪輥惃鯀鮌袞緄緷㫎䜇謴",
+ "guo": "国过果郭锅裹蝈埚帼聒虢椁腘粿掴蜾崞猓馘菓蔮聝䂸㞅䆐腂膕䐸堝墎㳀㶁淉漍濄蟈褁㖪㕵嘓啯㗻國囯輠囻囶圀幗過惈慖䙨鈛鍋鐹馃㚍懖摑楇䴹槨簂瘑䤋䬎餜彉綶彍涡",
+ "ha": "哈蛤虾铪鉿紦",
+ "hai": "还海孩害嗨亥骇咳氦嗐骸胲醢㜾駴駭㦟塰咍䯐㤥烸䱺㺔㨟㧡酼䠽䠹䇋妎饚餀",
+ "han": "含汉喊寒汗旱韩函涵罕憾焊憨翰撼邯悍捍酣瀚鼾蚶颔晗菡犴旰顸焓厂邗撖䕿䓿㽉䓍蔊莟顄凾圅馯駻厈䫲丆䏷䶃䐄爳䨡䖔㙳頇㙈垾韓㲦螒鶾䮧雗㙔䎯䧲琀䁔睅甝㵄漢涆澏浫㵎浛暵蜬虷㪋晘蜭蛿㘕㖤哻㘚㘎唅輚䍐崡嵅屽䍑㟏㟔熯㶰㸁䗙䘶䤴䥁釬銲魽鋎猂㺖鋡㨔扞皔㮀梒䈄馠筨兯閈闬㽳嫨㜦娢傼佄㒈㑵谽豃頷㼨䌍㢨䛞譀",
+ "hang": "行航杭巷夯沆吭绗颃苀垳䀪蚢䣈䟘貥㤚裄䴂魧筕笐䘕䦳絎斻頏迒䲳",
+ "hao": "好号毫耗豪浩郝壕嚎皓镐蒿嗥濠昊貉薅颢灏蚝嚆薃䒵茠薧聕䧚䧫䝞毜㬶䝥㘪淏㵆灝澔滈昦㬔暤暭晧曍䯫顥暠蠔㙱䪽號㕺噑哠嘷㞻㠙乚悎鰝獆獔獋皞皡皥皜㩝椃秏籇竓恏㚪侴䬉䜰傐儫㝀䚽鄗譹皋",
+ "he": "和合何河呵核喝荷吓贺赫盒颌褐鹤禾嗬壑诃涸阂阖劾貉龢翮菏盖盍曷纥蠚鞨䕣萂䒩䓼㹇䃒碋礉盇賀䶅貈䞦䚂㷤靏靎垎靍鸖齕㕡龁澕渮㵑䳚㬞螛毼㔠鹖㓭䫘鶡㕰嚇啝咊㗿哬嗃䵱䢗峆䳽㥺䪚㦦翯煂熆爀焃㷎籺粭熇燺袔寉鶴鑉釛鲄饸魺狢鉌皬㿣抲㭱㪃㰤㮫楁覈柇㭘㮝麧䴳篕䎋惒盉䅂闔癋閤閡姀郃敆頜㪉欱餄紇鶮訶訸詥謞苛",
+ "hei": "黑嘿嗨潶黒",
+ "hen": "很恨狠痕鞎䓳拫㯊佷詪",
+ "heng": "衡横恒哼亨珩鸻蘅桁㔰䒛胻脝㶇涥啈䯒恆悙烆䄓鑅撗橫鴴鵆姮䬖䬝",
+ "hong": "红洪宏轰鸿哄虹烘弘泓竑訇讧闳薨蕻荭黉鞃䩑葓䲨葒苰䧆耾硔翃䫺硡䃔䂫㬴黌垬霟霐䞑䨎玒沗玜䀧鬨澒鴻汯渱潂浤渹晎叿吰呍嚝㖓䍔䡌軣轟輷䡏屸羾灴䉺㶹粠焢翝䆖宖銾鉷鈜魟鋐鍧撔揈篊閧闀閎䪦竤闂妅娂仜䫹谾䜫谹谼紅紘纮㢬彋綋紭訌",
+ "hou": "后候厚猴侯喉吼逅篌齁骺堠鲎糇後瘊茩葔䂉㸸㕈鱟䞧豞睺洉㫗㬋䗔㗋㖃吽帿翵㤧翭䙈矦鲘䪷鮜鯸䳧銗犼㺅鍭郈垕㮢鄇䫛餱",
+ "hu": "互乎护呼户忽胡湖虎糊弧狐壶沪蝴葫瑚浒惚唬扈琥瓠囫鹄唿斛祜滹鄠鹕醐猢和许核觳虍轷岵怙煳烀鹱槲笏冱戽䩴芐萀㸦蔛匢匫䔯苸蔰䕶㕆鬍鶘鶦䭌綔瓳㪶䎁怘䮸膴䞱豰壺嗀縠㺉螜壷垀雽䨥䨼戸䁫虖歑虝雐鍙瀫沍淴汻䲵泘滬滸䗂昒昈㗅䠒嘑嘝嚛喖䍓軤幠恗䪝䊀䉿焀熩粐㝬寣隺鍸䚛鳠錿鱯鸌鰗魱鯱曶㫚㹱乕摢抇搰㿥䰧㨭楜㯛枑槴箶衚頶鵠䧼䇘戶䈸䉉乯簄㾰頀媩嫮嫭婟俿䬍餬䭍䭅弖絗護謼帍鳸㦿䛎戏",
+ "hua": "化话花划画华滑哗桦猾铧骅砉華鷨蕐黊蘤㭉䔢蒊驊硴夻磆䏦埖㓰䶤澅螖嘩㕦䠉㕷㕲呚㠏崋㟆㦊㦎糀鏵錵觟釫釪鋘䱻㚌撶摦搳㩇樺椛槬㮯枠杹䅿舙嬅婲畵畫劃婳姡嫿繣譁誮諣諙䛡話譮豁",
+ "huai": "坏怀淮槐徊踝蘾蘹䃶壊耲壞䴜瀤咶㠢懐懷櫰䈭㜳褱褢",
+ "huan": "还环换欢缓患幻唤焕寰桓痪宦涣豢獾浣奂洹圜鬟鹮垸萑漶逭锾鲩擐缳荁萈酄歡藧㿪㕕驩䭴䮝㹖貛䝠貆肒堚豲瓛環瑍雈睆䀨䀓澣澴㶎㵹渙㬊㬇㼫嚾喛喚還轘嵈䯘峘鴅懽㦥愌㡲糫煥䴟鵍寏䆠鍰䥧鐶镮奐烉鰀鯶鯇獂狟犿攌換梙槵㣪䈠歓䍺闤阛羦䦡瘓㓉孉嬛緩絙繯綄讙㪱",
+ "huang": "黄皇荒慌晃煌惶簧谎恍蝗磺凰隍幌徨潢璜湟肓篁蟥遑鳇癀䪄黃鷬葟㞷䮲騜奛䐵㬻䐠䑟墴塃趪䞹堭瑝䁜兤滉曂晄喤㡆崲䍿愰怳㤺熿䊣熀炾䊗宺鐄鎤鱑鰉鍠锽獚皝皩䳨㿠㨪揘榥櫎楻穔䅣艎韹㾠㾮媓偟餭䌙縨謊朚巟㠵衁諻詤",
+ "hui": "会回挥灰汇绘恢辉毁慧惠悔溃徽讳卉秽贿晦诙彗晖蛔桧诲喙洄荟珲蕙烩茴睢迴麾咴隳恚虺蟪缋蘳蔧薉匯㰥䕇藱薈隓䜐䧥芔䃣㥣靧䩈㩓毀毇䏨噕璤恵豗㱱㻅璯睳顪翽瞺頮颒滙湏洃泋潓輝濊瀈蛕㬩暳蚘蜖暉嚖嘒噅䫭囬廽逥圚廻㞧屷賄囘翙屶懳㤬憓恛翚翬烠烣燬㷐㷄煇燴寭袆䙡䙌褘禈鏸鐬䤧灳鮰獩㨤㩨㨹拻撝揮櫘槥檓橞檅楎篲䂕穢鰴幑䇻䅏徻闠阓痐瘣㜇彚媈嬒婎㒑僡會㑹佪儶餯㑰繢彙絵繪譿詼譭䛼譓䜋䛛諱詯誨堕",
+ "hun": "婚混昏魂浑棍荤馄珲诨溷阍葷蔒䧰鼲䰟琿殙睴睧尡渾涽䫟圂慁轋䡣昬睯忶㥵惛焝觨䚠掍㨡棔䴷䅙䅱閽婫倱俒㑮餛䛰諢",
+ "huo": "和活或火获货伙惑霍祸豁夥蠖嚯镬藿劐耠灬钬锪攉㦯韄䰥蒦騞奯剨臛耯靃眓矆矐䂄䁨濩湱瀖沎漷曤嚄嚿喐咟吙㗲㘞䯏旤雘㦜邩㸌煷窢䄀禍䄑䄆鑊䱛鈥鍃獲掝擭捇㨯檴䣶㯉穫秮䉟秳艧秴癨䦚閄彠彟佸俰貨䋭謋",
+ "ji": "机几基己期济及级计即极技记集际积纪急激既继击奇季鸡迹剂辑绩吉寄疾挤肌籍祭寂脊饥忌冀藉稽畸棘鲫叽圾嫉姬讥妓汲系伎缉唧骥羁髻悸瘠箕暨矶麂岌蓟亟戟跻诘犄荠稷畿霁嵇嵴屐蒺觊笈玑楫偈鱀勣芨咭其齐芰蕺剞赍殛乩洎虮戢跽哜墼鲚掎笄彐佶齑䓫䩯蘎鞿蘻蘮葪薊茤旡蕀蔇虀薺䓽焏際隮㤂䲯﨤㹄䯂驥䮺鳮䰏㞆㚡朞卙䦇惎諅磼磯䐀鶏膌䐕䐚鷄雞叝䨖趌䟌䞘䟇塉郆霵賷坖䣢耤耭垍賫㙫㙨霽㒫䢋㱞㻷㻑璣璾䶩茍㦸䁒㭰㲺㴕㴉湒濈瀱漃㳵泲鹡鶺漈潗済濟䗁螏蝍暩蟣嗘踖躤踑蹟蹐䠏躋跡㘍㗊㖢喞㗱嘰嚌羇羈轚擊檕罽輯毄㚻繋撃䍤䝸覬㡇䶓嶯㠖㞦㠍㥛忣㠱㥍丮鵋㞛愱懻妀庴廭㸄㲅襀襋禝禨錤觙觭銈銡鱾䤠鍓魥鰿魝魢鯚鯽鰶鱭鑙犱鏶鐖鑇㔕撠刏鬾魕㰟裚揤曁旣皀卽皍擠㨈鸄覉覊極㮟樭橶枅䤒檝㮨梞槣槉楖㭲檵機櫅䇫彶䚐嵆徛簊稘筓積臮箿稩躸䪢刉艥䒁鷑穊穄穖穧兾㾊痵癪㽺㾒㾵癠塈堲䳭姞䢳伋亼偮㑧飢饑谻㞃僟亽雧級綨績緁緝紀彑䋟継紒㡮幾㡭繼計韲齏剤劑齎齌㧀記誋譤譏䜞给",
+ "jia": "家加价假架甲夹佳嫁驾嘉贾钾稼颊伽挟迦枷荚戛拮浃胛袈痂颉镓岬笳珈蛱跏瘕袷葭恝郏铗莢䩡䕛斚犌戞㕅郟夾頰鵊㼪脥駕毠乫㔖鴐腵貑鴶㪴耞圿豭玾頬䁍䀹䀫浹泇蛺䖬唊斝䑝幏叚忦糘麚䴥裌鋏鉫鉀鎵猳拁抸扴㮖榎梜賈椵榢槚檟徦㿓婽傢價䛟",
+ "jian": "间见建件坚简渐减检践健尖监艰键肩兼鉴浅箭碱剪剑舰奸歼俭拣荐贱茧柬捡煎溅涧谏睑堑腱毽笺缄饯硷翦犍謇鲣僭锏缣囝鞯菅蒹戋戬湔趼踺蹇裥搛枧楗笕鹣牮谫戔韉靬韀鞬堅䵖㔋監鋻鍳鑒㯺譼虃囏艱蔪繭薦藆蕑蕳葌菺䧖䮿礷碊礛鬋䶠䩆礀磵礆堿麉䶬趝墹䵤鳽雃戩臶幵瑊珔䵡豜豣殱殲瑐蠒玪鹸鹻鹼見瞷睷瞼㓺瀳減洊瀐䤔漸濺瀽㶕澗湕㳨瀸暕鵑踐䟰跈轞䟅䭕賤䯛䯡賎帴㦗惤熞熸糋寋弿襺袸襉襇鑑鑬鳒鏩鰹鰔鰜鰎鑳㺝猏鐗鐧䥜鍵鐱鑯㨴挸揀擶揃㨵撿樫檻椷栫榗梘㰄椾検檢櫼箋㣤㔓䄯牋筧䅐馢籛䇟篯艦簡䉍徤䵛覵間覸冿鶼姧姦俴剣劍劎剱劒劔餞䬻䭠餰䭈㦰倹儉緘絸繝彅縑諓䛳譛鵳諫譾謭旔詃槛",
+ "jiang": "将讲江降奖蒋港匠疆浆姜僵酱桨缰绛犟强茳礓耩豇洚糨匞韁薑顜葁蔣䕬㹔膙塂壃䞪䙹畺殭䁰滰疅畕嵹翞糡鳉鱂摪摾橿櫤㯍夅䉃䒂奨醤㢡奬獎醬漿螀螿槳將傋䋌䥒繮勥謽絳弜弶講",
+ "jiao": "教叫较交觉角脚焦胶郊缴骄娇轿搅浇嚼校剿礁椒矫狡绞蕉酵窖饺跤佼侥皎蛟茭醮姣铰湫鲛峤艽噍挢敫徼僬鹪茮斠藠驕膠腳膲趭璬珓䂃䣤䴛䁶㳅灚澆漖䀊滘潐㬭曒蟜暞晈蟭䠛踋劋嘂嘄噭呌嘦轇轎較嶠㠐峧賋嶕嶣䪒憿憍煍烄燋䘨䆗窌䚩鱎鮫䥞獥鉸鐎㩰敎皭攪撹皦撟捁挍摷㰾譥釂㭂敽鷮敿矯徺臫笅穚簥筊㽲㽱虠䢒䴔鵁勦嬓嬌孂㚣僥龣儌餃鷦燞繳纐絞訆譑䜈",
+ "jie": "结解接阶界价节介姐借街揭届洁杰截皆戒捷竭劫桔藉诫秸睫楷芥婕拮孑诘疥嗟颉疖桀碣羯讦偈蚧毑袷家她卩喈骱鲒䕙鞊鞂蓵䔿菨莭㔾階卪岊犗礍䂝䯰䂶㛃镼砎䃈脻丯刦刧刼頡㔛劼㓤迼堺堦䣠琾疌玠䀷䁓潔尐滐蠽湝昅蛶蠘蜐蛣䗻蝔唶踕跲喼吤畍嶻崨幯㠹巀嵥岕悈屆㞯㦢㸅庎煯㝌衱袺褯衸㝏䥛觧鉣㘶鍻鎅鮚䰺䱄䲙魪狤擮㨗掲擑㨩掶搩杢㮮楬楐檞桝榤㮞椄徣䂒䅥節蠞稭㓗㾏㿍楶癤痎䇒媎媫嫅媘㑘倢偼䲸傑飷結䌖鶛誡訐詰誱謯䛺",
+ "jin": "进金今近仅紧尽禁劲津斤晋锦浸筋巾谨襟靳矜瑾烬噤缙觐馑堇荩卺赆廑衿钅槿妗蓳荕菫緊覲㝻歏黅藎䒺巹㹏矝厪㰹砛䐶墐壗晉㬜琎瑨殣琻勁珒璶璡齽䶖鹶漌溍浕濅堻濜㴆㬐䗯唫嚍䝲贐惍㶦煡燼寖䘳䆮祲觔釿錦釒㨷劤搢䖐䤐枃䫴㱈㯲㯸䑤凚嫤㶳盡䀆賮嬧僅仐侭伒僸饉䭙儘進縉䋮䌝紟謹䥆",
+ "jing": "经精境京静竟惊景睛镜径警晶劲竞净敬井颈茎鲸荆靖兢痉憬泾菁粳阱胫腈迳旌璟儆箐刭肼靓獍婧弪荊莖葏㢣蟼憼驚䔔聙頚㣏㕋脛鼱㘫坓汬丼璥靜靚䴖鶄殌璄巠剄頸鵛逕坙梷淨汫瀞㵾涇澋浄曔暻㬌踁䵞䡖幜麠麖宑穽鯨㹵猄鏡坕桱橸稉徑秔凈痙竸競竫竧妌婙婛俓傹経弳經綡䜘鶁亰旍誩",
+ "jiong": "炯窘迥炅颎冂扃蘏蘔褧駫駉澃䐃坰埛㷡煛泂浻煚㖥囧冋㢠冏䢛燛㤯烱逈㷗㓏㑋僒侰絅䌹綗熲顈",
+ "jiu": "就究九久旧酒救纠舅揪灸疚臼鸠厩赳韭咎桕啾柩鹫鬏玖阄僦匶萛韮匛䓘舊牞镹䊆䳔䳎慦㺩㺵殧齨䰗鬮㲃汣䡂㠇丩乆䊘㡱廏廐廄㶭麔䆒鯦勼匓捄摎㧕揂㩆欍柾朻樛杦舏䅢揫㐇鳩奺倃糾乣糺紤鷲䛮",
+ "ju": "具据局举剧句居巨距聚拒柜菊矩惧俱拘桔咀锯鞠橘踞驹沮瞿炬踽疽遽掬枸飓榘苣裾龃榉倨狙钜莒且车苴鞫犋雎琚屦窭锔醵椐讵蘜䕮䢹乬巪蒟輂埾陱聥犑駏驧駶駒䃊砠㪺䢸舉㐦擧鴡貗腒䏱鼳鼰毩毱弆壉趜埧㘲耟㠪歫䶙齟䶥郹䴗鶪㮂狊䋰勮豦劇愳虡眗䡞洰㳥挙湨澽涺泦泃淗趄昛蚷㬬蜛䗇蹫跙㘌躆跼跔踘啹罝㽤巈岠岨崌㞫鵙怇鶋懅懼䪕㥌屨㞐凥烥粔焗粷寠袓襷䆽窶䄔鉅鐻邭鋸鋦鮔匊䱟鮈鵴䱡據㩴㩀㨿挶䰬抅㐝拠檋櫸欅䣰䤎椇梮椈秬簴筥躹䅓艍䈮䵕閰姖娵㜘婮婅倶侷颶䜯繘詎䛯諊渠",
+ "juan": "卷倦捐圈娟鹃绢眷涓镌蠲鄄狷锩桊蔨菤奆朘腃臇埍睊睠淃瓹呟罥羂䳪脧惓慻焆㷷裐隽鋑䥴獧錈鎸鐫捲䚈䣺㯞䅌䡓勌劵䄅龹䖭帣巻餋弮勬絭姢䌸㢧絹㢾讂㪻",
+ "jue": "决觉绝掘嚼爵诀厥倔攫崛蕨獗撅噘抉镢蹶谲角孓噱橛珏矍鳜桷钁劂爝觖匷㓸芵蕝孒䦼矡駃砄蹷蟨憠鷢橜䐘䏣臄貜䏐䁷覺趉䞵䞷赽瑴䝌玨㻕玦亅䀗覐㵐決覚泬灍蟩䖼蚗虳噊䟾躩䠇趹爴䡈㟲嶡嶥崫㤜憰戄屩屫刔鴂爑㷾熦焳䙠䘿䆕䆢氒鐍鐝觼觮䦆鈌鴃玃㹟㩱挗㸕捔撧㰐㭾㭈櫭䍊䇶欮疦瘚弡彏䋉㔢絶㔃絕譎斍訣",
+ "jun": "军均菌君俊峻钧郡骏竣隽浚筠麇儁皲捃莙葰䕑陖皹駿鵕㕙碅㓴埈䝍㻒珺䜭濬汮㴫晙蜠蚐呁㽙畯賐懏燇麏麕皸軍袀㝦寯鲪銞馂鵔鮶鍕銁鈞攈攟棞桾箟箘䇹姰頵鵘覠㒞餕㑺雋龟",
+ "ka": "卡咔咖咯喀佧胩垰裃鉲䘔",
+ "kai": "开凯慨恺揩楷铠忾闿锴岂蒈垲剀锎䒓奒䐩塏䁗暟嘅䡷輆剴颽凱㡁嵦愷愾炌烗鎧㚊鎎鐦鍇開闓勓欬",
+ "kan": "看刊堪砍坎勘嵌侃槛瞰龛阚磡戡莰凵顑歁墈栞䶫鬫矙轗輡嵁崁惂冚欿衎㸝䘓㸔䀍竷闞龕偘",
+ "kang": "抗康炕扛慷亢糠鱇伉钪闶匟砊漮䡉囥嵻忼㱂粇㝩鏮犺鈧槺躿穅閌嫝邟㰠",
+ "kao": "考靠烤铐拷犒尻栲䐧攷丂洘䯌嵪㸆銬鲓鮳鯌䯪髛",
+ "ke": "可科克客刻课颗壳棵渴咳柯磕苛坷瞌窠蝌轲颏恪稞髁珂氪缂岢嗑剋尅呵骒溘蚵锞钶疴薖萪匼騍牱犐礚碦勊勀砢㕉堁殼殻㵣渇顆敤㪙趷礊軻嶱嵑㞹嵙峇愘炣㪡愙䙐錁翗鈳搕揢榼醘㐓㪼㤩衉艐痾㾧牁娔樖緙課頦",
+ "kei": "剋尅",
+ "ken": "肯恳垦啃龈裉㸧硍墾懇貇豤肻肎褃錹掯",
+ "keng": "坑吭铿硻阬牼硁硜䡰鏗鍞銵摼挳妔誙劥",
+ "kong": "空控孔恐箜倥崆鞚硿埪涳㤟悾鵼錓躻㸜",
+ "kou": "口扣寇叩抠佝蔻芤眍筘剾蔲瞉鷇㲄瞘滱䳟怐冦宼㓂窛釦敂䳹摳劶㔚簆彄",
+ "ku": "苦哭库枯裤酷窟挎骷绔袴刳堀喾䧊郀矻嚳㱠跍圐㠸庫廤㐣焅褲鮬狜楛桍䇢秙䵈瘔㒂俈絝",
+ "kua": "跨夸垮挎胯侉咵趶骻䯞銙舿姱誇䋀",
+ "kuai": "会快块筷脍侩狯哙蒯浍郐䓒巜膾凷墤㙕㙗塊圦㱮欳澮㬮噲䯤㟴廥糩鲙鱠獪擓㧟㔞䈛鄶䭝儈旝",
+ "kuan": "款宽髋䕀臗髖寛寬窾窽䥗䲌鑧䤭㯘歀梡欵",
+ "kuang": "况矿狂框旷筐眶匡邝哐圹诳劻夼贶贶纩诓匩邼硄礦砿壙眖矌洭黋況曠昿軭軖軦軠岲貺恇忹懭鄺懬爌䊯鋛鑛鉱㤮鵟狅抂䵃筺穬儣絖纊絋誆誑",
+ "kui": "亏溃愧奎魁馈葵窥盔傀匮逵夔喟睽喹聩揆篑岿馗蒉蝰暌跬悝愦䕚蘷藈匱蕢䕫虁聵聭聧骙騤犪尯磈㚝膭頍㙓刲䖯殨㕟虧潰晆䠑䟸躨蹞嘳顝䯓巋巙憒煃窺頯鍷鍨㨒䫥楏䤆櫆楑籄簣䈐䦱闚䍪㛻嬇媿戣鄈䳫饋餽䧶謉",
+ "kun": "困昆捆坤锟崑鲲琨髡堃醌悃阃菎騉髨髠硱堒壼壸瑻睏涃潉蜫䖵晜㫻鹍鵾䠅崐焜熴鶤裩裍裈褌祵錕鯤猑㩲梱稇稛閸閫綑",
+ "kuo": "括扩阔廓蛞鞟鞹萿葀䯺髺鬠霩濶䟯㗥韕挄擴拡頢筈䦢闊",
+ "la": "拉啦腊辣蜡落喇垃剌旯邋砬瘌藞鞡䪉菈䏀鬎磖䂰㕇䃳臈臘䟑䝓䶛㻋㻝瓎溂䗶蝋蝲蠟嚹翋㸊爉鯻鑞镴搚揦攋䱫揧辢楋櫴柆䓥",
+ "lai": "来赖莱癞睐籁徕涞崃疠唻赉濑铼䓶藾萊䧒騋㚓䂾琜睞瀨瀬淶䠭㠣崍庲襰䄤䲚鯠錸猍梾頼賴鵣棶郲來賚顂鶆逨䚅麳筙㥎籟徠箂䅘癩㾢婡俫倈䋱",
+ "lan": "兰蓝烂览篮栏拦懒滥揽澜婪岚缆阑榄斓褴啉谰镧漤罱藍韊䪍覧覽擥蘫蘭葻䰐䃹䑌壈璼㱫瓓灆濫灠灡浨㳕瀾嚂囒躝㘓幱嵐㞩懢懶惏㦨爁爦爤糷䊖顲燗爛燷燣襤襽襕襴䆾钄䳿鑭㩜攬㨫攔欖㰖欗醂欄籃籣䦨闌㜮孏嬾㛦孄儖㑣㑑繿纜䌫䍀譋斕讕",
+ "lang": "浪朗郎狼廊琅螂啷榔鎯莨阆蒗锒稂䕞蓈蓢硠朤朖㙟埌㱢瑯䁁䀶蜋㫰䍚䡙䯖崀㟍㢃烺䆡㝗䱶鋃樃桹躴艆筤㾿閬㾗嫏郞塱㮾勆郒欴㓪斏誏",
+ "lao": "老劳落牢络捞姥烙唠涝佬潦痨酪崂醪乐耢铹铑栳荖䵏䕩硓磱嗠䝤朥耮耂㐗䳓珯澇労浶蛯蟧㗦咾嘮哰轑㟙㟹嶗㟉㞠恅憦顟粩䃕勞憥䝁窂銠鮱鐒䲏狫㧯撈㨓橯䇭躼軂簩癆嫪僗髝䜎",
+ "le": "了乐勒肋仂嘞鳓泐叻艻阞砳㔹玏氻㖀忇㦡鰳鱳扐楽樂簕竻韷餎",
+ "lei": "类累雷泪勒蕾垒肋擂磊儡镭耒羸嘞檑酹嫘缧缧诔䒹蕌蘲虆藟蘽蔂蘱絫厽㹎䮑礌礧磥㲕䐯鼺䨓靁㙼䢮䣂頛㼍瓃矋㵢洡灅㶟涙淚㴃蠝䍥䍣塁罍礨㔣壘壨畾纍轠鸓䴎櫐㡞類頪纇颣禷鐳銇鑸鑘鱩錑攂㭩䣦欙櫑樏䉪䉂䉓癗㿔㒍㑍㒦儽傫纝縲䛶誄讄",
+ "leng": "冷愣楞棱塄薐䮚碐堎睖踜㘄唥䚏䉄稜倰䬋",
+ "li": "里理力利立例离历李礼丽粒隶哩璃励黎厉厘梨莉吏栗犁鲤狸砾沥荔篱漓笠蛎痢俐锂俚雳逦戾镉罹栎蠡俪藜鹂骊砺蜊黧娌莅猁疠傈唳溧疬慄醴砬喱鬲苈澧蓠坜嫠郦呖跞轹詈粝鲡鳢枥篥缡藶蒚蒞荲䔆䔁䔣䔧蔾菞䔉苙茘䓞蘺䧉犡䮥䮋驪勵厲礪㔏礰鬁㻎砅䃯礫歴暦厯磿歷厤曆㻺㽁貍䤚蠫䴄脷壢靂隷䟐赲䟏靋塛孷釐剺斄㹈瓑珕蟸叓䣓䰛酈鸝邐䚕婯麗䴡㱹㡂㽝瓅瑮琍瓈䶘㮚䁻睙濿瀝浬浰沴涖灕蠇䘈曞蠣蛠㬏蝷蚸蟍蜧㒿嚦㘑囇躒㗚唎嚟㕸囄轣䡃轢䍠䍦豊巁屴峛峢㟳峲㠟岦㤦㤡㦒悧悷䊪爄糲糎爏廲粴麜㷰裡褵䙰禲禮䄜䥶觻䲞鋰鱱鳨鱺鯉鱧鯏㺡鏫鑗鉝瓥㼖攊㿨攦㸚擽皪搮㧰攭櫔櫪栛朸隸䣫欐䤙醨栃檪櫟鷅梸㰀㯤欚棙樆㰚䅄穲䖽䵩悡鋫䱘㴝犂睝䖿鯬鵹䊍邌錅䴻棃剓筣䉫秝艃䵓䅻籬癘竰癧䍽㿛㾐㾖鴗凓䇐孋㓯娳刕儮儷䬅䬆㑦㒧劙䗍盠盭䰜纚䋥綟縭讈裏離謧",
+ "lia": "俩",
+ "lian": "联连脸练炼恋莲怜链廉帘敛镰鲢涟殓濂梿奁裢潋楝蔹臁琏琏蠊裣匲蓮薕萰蘞匳蘝聨聫聯䏈聮奩鬑䃛磏臉䨬覝堜鄻璉㱨殮瑓䁠㶌瀮漣湅濓溓瀲澰㶑螊蹥嗹噒連㦁㡘慩翴㦑憐䙺㥕燫煉劆㢘熑褳襝鏈鰱鰊鐮錬鍊㺦䥥鎌㼓摙櫣㪝槤㼑㰈㯬㟀簾䆂䇜籢籨亷㾾㝺羷㜕嫾嬚媡㜃㜻斂㪘歛㰸僆䭑縺練䌞纞謰戀",
+ "liang": "两量亮良粮梁俩凉辆谅粱踉晾靓莨墚魉椋䩫䓣駺㹁脼㔝兩両涼湸蜽唡啢䠃喨哴輌輛輬辌㒳䝶悢糧裲䭪鍄掚魎䣼樑倆倞俍緉諒",
+ "liao": "了料疗辽僚聊廖缭寥撩燎撂瞭缪嘹潦寮镣蓼獠尥鹩钌藔䒿镽䩍尞鷯遼䨅㶫膫㙩璙䝀敹漻㵳暸蟟曢蹽蹘䍡嶚嶛髎嵺賿憭憀屪鄝䢧䎆廫膋爎㡻䉼炓㝋窷竂釕鐐爒㺒橑䄦簝䑠療嫽尦飉豂䜮繚䜍",
+ "lie": "列烈裂猎劣咧冽趔鬣埒洌躐捩茢䓟聗㸹犣鬛㼲脟㲱埓劽䴕㤠烮鮤鴷迾姴䁽浖毟蛚㬯哷䟹䟩㽟煭鱲猟獵㧜挒挘擸栵㭞㯿䅀䉭巤颲儠䜲",
+ "lin": "林临邻磷淋鳞霖麟琳拎凛吝粼赁蔺躏嶙啉璘廪檩遴膦瞵辚辚懔臨䕲菻藺隣阾厸驎䮼䫰碄壣瀶潾澟暽䗲晽躪蹸躙㖁轥疄轔崊恡悋懍燐㷠䢯鄰粦㔂亃翷斴甐麐廩冧㝝䚬鱗鏻獜撛㨆橉䫐檁箖䉮焛閵癝凜癛僯賃繗綝㐭",
+ "ling": "领另令灵零龄岭铃玲凌陵棱菱伶苓聆翎绫羚鲮呤棂蛉囹瓴酃泠柃䔖蘦䖅蕶蔆蓤䕘䧙駖㸳砱朎霊霗㪮䰱龗霝䴒䚖孁靈㲆䨩夌坽䴇霛琌㱥㻏齡羐鹷齢澪淩㬡昤㖫跉䡼䡿輘軨䯍崚岺嶺㦭㥄爧燯炩㡵䴫麢䙥裬袊祾䄥錂鯪魿狑鈴掕皊櫺欞㯪醽䉁䍅䉹䈊䉖䠲舲彾秢笭衑竛閝㾉婈姈鸰刢領鴒䌢綾紷詅〇",
+ "liu": "流六留刘硫柳溜瘤碌榴馏琉浏绺蹓遛镠骝鎏鹨熘镏锍旒蓅藰蒥䋷䭷驑駵駠騮磟磂䶉㙀塯霤㽌璢畱鬸珋瑠䰘澑畄瀏瑬蟉䗜㽞嚠疁罶嵧羀懰鷚翏雡熮㶯廇麍裗䄂䚧鐂鏐䱞䱖鰡鎦鋶鹠劉鶹㨨橊桺栁桞橮䉧癅嬼媹飗飂䬟飀飅餾綹㐬斿旈",
+ "long": "龙隆笼垄拢胧聋咙陇窿珑垅弄砻茏栊滝眬泷癃䪊蘢䃧隴䏊龓尨礲朧霳䥢鏧壠靇瓏矓漋㙙㴳湰瀧昽曨蠬哢躘嚨嶐㟖巃巄贚㦕㢅爖㝫襱竉鑨攏梇䙪櫳槞㚅䡁徿籠䆍篭聾礱龍壟龒蠪驡鸗㰍竜㛞㑝儱豅㡣",
+ "lou": "露楼漏陋搂喽篓娄镂偻髅蝼瘘耧蒌嵝鞻㔷蔞䮫㲎塿耬䝏剅瞜䁖漊溇螻嘍䣚䫫婁甊遱鷜㪹髏㟺嶁屚慺㥪廔熡䄛鏤䱾㺏摟樓簍䅹軁艛瘻瘺謱",
+ "lu": "路陆绿露录鲁炉卢芦鹿碌禄卤虏庐噜麓颅漉辘掳六赂鹭戮泸橹璐潞鲈撸蓼箓轳胪垆氇鸬渌辂镥栌簏舻逯虂䩮蘆蓾蕗蔍菉陸䎼騼䮉騄馿䰕磠硵䃙硉臚膔氌䐂壚塷趢塶圥勎坴鵱瓐㱺璷琭矑虜㪭盧顱鸕鹵睩淕瀘滷澛瀂淥曥蠦螰㫽踛嚧蹗鷺䟿嚕㖨黸䡜轤轆輅䡎髗㠠賂峍㟤㦇䎑勠剹㢚廬爐廘熝粶䴪㼾䘵祿錴鐪鑪鏀㔪鏴鯥䲐鱸魯鴼鵦䱚鏕魲鑥獹録錄鈩擄攎摝擼醁㯭櫨樐㯝樚櫓㯟㭔椂枦甪罏稑籚簬簵穋簶穞籙艣艫艪舮㓐㿖㛬㪖䚄盝㜙娽僇侓纑彔䌒㢳㪐謢玈",
+ "luan": "乱卵挛峦滦鸾孪栾銮脔娈䖂虊亂灤羉圞圝釠癴癵鵉孿㝈奱㡩灓曫巒鸞鑾攣欒孌臠㱍龻䜌",
+ "lun": "论轮伦仑沦纶抡囵崙菕芲陯磮碖腀耣埨淪溣蜦踚㖮圇輪崘惀㷍鯩錀㤻掄棆䑳稐䈁婨侖倫綸論",
+ "luo": "落罗逻洛络螺裸萝锣骆烙骡啰珞箩摞捋倮瘰猡硌荦脶漯泺镙椤雒蠃蘿蓏騾駱䯁硦覶頱腡㼈㱻覼䀩㴖濼曪囉囖邏羅峈㦬犖鏍鑼鮥玀㩡攞㰁欏洜㓢鵅籮躶䈷笿癳㿚㑩儸饠㒩纙絡䌱䌴驘臝䊨鸁䇔詻剆㽋咯",
+ "lv": "律率绿虑旅氯铝履吕捋驴滤侣屡缕榈褛偻闾稆膂藘葎䕡驢膢膟垏勴慮濾郘呂氀㠥嵂屢爈焒褸祣鑢鋁㲶捛挔櫖梠櫚穭箻閭儢侶僂絽縷緑綠繂膐",
+ "lve": "略掠锊寽㔀畧㨼圙鋢鋝稤",
+ "ma": "马吗妈麻嘛骂码抹玛蚂蟆犸嫲么杩蟇蔴䣕馬䣖遤碼鬕瑪睰溤螞䗫嗎駡嘜罵䯦犘㦄䳸祃禡鎷鰢鷌獁㨸榪㾺痲痳閁媽㜫㐷傌㑻摩",
+ "mai": "买卖麦脉埋迈霾荬劢唛薶勱邁蕒䮮脈霢霡䨪賣売䨫䁲嘪䚑鷶買麥衇䘑䈿㜥佅䜕",
+ "man": "满慢漫曼蛮瞒蔓馒螨幔缦鳗谩颟墁埋鞔熳镘䕕顢㒼蔄蘰鬗䯶鬘䰋䐽䝡䝢㙢䟂瞞満滿㵘澷蟎鄤㬅㗈㗄䡬㡢慲屘悗䊡襔鏋鏝鰻獌摱樠槾䅼䑱姏娨嫚㛧僈饅䜱縵謾䛲矕蠻",
+ "mang": "忙盲茫芒氓莽蟒铓牤邙硭漭䒎莾蘉茻牻駹厖硥壾㙁㻊䁳䀮盳浝汒蠎㬒蛖哤䟥䵨㟿㟐㟌㡛恾庬㝑鋩狵釯杧䅒笀䈍痝娏䖟杗吂",
+ "mao": "毛矛貌冒贸帽猫茂茅髦瑁锚牦铆卯懋袤昴峁眊茆瞀蟊蝥耄泖旄蓩鶜䓮芼鄚萺堥暓䖥愗髳冇貓䫉覒氂犛㲠㺺渵㴘冐毷㪞㒻㫯蝐罞軞䡚冃㡌戼㝟錨夘鉾䀤鉚乮鄮貿㧌㿞㧇皃㒵楙柕㮘枆酕䅦笷媢㚹䋃",
+ "me": "么濹嚰嚒",
+ "mei": "没美每妹梅煤眉霉媒枚酶镁媚魅玫昧莓糜楣寐湄嵋袂浼鹛镅猸䒽葿䓺苺脄腜脢堳坆㺳䜸瑂珻眛睸䀛湈沬沒渼䰪蝞跊嚜槑䵢黣䍙嵄郿鶥韎㶬䊊煝塺䊈燘禖祙鎇鋂鎂抺攗鬽挴楳㭑䤂栂䆀䰨躾黴徾篃毎䉋羙凂痗媺嬍媄睂旀",
+ "men": "们门闷瞒懑扪汶焖钔虋菛璊玧㱪懣㵍暪㡈䝧㥃㦖䊟穈燜䫒鍆㨺捫椚門悶閅們",
+ "meng": "梦蒙猛盟孟萌朦氓锰懵蟒勐檬濛蜢虻蠓矇瞢甍礞艨艋䓝鄸䒐䠢顭夢莔氋鹲鸏蕄䰒㚞䑅䑃䏵㙹靀霿霥矒溕曚䗈甿㠓幪懜懞冡鼆䀄䙩㝱䙦錳䴌䲛鯭鯍䥂獴䥰㩚掹擝橗䤓䴿䵆䉚㒱癦䇇㜴儚饛鄳夣蝱",
+ "mi": "密米秘迷蜜弥泌眯咪觅谜靡糜猕谧醚嘧弭脒幂麋縻汨蘼蘼芈敉宓冖祢糸蔝㰽蒾䕷蘪藌蔤葞䕳䮭镾覔㫘䪾覓㸓塓鸍羋瞇䖑濗漞濔㵋㳴㴵灖洣滵淧沵沕䌘渳瀰㳽羃䍘峚幎㠧㟜怽幦戂㥝㐘粎䊳麊熐麿爢㸏麛䴢冪宻鼏䁇冞㝥袮禰祕䱊銤獼㩢覛擟攠㨠䤍䤉釄醿醾䣾榓櫁樒簚䉾㜆孊侎䭩䭧䌩䌐㣆䥸彌㜷瓕䌕䋛䌏䛉謐䛑䛧謎詸",
+ "mian": "面免棉眠绵勉缅腼冕娩沔湎眄渑宀芇葂䏃䰓勔靦靣䃇㻰㤁丏麺䀎睌矈矏矊汅㴐澠蝒㬆喕愐糆㝰鮸緜㮌䤄杣㰃櫋麵麪麫檰䫵臱媔㛯婂嬵偭㒙緬絻綿",
+ "miao": "描苗妙秒庙渺瞄缪淼藐缈邈鹋眇喵杪鶓㦝䁧䖢㠺庿廟劰篎䅺竗媌嫹㑤緢緲玅",
+ "mie": "灭蔑篾咩乜蠛薎孭礣烕䩏䁾瀎滅䘊哶吀幭懱鴓鑖鱴搣櫗衊䈼㒝",
+ "min": "民敏闽皿悯抿泯岷闵苠珉玟黾愍鳘缗蠠䃉䂥碈砇垊琝瑉琘䁕盿湣潣旻旼䟨䡅罠䡑䡻㟭崏㞶䪸敯刡㥸鴖暋㟩敃惽怋憫忟鍲鈱䲄錉㨉捪笽笢簢勄慜鰵閩冺痻閔姄僶緡㢯䋋黽緍忞",
+ "ming": "明命名鸣铭冥螟茗瞑酩溟暝蓂眀眳洺㫥鳴朙㟰慏䊅鄍䒌䫤覭㝠䆩䆨䄙銘猽掵榠凕嫇姳佲詺",
+ "miu": "谬缪謬",
+ "mo": "么没模末默莫摸脉磨冒膜摩墨漠魔抹沫陌寞摹蓦蟆蘑馍谟茉貉秣殁貘万貊耱麽镆瘼嫫嬷嬷靺䒬莈驀㱳謩藦䮬砞䩋礳䃺䏞貃䳮塻圽歿歾瞙眜瞐䁼眽眿尛蛨黙昩䘃蟔嗼嚤䁿㱄髍帞帓懡糢㷬爅㷵䯢劘麼䜆庅鏌銆魹䱅魩獏㹮皌擵枺橅䴲䉑妺嫼嬤饃䬴饝纆絈謨嘿",
+ "mou": "某谋牟眸缪呣哞鍪蛑侔䥐劺鴾䏬䗋踎䍒恈䱕㭌麰繆謀",
+ "mu": "目母木模莫幕牧亩墓姆慕穆暮姥牡拇睦募沐牟缪苜钼毪坶仫莯䧔鞪䱯楘㜈牳砪氁胟雮霂畞䀲暯蚞踇畂畮峔幙慔毣炑䥈鉬狇鉧㣎㧅䑵艒㾇凩㒇縸䊾畆畝畒",
+ "na": "那哪拿纳娜呐捺衲钠内南肭镎靹蒳䖓乸䫱貀豽䏧雫䀑㴸䖧䟜吶㗙嗱軜䎎䪏袦鈉魶䱹鎿㨥䅞笝䇱郍䇣䈫拏妠搻納䛔",
+ "nai": "奶耐乃奈萘氖迺艿能鼐柰孻螚䘅䯮腉渿褦釢錼㮈㲡廼㮏疓㾍䍲嬭倷",
+ "nan": "难南男喃楠囡赧囝腩蝻䕼䔜萳戁難莮䔳遖䁪湳暔㫱㽖畘䶲煵揇抩枏柟䈒㓓婻娚侽諵䛁",
+ "nang": "囊囔曩馕攮䂇嚢灢㶞蠰乪擃欜齉儾㒄饢",
+ "nao": "脑闹恼挠瑙呶孬桡淖铙硇垴蛲猱夒䃩碙碯臑脳腦䑋䴃堖鬧蟯巎嶩悩憹怓惱鐃獶獿峱㺀㺁撓䄩閙嫐㞪㛴䫸㑎匘譊䛝詉䜀䜧",
+ "ne": "呢呐讷哪疒䭆䎪眲㕯抐訥",
+ "nei": "那内哪馁脮腇㼏㘨㖏䡾內䳖鮾䲎鯘錗㨅氞氝娞㐻餒",
+ "nen": "嫩恁㶧㯎㜛嫰",
+ "neng": "能䏻㲌㴰",
+ "ni": "你疑尼泥拟逆妮腻倪匿溺霓昵睨怩鲵铌旎呢坭猊伲䘌臡苨䕥薿孴聣隬䧇膩貎胒䝚郳㪒堄䁥齯惄眤㵫淣聻埿氼暱晲蜺蚭跜輗㞾㠜㥾㦐愵籾麑䘽䘦觬鈮鯢狔㹸掜屔抳䰯擬棿檷柅䭲馜秜䵒䵑屰䦵嫟嬺婗妳儞㲻伱儗㣇縌誽䛏",
+ "nian": "年念粘碾撵捻辗蔫拈埝黏鲶鲇辇廿䩞卄輦涊㲽淰躎蹍蹨哖唸㘝㞋惗焾鮎鯰攆撚䚓鵇秥簐䄭秊艌䄹姩䬯",
+ "niang": "娘酿䖆醸釀嬢孃",
+ "niao": "鸟尿溺袅脲茑嬲蔦䮍䦊䃵䐁㳮㠡㞙鳥䙚裊㭤樢嬝嫋㜵㒟褭",
+ "nie": "捏聂涅孽镍蹑蘖镊颞啮嗫摄乜陧臬糵㜸苶菍聶顳隉孼蠥糱櫱䯅䯀䯵齧㚔䂼㘿㙞摰槷湼㴪圼囁囓躡踙嚙踂噛踗㡪嵲嶭巕㸎䄒鑷鑈钀鎳錜揑㩶枿㮆籋臲篞㖖痆闑帇敜䌜䜓讘捻",
+ "nin": "您恁脌囜㤛拰䋻䚾䛘",
+ "ning": "宁凝拧狞咛柠泞佞聍甯䔭薴聹鬡㿦矃澝濘䗿嚀寕㝕㲰寍寜鸋寧寗鑏獰擰橣檸㣷嬣儜倿䭢侫",
+ "niu": "牛扭纽钮拗妞忸狃靵莥䒜牜䏔㺲䀔汼炄鈕杻䋴紐",
+ "nong": "农弄浓脓哝侬蕽鬞膿䢉䁸濃噥農燶㶶襛禯㺜挵挊醲檂欁辳齈穠秾䵜癑儂繷譨",
+ "nu": "努奴怒弩帑孥驽胬搙䢞笯駑砮㐐傉伮㚢",
+ "nuan": "暖䎡渜㬉煗煖䙇奻餪",
+ "nuo": "诺娜挪糯懦喏傩搦难锘逽㔮蹃㡅愞懧糥糑鍩䚥掿㰙梛榒橠稬穤㐡㛂儺㑚諾",
+ "nv": "女衄恧钕朒沑籹釹衂",
+ "nve": "虐疟硸䖋䖈瘧婩",
+ "o": "哦噢喔筽",
+ "ou": "偶呕鸥殴耦藕讴禺沤怄瓯区欧蕅毆鷗歐甌䚆鴎藲膒腢塸漚㼴嘔吘䯚慪熰鏂䳼櫙㛏㒖䌔䌂謳",
+ "pa": "怕爬帕扒啪趴琶耙杷葩钯筢䔤苩䯲䶕潖帊袙皅掱舥妑",
+ "pai": "派排迫拍牌湃徘俳哌蒎犤沠渒㵺䖰輫鎃猅棑㭛簲箄簰",
+ "pan": "判盘胖潘盼叛攀畔拌蹒泮蟠磐槃爿袢柈番襻丬萠蒰聁䰉䰔磻䃑䃲坢眅㳪溿沜瀊洀蹣跘炍鑻鋬牉䈲鞶幋縏盤鎜搫媻頖鵥冸詊拚",
+ "pang": "旁胖庞乓磅螃彷滂徬耪逄䮾厐龎肨膖胮霶㫄雱䨦眫㤶㥬炐龐鳑鰟舽䅭㜊嫎䒍覫",
+ "pao": "跑炮泡抛袍刨咆疱狍庖匏脬鞄䩝萢皰礟礮靤砲奅褜垉㘐軳麅麃炰拋爮㯡麭䶌㚿䛌",
+ "pei": "配培陪佩胚赔沛妃裴呸帔辔霈锫醅旆蓜阫馷䪹䲹䫠肧毰珮㳈浿㫲䣙賠㟝怌㤄䊃犻錇㧩衃姵俖伂轡裵斾",
+ "pen": "盆喷湓葐翸歕喯噴呠瓫",
+ "peng": "朋碰棚蓬膨捧篷鹏烹砰澎抨怦硼嘭彭堋蟛莑芃蘕駍騯鬅髼鬔䰃磞硑鵬蟚塜塳㼞淎泙踫輣軯䡫剻㥊憉恲熢袶䄘鑝錋匉捀皏掽樥槰椪䴶梈椖稝竼篣閛韸韼㛔倗傰纄弸苹亨",
+ "pi": "皮批屁披辟疲脾匹劈僻副罢譬啤琵坯癖毗痞枇霹噼裨媲否貔丕吡陂砒邳铍圮睥蜱疋鼙陴埤淠蚍罴甓庀擗郫仳纰苉鴄㓟隦阰駓髬㔻礔磇䏘豾脴腗䑀䑄膍肶豼噽嚭壀耚疈錃潎澼蚾蚽䠘㔥羆䡟毘岯嶏崥翍礕䴙憵鷿鸊悂炋焷螷蠯鈹銔鉟銢鲏鮍魾魮䤨釽錍狓狉鈚抷㨽揊䰦䫌䤏㯅秛秠稫篺笓鵧㿙闢嫓伾伓枈紕諀旇䚰䚹",
+ "pian": "片偏篇骗扁翩骈胼蹁便犏谝貵䮁騈駢騙腁䏒跰囨骿賆魸鍂楩楄覑㸤㾫㛹媥㓲騗鶣㼐諞",
+ "piao": "票飘漂瓢瞟缥剽嫖朴嘌骠慓殍螵薸䕯䏇驃犥㵱㬓䴩鰾㺓㹾皫㩠魒勡彯飄顠翲㼼醥徱篻闝僄飃縹旚",
+ "pie": "撇瞥苤氕丿暼鐅撆嫳覕䥕",
+ "pin": "品贫频拼聘拚嫔颦姘牝玭榀蘋薲驞礗砏琕顰䀻矉蠙嚬汖㰋馪穦嬪娦貧",
+ "ping": "平评凭瓶屏苹萍乒坪呯鲆枰娉俜蓱荓聠砯胓䶄塀玶㻂淜涄洴蚲蛢輧軿甹岼幈帲帡屛焩鮃檘缾䍈甁簈箳郱頩艵慿憑凴竮㺸評冯",
+ "po": "破迫婆坡颇泼朴泊魄粕珀鄱钋笸陂叵钷皤蔢尀蒪頗駊奤砶䞟䨰㨇洦湐溌潑昢哱嘙嚩岥䯙岶䪖烞鉕釙鏺廹敀櫇䣮䣪酦醱醗箥䎊䄸㰴㛘㔇繁",
+ "pou": "剖掊裒犃垺哣㧵抔捊抙箁咅娝婄",
+ "pu": "普铺扑谱朴葡仆浦蒲埔菩瀑圃噗曝匍蹼溥濮璞莆氆攴镤镨堡攵䔕䑑蒱䧤陠㹒暴圤墣㺪瞨潽㬥䗱圑贌烳炇㲫䴆菐鯆鏷䲕鋪獛鐠擈撲酺檏樸㯷䈻䈬穙痡暜舖舗㒒僕纀諩譜",
+ "qi": "起其气期器企七奇汽齐妻启旗弃骑欺漆棋岂凄契歧戚栖泣砌祈蹊乞迄崎祺鳍伎缉岐琦祁琪憩畦沏绮脐亟嘁荠杞麒颀耆啐蛴碛淇葺芪祇綦欹槭萋讫圻蕲揭萁芑骐亓丌柒汔蜞屺桤藄䩓䓅鄿䕤蘄䔇䒻炁芞藒䒗萕陭隑䏅䧘亝騹騎騏䭼䭶唘碶磩碕鬐䰇磧慼䫔䚉栔㓞㼤矵攲敧鵸碁䫏磜剘蜝㐞䳢棊肵䏠臍墄埼霋䞚䟄䎢璂䚍玘郪鶈䀙䶞盀盵濝淒呇滊湇湆蚑螧蚔蚚暣㫓蠐咠唭踦跂䟚噐呮罊蟿䡋軝䡔䢀㟢豈帺㟓岓嵜㠎邔慽㥓愭悽愒迉忯㞚㞓懠粸䉻麡䧵褀褄䙄禥䄎䄢鏚錡锜釮鲯鯕鶀䱈鰭䲬䰴夡玂猉鐑頎掑捿氣鬿魌気摖㩩櫀㯦㟚棲㩽榿檱㮑䣛桼憇諬䅲欫甈㣬䄫簯䅤䑴䉝艩簱籏㾨竒疧闙䀈婍娸傶僛倛䏌䬣㒅綺緀紪䭫䭬綥䌌斊棄䛴諆斉齊䶒䐡䁉䋯啔啟䏿䁈晵啓棨訖旂枝俟稽",
+ "qia": "恰洽掐卡髂拤袷咭葜鞐圶硈胢䨐殎䶝䶗䠍跒䯊峠㡊帢㤉擖酠冾㓣䜑",
+ "qian": "前千钱潜迁浅签纤牵欠遣铅歉谦乾倩嵌虔钳黔谴堑扦阡茜钎掮犍钤佥荨骞愆箝芡芊肷椠岍悭慊褰搴仟缱䪈韆䕭茾孯臤蜸掔婜蔳葥蕁蒨騚騝㸫鬜鬝厱膁㦮墘䥅亁乹圲䨿䁮䖍歬淺灊潛汧壍嬱汘濳蚈黚輤塹㟻槧㜞軡㡨岒慳悓忴粁䊴䞿騫錢鹐鵮銭鉗鑓鰬釺鎆鈆鉛鈐鏲㧄攑㩮拑皘㨜攐攓㩃拪揵扲撁橬檶遷棈榩櫏杄槏㯠圱刋谸籖䍉篏䈤䈴篟簽籤羬䇂䦲竏㪠䫡奷媊僉俔儙諐伣㐸偂傔䭤仱欦綪繾縴譴顅謙牽",
+ "qiang": "强枪墙抢腔羌呛跄锵蔷羟襁戕戗嫱樯蜣炝锖镪薔蘠蔃墻玱瑲溬漒蹡蹌啌嗴唴嗆嶈廧熗獇猐鏘鎗鏹摤㩖搶檣椌䵁槍艢䅚篬牆羥羗羻羫墏斨牄嬙㛨戧強彊繈繦謒疆",
+ "qiao": "巧桥悄瞧敲乔侨翘峭窍俏锹鞘憔跷撬樵荞橇壳雀诮峤鞒硗愀劁缲谯鞩鞽㤍䲾㚽菬荍藮蕎陗犞磽䃝䩌硚礄䂭翹墝㚁趬趫墽墧睄郻㴥踍蹺躈蹻嘺骹帩幧韒燆㢗㝯䆻竅釥鐰鄥䱁鄡鐈鍬撽櫵槗橋勪喬䀉䎗㡑鍫䇌頝癄嫶僺僑顦繰繑誚髜毃㪣髚譙",
+ "qie": "切且窃契怯砌伽茄妾惬趄锲箧挈郄苆㥦匧㰼聺㚗洯蛪㓶厒㤲㰰朅淁㫸䟙踥㗫愜悏竊鍥䤿鯜㹤癿篋笡籡穕㾜䦧㾀㛍㛙䬊",
+ "qin": "亲侵勤秦琴禽钦沁芹寝擒矜噙覃揿芩嗪衾螓吣锓檎菣靲䔷懃斳兓菳菦藽耹骎㮗駸肣㘦赾埐坅琹珡䖌澿瀙螼蠄昑蚙唚㞬嶜嵚嶔嵰懄慬吢㤈㢙庈㝲寴寢寑顉鈙鮼鵭欽鋟鈫抋捦撳㩒搇梫䠴笉䈜瘽䦦親㓎㾛嫀媇㪁鳹雂綅誛",
+ "qing": "情清亲青轻请倾庆氢晴顷卿蜻擎氰磬罄圊箐苘檠謦黥鲭綮葝䔛碃䌠硘埥殸漀㷫郬靘靑殑濪淸暒甠啨軽輕鑋䝼䞍慶檾庼廎寈錆鯖䲔夝擏掅氫㯳櫦棾樈凊儬傾頃請勍剠䋜䯧",
+ "qiong": "穷琼穹邛茕跫蛩銎筇卭㧭䓖藭藑蛬䊄䅃赹璚瓗㼇瓊瞏睘惸㷀煢焭熍焪䆳竆窮宆憌桏㮪橩笻䠻舼儝㒌䛪",
+ "qiu": "求球秋丘酋囚蚯邱裘鳅巯泅湫虬遒楸逑龟蝤赇糗犰鼽俅蓲鞦鞧莍萩蘒芁䎿毬肍䞭趥坵皳䣇盚㺫蟗玌璆殏巰㐀汓浗湭渞蛷虯䟵䟬䠗唒㕤賕㟈崷㞗㤹㥢恘秌煪觩觓銶䲡䤛䱸鰽鯄鮂鰍鰌釚釻㼒㧨搝扏梂逎㭝朹湬蝵鹙鶖醔媝穐篍龝蠤㷕丠頄㐤叴訄恷䜪紌絿緧䊵訅仇",
+ "qu": "去区取曲趣趋屈驱渠躯娶岖瞿祛蛐觑衢蛆龋黢癯苣蠼佉阒麯蘧蕖磲朐璩氍劬鸲麴诎葋䒧匤菃敺區䒼螶䧢阹驅駆駈厺髷胠刞臞䝣鼩㰦鼁坥䟊䞤趍趨耝璖麹䶚齲覰覻䁦䀠䂂戵鸜覷灈浀淭䖦㫢蠷蟝蝺呿䠐躣㖆軥㻃嶇㲘岴胊鶌憈翑焌爠粬煀袪鑺鴝斪䵶鰸魼鱋抾㭕㯫欔欋麮衐籧忂筁軀闃閴竘竬㜹佢伹紶㣄䋧絇詘詓誳㧁",
+ "quan": "全权圈泉劝拳券犬醛蜷痊颧铨荃诠筌鬈畎辁悛犭绻勸顴葲虇䄐騡駩犈牷犮硂䑏䟒埢瑔䀬湶洤蠸䠰踡跧啳圏輇巏㟨㟫峑恮䊎烇鳈鐉鰁銓搼權楾権棬椦勧箞㒰齤奍韏觠牶闎婘姾佺縓綣絟詮",
+ "que": "确却缺雀鹊炔瘸榷阙阕悫皵鵲䧿蒛碏礭確硞碻礐趞㱿㲉愨慤埆㱋塙琷㴶崅燩㕁搉㩁棤㰌缼䇎䦬㾡闕闋傕卻",
+ "qun": "群裙逡麇䭽夋囷峮宭㿏㪊裠帬羣",
+ "ran": "然染燃冉髯苒蚺䖄㲯蒅㸐䒣㿵髥肰䫇珃蚦呥嘫冄䎃衻袡袇䤡橪㯗䑙㾆媣姌㚩㜣䣸繎",
+ "rang": "让壤嚷攘瓤禳穰蘘鬤㚂壌瀼躟懹爙獽穣䉴儴勷譲讓",
+ "rao": "绕扰饶桡娆荛蕘隢㹛遶襓擾橈䫞嬈㑱饒繞",
+ "re": "热惹喏若熱",
+ "ren": "人认任忍仁韧刃妊纫壬饪仞衽荏稔轫亻靭靱荵芢㸾牣䏕䏰肕腍忈䀼軔㠴岃屻韌㣼䴦袵祍魜鈓銋扨梕杒栣朲棯忎躵秹䇮秂姙刄䋕鵀㶵栠飪餁䭃仭䌾紝纴綛紉絍認訒讱",
+ "reng": "仍扔芿陾辸礽㭁䄧㺱䚮",
+ "ri": "日䒤驲馹囸衵鈤釰釼",
+ "rong": "容溶荣融绒熔蓉茸戎榕冗嵘镕蝾肜狨茙㲨䩸氄駥毧㲓㲝坈瑢瀜栄螎曧蠑䠜㘇䡥䡆軵嶸峵烿爃嵤榮㣑䘬褣䇀宂㝐䢇㼸鎔㺎搑搈榵㭜䤊槦穁䇯穃䈶羢媶嬫嫆傇傛縙絨",
+ "rou": "肉柔揉蹂鞣糅葇鶔騥䰆腬脜䐓瑈瓇渘蝚㖻輮㽥禸韖煣粈宍鍒鰇楺䄾䧷媃厹譳",
+ "ru": "如入乳儒辱汝蠕茹褥濡嚅孺铷缛襦女蓐薷颥溽洳蕠蒘㹘㦺鄏肗䰰顬渪蝡曘嗕嶿袽鱬銣㨎擩扖醹杁筎㐈鳰邚鴑䋈媷嬬帤鴽挐桇侞縟繻",
+ "ruan": "软阮朊䓴碝礝耎腝堧壖瑌瓀輭㽭軟䞂撋䪭㼱媆偄㐾緛",
+ "rui": "瑞锐蕊兑睿芮蕤蚋枘蕋蘃蘂䓲㓹甤叡㪫㲊壡汭蜹繠橤鋭銳桵㮃䅑㛱緌䌼",
+ "run": "润闰膶瞤潤㠈橍閏閠䦞",
+ "ruo": "若弱偌箬鄀爇蒻叒䐞渃㘃嵶焫鰙鰯挼捼楉篛婼鶸",
+ "sa": "萨撒洒仨卅飒脎蕯薩靸躠隡馺䘮㪪灑㳐䊛䙣鈒钑摋櫒颯㽂㒎䬃訯",
+ "sai": "塞赛腮鳃噻毸毢嗮㗷嘥顋愢賽䚡鰓揌䈢簺僿思",
+ "san": "三参散伞叁糁毵馓弎㪚毿犙䫩鬖毶壭䀐潵㤾糤糣糝䊉䫅鏾鏒㧲㪔䉈閐厁俕饊傘繖",
+ "sang": "丧桑嗓搡颡磉顙䫙桒喪䡦褬鎟槡",
+ "sao": "扫嫂骚缫搔臊瘙埽鳋䕅騒騷䐹矂溞螦氉鰠鱢掻掃㿋㛐㛮颾繅髞梢",
+ "se": "色涩瑟啬塞铯穑槭䔼雭䨛嗇㱇璱㻭歮濇濏澁渋㴔洓瀒澀轖懎㥶銫鏼摵擌㮦栜穯穡䉢閪瘷歰飋㒊繬譅",
+ "sen": "森襂槮椮",
+ "seng": "僧鬙",
+ "sha": "沙杀啥纱砂傻刹厦杉莎煞鲨霎裟挲嗄唦痧唼铩歃萐蔱䮜髿䝊硰㲚㸺乷鯊桬啑喢帹翜翣廈粆魦鯋鎩猀毮閷殺榝樧㰱箑䶎䈉䵘閯㚫㛼儍倽紗繺",
+ "shai": "晒筛曬㬠㩄簛籭簁篩",
+ "shan": "山单善闪扇衫陕珊禅杉擅掺栅煽膳删姗汕赡跚掸讪缮舢疝嬗潸鳝搧鄯苫膻芟骟彡蟮钐陝剼騸㚒磰㪎脠赸墠圸墡㣌睒灗澘㶒晱蟺嘇軕刪邖幓贍炶煔覢熌䘰禪䄠釤銏䱉䱇鱓鯅鱔狦鐥䦅䥇䦂掞挻㨛樿柵檆椫䴮㣣笘䠾䆄痁閊㪨敾歚羴閃羶譱姍僐饍傓縿繕䚲訕謆",
+ "shang": "上商伤尚赏汤裳晌熵墒垧殇觞绱鞝蔏鬺殤丄尙賞漡滳螪贘慯恦禓觴鋿鏛鑜樉䬕傷緔扄謪",
+ "shao": "少烧绍召稍梢哨勺捎邵鞘芍韶筲艄苕劭潲杓莦萷䒚䔠蕱髾㪢䏴㲈玿輎㷹焼燒䘯袑鮹柖䈰䈾㸛娋卲綤䙼䬰弰紹旓",
+ "she": "社设射涉舍摄舌蛇折拾畲奢赦慑麝赊佘猞歙阇厍滠揲蔎騇厙奓䂠䁋䁯灄渉㴇㵃涻蠂虵蛥䵥畭輋䞌賒賖懾韘慴䀅䄕䤮攝摂捨欇㰒㭙檨䠶㒤舎畬䬷弽䌰㢵設",
+ "shen": "什身神深参甚审伸申沈渗婶肾慎绅呻娠砷蜃莘吲糁鯵诜谌瘆信葚胂渖哂矧谂蔘腎頣蓡薓葠駪㰮眘昚脤㥲堔珅眒瞫滲㵕㵊瀋涁蜄曑曋罧屾峷愼糂籸燊籶邥㔤審覾宷裑䆦穼罙祳鋠鲹鉮鰺鰰魫鯓氠扟䰠柛㰂榊兟甧甡鵢瘮㾕妽嬸㜤姺敒侺侁㑗紳弞矤訷谉讅詵諗訠",
+ "sheng": "生声省胜升盛圣剩乘牲绳笙甥嵊晟眚蕂苼䎴聖陞阩陹鼪勝賸榺墭聲殅珄渻湦泩䚇㼳晠琞曻昇㗂呏貹䞉憴焺鍟䱆鵿鉎狌斘橳枡剰䪿㾪竔偗䁞繉縄繩譝甸",
+ "shi": "是时实事十使什式世识食市史石始师失视示似适士势试施室释诗氏湿饰驶拾蚀尸逝侍誓矢狮匙柿硕嗜屎噬嘘栅拭峙仕恃虱轼舐耆螫豕谥弑奭殖蓍泽莳贳埘炻鲥鲺铈酾筮蒔貰䒨蒒葹䦹旹㱁乨駛䰄觢㸷䩃乭䂖䏡鼫鼭卋㔺邿塒㐊辻兘勢丗䴓鳾瑡亊䶡眎睗䁺眂眡溼溡浉湜濕㵓澨溮湤䖨㫑㫭時昰遈㒾呩㕜䟗㖷呞軾嵵崼峕忕蝨屍鸤䲩鳲恀烒煶䊓実寔宩冟襫襹褷䙾實祏視鉽釶鉐鉂䤱鮖鰣鯴鰘鰤鶳鉇鉃㹬㹝獅鈰鍦㹷銴弒揓栻枾釃榯榁柹㮶簭遾舓秲徥師釋釈笶籂箷竍䦠嬕姼餝䭄蝕餙飾飠䌳絁試詩諟戺䗐䛈適謚諡識",
+ "shou": "手受收首守授售寿瘦兽狩绶扌艏膄壽夀垨涭獣㖟獸㥅収㝊鏉龵痩䭭綬䛵",
+ "shu": "术数书属树述熟输束殊叔朱舒鼠疏署竖蔬抒枢淑暑薯梳俞蜀庶赎塾墅恕曙倏漱黍腧戍孰澍秫菽纾疋沭摅姝殳毹荗䩳䩱㷂竪豎䜿䝂薥䔫蒁藷陎㽰毺䑕䞖霔尌朮怷璹琡䜹㻿尗虪濖瀭潄潻㳆鼡㶖蠴暏䠱踈䟽咰數軗輸㟬贖䝪䎉疎屬庻糬襩裋襡䘤䆝鏣鮛鱪鱰錰鉥掓攄捒樞樹橾㯮杸䴰鶐䉀䢤術癙㾁書㛸婌㜐㣽鵨鄃侸跾倐儵焂㒔紓綀絉䃞",
+ "shua": "刷耍唰㕞誜",
+ "shuai": "率衰摔帅甩蟀帥䢦卛",
+ "shuan": "拴栓涮闩䧠腨閂",
+ "shuang": "双霜爽孀骦騻驦礵䫪鷞㼽㦼塽鹴鸘漺灀䗮䡯慡鏯欆艭㕠孇雙縔",
+ "shui": "谁水睡税说氵脽氺涚涗帨裞祱稅閖㽷䭨誰",
+ "shun": "顺瞬舜吮蕣䑞鬊䀵瞚䀢順㥧橓楯",
+ "shuo": "说烁硕朔数妁蒴铄搠槊矟碩䀥爍鑠獡鎙欶箾䌃説說",
+ "si": "思四死斯似司丝私饲寺撕祀肆嘶嗣厮俟泗咝巳鸶蛳驷锶汜伺食厶耜兕澌笥姒缌纟蕼䔮蕬㹑㸻牭騃駟騦磃蟴䏤鼶貄亖耛䎣㺨肂洍涘洠瀃泀泤㴲蟖螄㕽噝罳㟃孠覗廝燍䇁禗禩禠鈶鐁鋖鍶鈻鉰釲㺇銯虒枱杫梩柶楒㭒榹蜤㐌恖竢凘䦙䇃媤㚶㚸娰儩佀飔価俬颸飼飤緦糹㣈鷥絲",
+ "song": "送松宋颂讼耸诵淞嵩悚凇怂忪菘崧竦駷鬆硹濍㕬嵷憽㞞愯䢠庺梥鎹㧐㩳㨦檧楤㮸枩柗㣝䉥聳慫娀頌枀倯傱餸䜬誦訟䛦",
+ "sou": "搜艘嗽嗖擞飕馊薮螋叟溲瞍嗾锼蓃藪蒐䏂騪䮟鄋㵻㖩廋廀叜鎪獀捜擻摉摗醙櫢籔䉤䈹凁瘶傁颼䬒餿",
+ "su": "素速苏诉缩俗塑肃宿稣溯粟酥簌窣夙谡嗉僳愫蔌涑觫蘇莤藗䔎蘓㕖骕驌碿䃤膆塐趚甦殐璛珟玊溸㴑㴼泝潥潚㴋洬㬘囌蹜憟䘻㝛鯂穌鱐鋉䥔㨞㩋榡遬㔄樎櫯梀㯈樕䅇橚䑿愬遡㪩肅䎘鷫嫊鹔䏋粛㜚㓘傃㑛餗㑉縤䌚䛾謖訴",
+ "suan": "算酸蒜狻匴㔯祘筭笇痠",
+ "sui": "随虽岁碎遂髓穗隋绥隧邃祟燧睢荽濉谇䔹荾鞖䪎芕隨䢫鐆遀砕膸埣瓍㻟璲㻪㻽歲歳睟瀡㵦浽澻㴚滖哸䠔雖䡵嵗㞸䯝髄亗賥韢熣煫襚禭䥙鐩夊檖䉌穂䅗穟㒸嬘䭉倠綏繐繀繸䍁䜔旞譢誶尿",
+ "sun": "孙损笋荪狲飧榫隼蓀薞蕵孫飱䁚㡄㦏猻鎨搎損㔼槂簨箰筍鶽",
+ "suo": "所索缩锁梭嗦琐唆羧唢娑蓑挲些睃睃嗍桫䓾莏䂹䐝䞽趖琑瑣㪽䖛溹溑逤䣔暛蜶嗩䞆惢褨鎍鎖鮻獕鏁鎻挱乺摍䵀䅴䈗簔簑㛖傞䌇縮莎",
+ "ta": "他它她塔踏塌榻沓蹋嗒拓獭挞趿遢溻鳎铊闼鞳鞜䓠㿹牠䂿䶁䶀墖㳠㳫澾涾毾躂躢蹹嚃嚺㗳䵬䍝遝崉䎓粏褟祂禢錔鰨鮙鉈㺚㹺獺狧撻㧺搨榙橽㭼㯓䑜䍇㣵濌䈋䈳㣛闧闥闒阘㛥侤㒓傝䌈誻䜚譶",
+ "tai": "大太态台抬泰胎苔汰钛酞肽薹骀邰炱跆鲐䑓菭孡態㣍駘夳冭坮臺㙵溙汏汱㬃旲㘆囼㥭忲䢰燤炲㷘㸀鈦鮐擡檯䣭䈚籉箈舦嬯㒗㑷㑀儓颱",
+ "tan": "谈探弹碳坦叹滩炭摊坛贪谭潭痰毯瘫檀坍袒覃忐昙钽澹郯锬藫歎菼䕊䃪貚䏙䐺墵䞡壜埮墰㽑壇璮灘潬湠曇暺嘆嘽啴嗿惔憛憳憻顃㲜㲭㷋燂䊤襢䆱鉭錟擹攤醈醓醰橝榃舑舕罎罈䉡癱䦔痑婒怹倓僋貪談譚䜖譠",
+ "tang": "堂唐糖躺汤塘倘趟烫膛淌棠搪螳蹚羰傥溏帑醣耥瑭螗铴镗樘鞺薚蓎隚䧜磄膅鼞赯矘漟燙湯坣䣘劏曭蝪踼䟖嘡啺戃糛爣糃煻鄌㲥鶶㼺禟鐋鏜钂鎲镋鎕㿩摥㭻橖榶䉎篖䅯闛㜍伖㑽儻㒉偒傏饄餹䌅㙶",
+ "tao": "讨套逃陶桃萄掏涛淘滔叨韬啕绦洮饕跳鼗鞱鞉鞀㹗騊駣㚐夲瑫㴞濤蜪飸咷轁幍慆韜裪祹迯鋾匋搯槄醄䵚嫍絛䬞饀䬢弢縚綯绹縧詜謟討䚯䛬",
+ "te": "特忑忒慝铽脦蟘㥂鋱㧹",
+ "teng": "腾疼藤滕誊䕨虅驣䲢幐縢螣騰鰧謄膯鼟霯漛䠮熥籐籘䒅䲍駦痋邆儯",
+ "ti": "体提题替梯踢蹄惕啼剔剃涕屉嚏锑棣倜悌鹈逖醍缇绨䪆䔶薙蕛䧅㯩騠髰鬄鬀碮厗朑䨑趧趯䎮瑅殢瓋睼漽渧題鶗惖逷㗣嚔蹏鷤嗁㖒罤䯜體骵䝰㡗崹惿屜褆䙗褅禔禵䚣鳀鯷鷈鮷悐銻鍗䴘鷉掦挮揥擿笹䣽㬱䶑䅠躰軆徲籊稊㣢䣡䶏鵜媞偍䬾緹䌡綈䛱戻謕歒鶙弟",
+ "tian": "天田甜填添佃恬腆舔阗钿畑忝殄畋掭菾黇磌碵䩄胋鷏㙉甛塡靔靝瑱㐁琠璳兲睓沺淟湉晪䟧䠄唺㖭䡒䡘鴫䐌覥觍賟悿屇㥏㶺窴錪䥖搷㮇䣯酟䑚舚䄼䄽闐痶婖倎餂鷆緂㧂甸",
+ "tiao": "条调跳挑眺迢窕苕佻笤啁粜髫龆蜩祧鲦䒒萔芀蓚蓨糶聎䯾朓趒齠晀旫䟭㟘脁岧岹恌庣宨窱祒覜鰷䱔樤㸠䠷䎄䳂嬥鞗䩦䖺鯈鋚鎥條絩誂",
+ "tie": "铁贴帖餮萜聑驖䵿蛈呫貼怗鐵鐡䥫銕鉄䴴僣飻",
+ "ting": "听停庭挺厅廷亭艇烃婷蜓汀霆町铤葶莛梃鞓聴聽聤厛鼮脡䵺圢耓珽涏渟䗴蝏甼嵉聼廰廳烴庁烶䱓鋌㹶邒桯榳楟頲颋筳䦐閮娗侹䋼綎誔諪",
+ "tong": "同通统痛童铜筒桶桐佟侗酮捅瞳僮彤潼嗵恸峒茼砼仝蓪㼧㪌䂈䮵犝朣赨眮浵晍蚒䳋曈哃㠽峝峂㠉膧慟㤏烔粡庝炵燑䆚䆹鲖鉵銅鮦狪獞鉖樋㮔橦筩憅㣠秱㣚衕穜䶱勭氃䴀㼿痌㛚㸗餇絧統綂詷",
+ "tou": "头投透偷愉骰亠蘣斢黈䞬頭㰯䟝㖣㡏䵉㢏鋀䱏鍮㪗敨婾媮妵㓱偸紏緰㕻䚵",
+ "tu": "图土突途徒吐涂兔屠凸秃荼钍菟堍酴蒤葖莵鷋駼鼵迌腯㐋堗圡瑹㻬㻯㻠㻌䖘汢潳涋湥塗跿䠈唋圗圖図嶀㟮䣝鷵怢悇廜庩宊鶟鈯釷鵵鵌鍎鋵揬捸捈䤅㭸梌䅷馟兎禿稌筡鵚瘏痜凃䣄峹嵞䳜",
+ "tuan": "团湍疃抟彖䵎貒墥剸鷒漙湪団䵯團畽圕慱䊜糰煓褖鏄鷻猯摶㩛槫檲篿䜝揣",
+ "tui": "推退腿颓蜕褪忒煺藬蓷蘈隤駾㞂尵㦌䍾䀃㱣螁蛻蹪蹆骽㷟㢈㢑魋橔頺䅪頹䫋頽穨㿉㾯㾽㿗㾼弚娧俀僓弟",
+ "tun": "吞屯豚臀囤褪饨鲀氽暾芚朜霕坉㼊豘涒旽蛌㖔噋黗軘臋忳㞘焞魨㹠㩔呑飩",
+ "tuo": "脱托拖妥拓驼陀唾椭驮沱砣鸵佗坨跎箨柁柝橐沲鼍庹酡乇䓕萚蘀莌阤嶞陁馱駄䭾驝騨驒駝馲駞㸰㸱毻碢砤鼧鵎脫堶槖沰汑涶跅鼉咜咃䡐㟎岮䪑袥袉㼠饦䲊鰖鮀鴕魠䰿鮵狏扡拕捝挩橢楕杔䴱彵籜䍫㾃嫷媠毤侂仛侻飥紽詑託讬",
+ "wa": "瓦挖娃哇蛙凹洼袜佤娲腽韈聉䎳砙膃劸㰪鼃䵷邷漥溛咓䠚嗢嗗畖㼘韤襪窐窪穵窊㧚搲攨屲瓾媧䚴",
+ "wai": "外歪崴呙㖞喎咼䶐䠿顡竵",
+ "wan": "完万晚玩湾弯碗顽挽烷婉皖蔓腕丸宛惋蜿豌绾纨莞剜脘畹塆菀芄琬箢薍萖萬㿸䂺䩊脕埦頑㝴刓壪琓瞣睕澫涴潫汍灣蟃晥晼晩踠唍輐輓贎䯈岏貦帵贃䝹忨卐卍翫䗕䘼䖤盌㽜鋄錽䳃鋔䥑鎫抏捖捥杤椀梚䅋笂妧婠倇㸘綰綩紈䛷彎",
+ "wang": "往王望忘亡网旺汪妄枉惘罔辋辋魍朢菵莣尪迋尫瀇㲿㴏㳹蚟蛧蝄暀罒輞罖罓㓁䤑棢徍彺䰣徃兦仼亾尣尩䋞䋄網䛃誷",
+ "wei": "为维围委未微谓卫味唯威危伟尾违伪慰魏喂胃纬畏韦惟苇萎尉蔚巍薇偎帷娓渭桅圩倭痿崴猬诿猥潍煨葳韪帏嵬玮逶炜隈隗洧涠沩囗軎鲔艉闱位䔺蔿䪋苿菋蓶葨䵋荱藯葦蘶芛蒍隇䧦䮹熭碨硙䃬㞇硊㕒䑊腲鄬爲䙿壝墛霨䞔霺䝐瑋㱬琟覹矀濻潙韑瀢渨潿溦浘湋洈濰溈蝛㬙韙蝟暐蜲蜼喴踓㖐喡䡺轊囲䵳罻圍㠕骩骫骪幃嶉嵔㟪峗峞嶶崣屗㞑叞褽犚螱㷉韡䪘韋違㥜愄愇懀燰烓煟煒寪頠鏏厃鳂鳚鍡鍏鮪鰄鮇鰃䲁鮠䥩撱㨊揻揋捤㧑楲㭏醀椳欈梶椲䈧㣲徫躗躛㦣衛衞䘙讆讏䉠覣犩䭳痏闈癓媙媦媁儰僞偉䬑颹䬐饖餵䬿餧偽縅緭緯㢻維䗽詴亹斖䜜謂䜅為諉",
+ "wen": "文问温闻稳纹吻蚊紊瘟韫雯汶刎璺阌鞰莬芠䎽駇馼鼤脗肳塭豱瑥䰚殟珳渂溫㳷昷㗃呡㖧呅輼辒轀蟁炆顐㝧鳁鎾鰛鰮魰揾搵抆榅榲桽穩穏䎹聞閿闅䦩閺問闦瘒妏㒚伆饂繧紋彣䘇螡蚉㐎鴍鳼",
+ "weng": "翁嗡瓮蓊蕹聬㹙㹚䐥䤰塕奣瞈滃暡螉㘢嵡䱵鎓攚齆䈵㜲勜鹟鶲罋甕",
+ "wo": "我握窝卧沃涡斡蜗喔倭挝龌渥莴幄硪肟臥萵䰀臒腛㦱瓁㱧齷䁊瞃濣渦涹蝸䠎踒唩㠛焥窩猧捰捾㧴枂楃婐媉婑仴偓",
+ "wu": "物无五务舞武屋误恶午吴伍污乌雾悟吾呜侮唔巫勿梧诬捂晤兀於芜戊毋鹉妩钨邬坞蜈婺鹜忤骛牾庑杌亡芴阢鼯圬浯鋈怃焐寤迕痦仵莁靰蘁茣蕪鹀鵐陚䎸隖奦務㡔嵍熃騖鶩䳱敄䮏鴮碔矹䃖䑁㬳霧雺霚塢墲鵡珷珸郚㻍㐚逜㐏忢瑦卼玝璑瞴洿汚汙洖溩㵲潕螐旿蟱䟼躌吳呉嗚䡧䍢峿屼嵨岉剭悮悞憮乄熓粅廡㷻窏窹祦鋙铻鄔鯃烏鰞歍鎢㹳扤摀㐅杇啎無鷡橆甒鼿齀箼䒉㽾䦜䫓䦍娬娪嫵娒倵俉㐳儛㑄弙䳇誣誈䛩誤譕",
+ "xi": "系西细习息吸喜戏析希席洗稀惜悉袭腊溪媳牺锡嘻夕隙晰栖膝熙昔烯熄禧鳃徙嬉犀蟋奚兮曦蜥汐翕玺唏螅铣淅硒皙熹窸羲矽檄郗忾僖屣歙樨觋娭豨咭葸菥蓰隰鼷舄浠粞裼穸禊饩欷醯舾阋㐂葈蕮蒵䩤䓇匸煕蓆莃薂蒠覡隵隟䧍䢄枲騱驨騽䮎犔犠犧磶磎礂䲪䙽㚛䐼䏮貕舃肸肹谿䫣㙾霼趘䨳趇欯囍憙歖霫赩赥豯卌琋壐璽瞦䀘鬩戲䖒矖戱卥戯睎盻覤㳧澙渓潟鸂虩漝㵿漇潝螇暿蟢蠵晞嚱躧蹝呬㗩㕧焁唽噏喺繫黖㽯嵠巇㠄嶍酅㔒忚㤴慀恄憘㤸怬屃屓屭㥡㦻悕習飁恓㞒屖焟熺糦㸍焬熂燨爔熻邜鐊觿觽觹鳛錫鑴饻鱚鰼鯑鉨釸鈢㹫㺣鎴釳鏭狶鉩扱鵗㩗忥氥扸墍㯕榽䙵橲槢桸晳惁椞㮩㭡厀椺橀怸熈㷩稧徯㣟䈪郋鄎徆襲㿇凞瘜闟䊠㜎衋嬆傒翖俙㑶係饎餼餏郤豀縘繥緆細縰綌绤謑䜁譆諰焈謵䛥䜣䚷洒蹊",
+ "xia": "下夏吓狭辖霞峡瞎厦虾暇匣唬遐侠黠呷瑕罅狎瘕硖柙蕸陿陜䖎騢硤碬磍夓埉圷㙤赮丅乤珨睱䖖虲蝦㗇㽠㘡翈轄峽懗䫗㰺䪗舝炠煆烚鶷䘥祫鎼鏬鍜魻鰕鎋狹梺筪敮舺閕䦖疜閜傄俠颬谺縖諕䛅",
+ "xian": "现先线显限县鲜险献宪陷仙闲纤腺弦贤嫌掀咸衔羡掺涎娴见酰舷藓馅锨铣冼霰暹籼苋痫氙蚬岘莶燹跹跣祆猃筅鹇藖韅䁂賢贒莧䵌㔵蘚䒸䕔薟苮䧟䧋䧮陥険險礥䃱尠䃸臔姭䏹鼸毨胘韯壏塪赻䨘垷埳䨷現豏珗䶢䶟獻睍縣鹹県盷瞯涀灦㳭瀗㶍㳄鍌㵪澖湺䝨尟㫫晛蜆䗾顕䘆㬗蛝顯㬎蚿㘋咁咞嘕哯蹮躚啣㘅嗛輱䞁幰峴㡉崄嶮㦓忺憪憸糮粯廯䵇烍㡾麙鶱憲褼襳禒鑦臽䚚䀏鋧䥪䱤鱻䲗鮮銽錎䤼鍁銛铦銑獮玁狝㺌獫㧋搟攇㩈㧥撊撏挦攕㮭醎枮櫶杴㭠橌橺麲㭹㯀䉯䢾㪇箲馦秈銜䉳衘稴屳閒鷳羨鷼閑鷴㜪䦥㿅癇癎甉㛾娊奾嫺嫻嬐孅娹妶仚僊僲僩僴餡韱佡伭綫纎繊線缐絤㢺纖婱絃諴誢䜢譣誸洗",
+ "xiang": "想相象向响像项乡降香羊享箱祥详湘橡翔巷厢镶襄饷骧芗飨衖葙蟓庠鲞缃缃項瓨䔗萫䢽薌驤䐟膷䜶珦瓖晑䖮曏跭㟟嶑㟄䊑廂麘襐勨鱌鱶鱜鐌銄鑲栙楿欀缿稥忀鮝鯗姠佭餉饟緗鄊蚃鄕郷鄉蠁響嚮㗽饗絴纕亯㖜㐔詳",
+ "xiao": "小消笑效校销削晓肖硝萧孝啸潇俏嚣哮淆宵箫霄筱逍骁姣枭哓鴞蛸崤魈枵绡绡䒕虈䕧䒝蕭藃驍硣膮斅斆㬵毊瀟揱涍㕾敩洨蠨蟏暁曉蟂蟰嘵嘋鸮踃嚻囂呺嘐㗛咲嘯嘨髐髇憢㤊恔庨焇灲熽䊥灱宯窙銷鴵䥵梟㹲猇獢郩殽皢皛撨櫹穘鷍筿簫簘篠痚痟効㔅歗婋虓侾翛㑾烋颵俲傚綃彇謏誟歊誵訤詨",
+ "xie": "些解写协谢械鞋斜谐胁泄歇邪契携卸屑泻蟹懈挟蝎偕楔勰亵燮鲑撷颉榭邂缬澥瀣廨躞叶薤渫獬榍绁靾鞢鞵䕵䩧䢡藛薢䕈䔑㔎㕐絜脅脇劦膎協㙝奊翓塮暬垥瑎齛齥齘禼卨䪥韰㱔㳦洩㴮瀉㵼㴬㴽㳿蝢旪蠍蠏㖑嚡噧㖿嗋䵦䡡峫嶰屟恊愶屧㞕㥟㦪灺緳熁燲糏炨炧䊝冩寫㝍褉䙎襭䙊祄㙰䲒䥱䥾猲揳挾拹㨙擷攜㨝烲焎娎㩉㩦擕㩪㰔䉣缷徢齂㣯䉏㣰䦑㸉㓔䦏媟孈脋伳偞偰龤㙦㒠㰡僁䭎紲緤綊纈絏縀繲絬衺䚳䙝褻讗爕夑㽊謝䚸諧血",
+ "xin": "心新信欣辛薪锌芯馨鑫衅昕訢忻莘炘歆囟忄镡䒖阠孞馸舋釁脪盺噷噺軐惞廞焮襑鈊䰼鐔鋅邤㭄杺枔馫顖嬜妡㛛㚯㐰伈俽伩䜗䚱訫䛨",
+ "xing": "行性形兴型星省幸醒刑姓杏猩腥邢惺悻荥陉擤荇硎饧䓷莕葕陘骍騂臖興㐩㓝㼬㙚垶㼛郉瑆䣆䁄睲涬洐蛵曐哘䳙煋滎㝭觲觪䤯鈃钘鉶铏銒鋞鯹鮏㨘䰢皨㮐䂔㣜箵篂㓑嬹婞娙倖侀餳緈䛭謃",
+ "xiong": "雄兄胸凶熊汹匈芎熋䧺洶焽焸哅賯恟忷夐敻胷匂兇詗诇詾訩讻㐫",
+ "xiu": "修秀休袖臭羞绣朽锈嗅溴貅岫咻宿髹庥馐鸺苬髤脙璓臹珛㱙琇潃滫螑嚊㗜峀糔烌鱃鮴鏥銹鏽鎀鏅銝樇齅㾋脩鵂俢飍饈綉繡繍褎褏",
+ "xu": "许需须续序虚徐绪叙蓄吁絮婿嘘旭栩墟畜浒戌胥圩恤煦蓿酗顼诩魆洫盱砉溆勖糈醑芧蕦藇藚㰲蒣聓䔓㜿䦽㞊䳳㷦㕛㐨䂆驉㚜㦽鬚䢕盨媭嬃須㘧壻垿珬頊珝殈㺷瞁虛歔虗汿沀㵰湑潊漵朂晇暊勗旴冔蝑昫㖅噓㗵呴喣盢㞰賉怴㤢㥠慉燸烼歘欻烅裇䙒禑銊鑐欨鱮䱬獝揟魖䣱䣴楈槒聟䅡鄦卹䘏欰稰稸疞㾥䦗䍱姁㜅㑔㑯敍敘伵偦䬔侐俆䋶續続緒緖縃綇䜡訏譃諿詡諝谞訹許䛙休邪",
+ "xuan": "选宣旋悬玄喧轩绚眩炫渲漩暄萱癣煊镟璇县碹泫铉揎楦痃儇谖萲䩰鞙䩙蓒蕿藼蘐蔙䧎駽䮄塇璿琄瑄琁玹懸睻眴矎贙䁢㳙㳬晅昍蠉暅蝖蜁暶昡咺䠣吅軒翾䴉㘣䍗䝮愋懁選愃怰烜翧䘩袨禤䚭䚙鋗䴋鰚䲂鍹㹡鏇鉉㧦楥梋檈箮衒䍻癬㾌媗嫙颴弲繏絢縼諼譞諠䗠䲻券",
+ "xue": "学血雪削穴薛靴谑踅噱鳕泶蒆鞾茓辥膤學觷壆澩嶨燢鷽䨮趐坹瞲㔧辪㶅瀥峃鸴㗾㖸吷轌㞽㡜岤䎀袕鱈䱑狘㧒㿱乴樰䤕桖艝疶䫻䬂䫼䭥斈謔",
+ "xun": "训迅寻循讯巡询旬逊驯勋熏汛殉荀薰峋洵浚鲟徇浔醺窨荨埙巽蕈孙曛恂郇獯蘍薫愻遜馴駨顨奞毥臐壦攳坃塤壎殾燅珣璕矄潠潯畃䖲蟳勛噀嚑噚䞊卂巺㽦爋燻燖䙉㝁迿㰬鱏鱘鑂狥㨚灥揗㰊杊栒樳桪稄勲勳鄩尋廵焄㜄侚伨偱㒐䭀紃䋸纁㢲訓訊詢䛜訙",
+ "ya": "压呀亚牙雅芽鸭押崖哑鸦讶丫涯轧衙娅伢蚜桠氩垭碣琊疋迓邪砑睚吖岈揠痖蕥䪵鴉聐孲厊圧厓䃁壓厑䝟劜堐埡圠玡亞鵶䢝㰳亜襾齖齾漄啞唖圔䵝軋鴨崕䯉㿿庌䊦庘㝞窫錏鐚铔䰲犽猰猚㧎掗氬挜枒椏覀笌䄰稏䅉冴疨瘂䦪婭俹訝",
+ "yan": "眼研验言严演烟沿盐延颜岩炎燕掩厌艳咽焰铅宴衍殷阎雁淹砚檐焉彦蜒俨奄谚腌堰晏胭嫣阉湮筵兖妍偃唁鼹恹琰赝魇滟酽焱餍甗郾菸厣埏鄢罨崦剡闫谳讠鹽匽鶠䕾酀㬫鷰㷼䴏嬊莚萒蔅䓂隁隒驠騴騐験驗牪硽黡䊙揅硏硯夵魘厭厴懕黶檿嬮饜䣍剦礹䂩鳫贗鴈贋㷳䶮䂴臙䑍鼴墕壧䎦䀋塩壛㿼䢥珚琂齞齴䖗鬳䁙覎䀽虤沇厳漹灔灎灧灩淊溎渷㶄㳂渰蝘曣㦔猒䗡暥曮鷃曕妟䳛昖㫟嚥嚈囐嚴碞喦嵒㘙啱㗴喭㘖噞黭黫黬黤艶艷豓豔巘巚巌嵓巖巗觃嵃嶖愝懨熖㷔焑敥炏焔煙烻㢂爓㢛麣戭褗裺鴳䄋䤷觾燄鰋䲓䱲狿抁揜椻㭺歅醼醃釅醶欕棪樮椼櫩楌篶郔䗺躽軅簷䅧䇾閆閹龑䢭兗乵閻顔遃㿕嬿㛪姸孍㚧姲娫娮傿弇顩㕣儼偐䭘酓㓧䳺䨄縯䊻綖䌪讌䜩顏彥訮詽讞扊諺㫃訁",
+ "yang": "样养阳洋氧央杨扬羊仰秧痒漾疡佯殃鸯怏鞅恙徉炀暘泱蛘烊陽阦駚礢胦䑆霷雵坱垟珜䁑眏眻瀁䬗昜敭蝆䖹旸㬕咉䵮輰軮㿮崸䒋鴦崵岟㟅懩慃煬炴鍈卬鍚鉠钖鰑㺊氜揚氱抰㨾攁楧鸉楊柍様樣䇦劷羏㔦羕飬養瘍鴹癢姎佒飏颺䬬䬺䭐傟紻諹詇详",
+ "yao": "要药摇腰咬耀遥邀瑶姚窑妖谣钥尧么乐吆肴夭侥舀幺徭珧杳窕窈鹞繇曜爻约轺崾鳐䔄蘨靿薬藥葽蓔苭葯騕磘㞁䂚䍃颻鷂飖尭垚顤堯瑤殀䋤䶧齩䁘㔽矅覞䁏眑㴭溔滧㵸㿢暚䖴㫐嗂喓鷕軺峣嶢嶤岆㟱愮熎燿烑㢓䴠宎㝔䙅袎窰䆙窅䆞穾窯窔祅鎐鰩鱙猺遙獟狕䚻䢣䌛邎揺抭搖㨱摿榣柼㮁楆枖榚鴁鼼䉰筄䑬艞㿑闄媱婹傜倄偠仸䬙餆餚鴢䌊䋂纅謡謠訞㫏䚺讑詏疟",
+ "ye": "也业夜叶液爷野喝页冶耶咽邪拽曳腋椰掖噎晔谒揶射邺靥吔烨铘䓉葉枼䧨驜靨擪㪑頁礏墷枽㙪㐖璍瑘殗瞸瞱潱澲漜洂曄曅蠮暍曵曗嘢㗼㖶㖡㙒嶪嶫燁煠㥷爗鄴鸈業㱉㝣鐷鋣釾䥺鍱鎁䤶鎑馌䲜䥟䥡䤳擛皣捓抴擫歋㩎捙擨㭨壄埜䈎㸣僷倻爺䭟餣饁謁亪亱鵺",
+ "yi": "一以义意已艺易议咦依益衣异医移遗疑亦宜仪忆伊倚乙亿抑役毅译椅翼姨蚁泄谊疫逸矣溢夷疙绎尾蛾怡胰贻裔彝邑奕翌屹臆颐诣驿熠咿蜴漪沂呓揖弋轶迤懿悒佚羿噫铱弈壹肄翳癔缢刈旖苡怿痍猗诒峄食射荑薏埸圯殪眙嗌黟嶷嶷衤饴钇镱镒挹酏劓舣瘗翊仡佾蘙芅匜䩟藝蓺虉弌頤巸媐䖁䓃㔴䔬苢勚勩萓苅殹㙠醫鹥瞖繄䗟贀悘鷖黳嫛毉瑿萟䓈藙䓹䕍䬥隿耴迆阣䧧㹓瓵䮊驛駅䭿逘礒䝝帠肊䐖䐅鶂膉貖䝘敼㰻霬墿夁亄㦤鷧㱅壱坄㙯埶㺿玴珆豷豛䰙鹝鷊辷㱲殔鴺乁頥齮齸頉㵩浳㶠渏沶㴁洟浥潩㳑瀷㲼泆浂澺洢㵝㴒湙曀蛦晹䗑曎螘蛡敡鶍螔蟻䗷螠蛜暆囈呭㘊跇遺跠㖂唈㘁呹吚㕥㘈異欭輢黓睪斁歝圛軼轙畩貤貽䞅骮䯆顗峓幆嶧䝯嶬崺怈㦉恞㠯䎈郼䢃懌乛㞔㰝㥴忔攺憶㡼廙熼燡㢞熤燚熪燱炈庡焲宧冝宐㝖襼袣䘝衪裿褹袘寱䘸䆿迱寲䄁祎禕釴鈘釔鉯䱌鶃鮧鯣䱒鳦鸃䲑鮨鏔匇迻狋㹭獈鐿鎰鈠銥撎䖊㣻拸乂㩘枻杙杝槸䣧醳醷桋栧椬栘柂檥檍榏枍䴬㰘椸檹棭䄬劮鄓㓷䇵䄿䇩穓顊稦笖簃乊䉨艗艤秇垼篒籎瘞瘱豙䴊義羛羠鷾痬䦴竩兿鹢鷁嫕㚤㜒嬄㚦㛕彛彞嬟嬑㜋㛄佁侇㑥俋伿㐹㑜䬁儀億飴饐䭂䭞䬮䭇伇㥋偯㑊弬㣂㡫䋵繹䋚䌻彜觺㽈繶縊讛詍裛詒旑訲讉譯㦾扅悥扆訳帟誼誃謻㫊議譩詣蛇",
+ "yin": "因音引印银阴隐饮姻吟殷荫淫尹茵寅蚓瘾龈垠胤喑氤窨鄞吲圻狺铟茚霪堙洇廴夤夤蘟蔭䕃鞇靷荶蔩䓄蒑䒡隱檃櫽隠阥陻隂陰骃駰㹜碒磤㕂㥯㸒䨸霒趛赺韾堷霠烎璌殥慭珢齗齦龂䖜濦滛濥垽㴈峾溵乑湚泿洕朄螾蟫噖嚚㖗噾䡛囙輑圁嶾湮㡥崟崯㞤訔㦩懚㥼愔廕粌㝙㪦冘裀禋䤺䲟淾銦鮣犾鈝㹞銀鈏㼉㧢斦慇㐆㧈檼垔䤃酳鷣栶檭猌㙬憖憗筃秵㣧䇙癮癊䪩㾙闉凐瘖訚誾婬婣飲侌㱃飮䌥絪緸讔䚿諲訡",
+ "ying": "应影英营映迎硬盈婴鹰颖赢荧蝇莹莺樱瑛萤鹦萦缨膺瀛荥璎嘤媵罂瘿茔楹郢滢颍嬴景蓥潆撄萾㲟鶧蘡藀䕦盁孾碤礯䃷朠膡䑉霙䨍珱瓔㼆䁐䀴鷪渶溁溋㵬浧㴄瀴濴瀠㶈瀯濙灐濚瀅蛍営鴬灜暎蝧蝿蠳蠅嚶甖巊鑍鸎罌嬰鸚賏譻巆愥煐㢍廮応罃褮塋䁝禜縈螢䪯營熒鶯覮鎣嫈瑩甇謍鶑噟應鷹譍䙬锳鐛鱦䤝㨕攖摬攍桜梬櫻櫿矨軈籝籯韺癭㿘媖孆偀僌㑞䭊䭗緓绬頴颕潁纓㯋穎贏",
+ "yo": "哟唷喲",
+ "yong": "用永勇拥涌庸泳佣咏雍踊蛹臃俑甬壅鳙恿痈邕喁慵湧墉镛饔苚蒏㦷勈硧砽惥埇䞻塎㙲慂滽㴩灉澭顒颙䗤踴嗈噰㞲嵱愑悀愹怺㶲醟鄘鷛廱彮㝘鲬鯒鱅鰫鏞郺擁柡栐㷏牅癰癕雝嫞傭㽫詠",
+ "you": "有又由优油友游右幼尤犹忧邮幽诱悠铀佑黝柚囿蚴酉釉疣猷莠攸祐鱿繇鼬蚰牖呦莜莸尢卣蝣宥铕侑苃䒴蕕䢊聈牰駀䀁鄾迶憂䳑肬貁䞥耰丣㻀逌䚃瀀沋㳺湵滺浟泑蜏䖻哊嚘㕱唀㘥輏輶㽕峟甴峳懮㤑怞怮庮麀䆜禉銪鲉鈾魷鮋䱂㹨狖㺠猶逰㮋栯櫌櫾酭梄槱楢郵怣牗㰶秞䅎䑻㕗羑㾞羪姷㚭優佦㒡㛜䬀偤纋孧㓜訧亴䛻遊誘䢟㫍",
+ "yu": "于与语育鱼余雨预域玉遇予欲宇愈渔誉郁羽狱御裕愉豫愚喻娱寓浴吁舆尉榆俞禹屿淤逾峪谕於迂虞瘀驭芋隅渝瑜阈毓盂汩熨禺腴揄臾煜钰彧鹬鬻谀馀聿纡竽伛龉觎圄欤妪玙邪蓣萸舁雩蜮昱蝓圉嵛庾庾燠窳窬饫狳瘐妤肀俣鹆蕷蘛㔱䩒芌蕍茰蒮䔡䖇萭薁蓹蘌茟匬萮陓隃䂊矞預鷸遹䮙驈馭䮇騟䂛戫礖砡㝼礇䃋硢硲䏸礜轝㦛鸒歟與譽輿䐳雤貐斞霱堣䨒迃亐圫䨞堬堉琙璵邘㺮㪀玗䢩敔䜽鳿瑀齬齵鸆䁌䲣䱷䁩睮歶淢㳚潏滪澦㳛盓澞湡漁灪淯㶛虶㬂䗨欥㬰蜟噳踰喅喩唹罭㽣輍骬嶼㠘髃嵎嶎䍞㠨崳惐䣁忬頨懙㥚㥥㤤㥔燏㡰粖庽㷒爩麌焴䢓㲾䴁䵫寙㝢䙔衧褕䆷穻鴧鴪䄏祤鈺鍝魣鱊鰅鴥鷠䰻魚鮽鯲㺞鐭䥏㺄獄銉鋊錥㼌扝扜挧魊扵棫櫲桙楰醧杅酑鬰欎欝鬱楀楡棛棜稢䈅稶穥籅䍂䄨䘘牏鄅㙑軉秗禦䉛篽籞艅艈籲込箊閾瘉羭癒䘱嫗嬩㚥䢖媀娛娯傴伃僪㒜儥兪覦歈㼶悆雓俁㑨㒁䬄偊饇飫餘螸慾鵒俼緎紆䋖㣃逳袬諛謣語斔䛕旟諭乻吾奥粥",
+ "yuan": "原员远元院源愿圆园缘援袁怨冤渊猿宛苑垣媛鸳辕沅爰橼塬鸢圜螈垸瑗鼋湲芫眢掾蒝薳䩩薗蒬茒葾鳶㹉䏍貟贠騵厡厵願鶢䳒䳣遠鼘逺邧䲮黿㤪盶溒渁鼝淵渆渕灁蝯蚖蜵蜎䖠蝝肙剈噮鶰員圎園轅囦圓㟶円㥳悁鹓惌鵷寃褑褤裫裷禐駌夗鴛妴鎱鈨魭鋺猨㭇榞榬杬酛棩櫞笎衏邍羱䅈䬇嫄媴嬽傆㥐䬧䬼䨊縁緣謜䛄䛇",
+ "yue": "月约越跃阅悦曰岳乐粤兑钥栎钺说刖瀹哕樾龠䖃戉蘥㹊玥䢲泧㬦蚏蚎䟠噦跀躍䠯啘䢁黦䡇軏岄曱嶽恱悅爚礿禴鉞鈅䥃鸑䤦鑰抈捳㰛籆矱籰粵籥篗箹閲閱嬳㜧妜㜰鸙䶳䋐約",
+ "yun": "运云允匀韵孕晕蕴芸陨酝韫耘恽纭熨愠氲筠郓郧殒员昀狁蕓䩵荺蕰薀蒀蒷蒕藴蘊阭耺隕馻夽奫磒腪䢵䨶䲰雲䞫霣㚃鋆殞齫齳眃沄澐涢溳蝹暈鄖䚋喗囩䵴䡝畇賱㟦㞌韗韞愪慍惲煴熉熅鄆運褞䆬鈗䤞勻抎氳抣枟橒醖醞秐䉙馧筼篔䦾䇖韻㾓㚺妘㛣㜏䪳伝傊餫紜緼缊縜縕贇赟",
+ "za": "杂咱扎咋砸咂匝拶䕹臜臢䞙䪞帀迊沯沞囋囃襍鉔魳桚韴雑雥雜",
+ "zai": "在再载灾仔栽宰崽哉甾䏁䮨載䵧烖㦲酨㱰睵渽溨洅㴓㞨賳扗畠䣬災傤儎縡",
+ "zan": "咱赞暂攒簪瓒錾糌趱昝㔆兂趲瓉鄼賛瓚濽灒噆喒暫蹔鏨㟛寁襸禶鐕鵤鐟撍攅攢揝橵贊簮㜺儧儹偺㤰饡㣅讃讚",
+ "zang": "藏脏葬赃臧奘驵蔵塟匨駔臟臓羘㘸贜贓髒賍賘弉銺牂",
+ "zao": "造早遭藻燥糟灶躁枣凿噪皂澡蚤唣薻䥣㲧趮栆璪璅䖣䗢蹧喿唕慥㷮煰鑿竃竈䲃皁醩棗梍簉艁䒃傮䜊譟",
+ "ze": "则责择泽侧啧仄赜咋昃帻箦迮舴蔶賾䕪䕉矠礋責齰䶦齚歵瞔㳻㳁澤溭沢滜泎汄蠌昗㖽嘖鸅幘則崱庂襗䰹皟捑擇択樍䇥簀㣱嫧諎謮",
+ "zei": "贼蠈賊戝鲗鱡鰂",
+ "zen": "怎谮䫈譖",
+ "zeng": "增综赠憎曾锃甑罾缯鬵磳増䰝璔囎㽪贈熷䙢鋥鱛橧矰鄫曽繒譄",
+ "zha": "扎炸眨渣闸喳榨诈栅札乍楂喋蚱柞铡咤查咋砟哳吒揸齄痄䕢䃎厏䞢耫霅㱜皻㪥㗬㴙溠䖳灹㡸宱觰鲊鍘鮓䥷抯摣紥挓搾拃柤醡樝皶蚻紮䵵牐齇劄箚䵙㷢閘鲝鮺偧㒀䋾譇䛽詐譗",
+ "zhai": "摘窄债宅寨斋翟砦责择侧祭齐瘵㡯鉙粂捚㩟榸檡夈債斎齋",
+ "zhan": "展战站占粘颤沾崭盏斩毡湛瞻栈辗詹绽蘸谵旃霑搌㠭菚虦盞䪌薝驏驙䩅氊趈䟋琖㻵虥䁴惉戦魙䗃蛅戰噡輾轏斬覱㟞岾嶃嶄嶦嶘㞡䎒䘺鳣䱠䱼鱣㺘棧桟醆枬榐栴橏䡀㣶閚嫸偡佔僝飐颭飦饘䋎綻詀讝氈鹯鸇邅譧譫旜",
+ "zhang": "长张章掌丈障涨帐仗胀账杖璋彰樟瘴漳蟑嶂鱆獐幛鄣嫜仉蔁騿礃脹墇㙣瞕涱漲暲㕩賬帳幥慞粻粀麞鏱扙痮㽴遧瘬傽餦張",
+ "zhao": "找照招召赵着兆昭沼诏朝钊肇濯啁棹罩爪嘲笊䮓駋㕚䃍㐍䝖爫趙垗瑵瞾曌㷖䍜羄燳㡽炤鍣釗鮡狣㺐鉊㨄櫂枛罀箌䈇䈃䍮㐒巶妱㑿佋皽肈肁旐詔",
+ "zhe": "这着者折哲遮浙蔗褶辙锗辄蛰蜇赭柘鹧摺螫谪著磔䩾䓆䎲㪿䮰䂞矺厇砓詟䐑䐲䏳喆嚞乽蟄謺䝕䝃歽淛蟅晣虴啫踷䠦嗻輙輒䵭轍㞏䗪鷓粍籷䊞襵袩銸鍺鮿埑晢啠悊㯰樜讋嫬這謫讁",
+ "zhei": "这",
+ "zhen": "真针阵镇振珍震诊侦贞枕圳砧斟疹臻甄祯桢朕赈帧榛缜箴畛稹填蓁胗溱浈轸鸩椹葴蒖䑐䫬薽萙塦陣聄㓄駗碪鬒䂧䂦㪛䏖䨯瑧殝珎遉貞眹眕㴨湞潧澵昣䟴辴轃黰甽軫賑帪幀䝩屒䲴寊䪴鴆裖袗禛禎鍼鋴針鎮錱覙鱵獉鉁鎭挋䳲揕搸抮㮳酙楨樼㯢栚籈姫嫃侲㐱偵弫䊶縥絼縝㣀眞紾紖纼誫診",
+ "zheng": "正政争整证征丁蒸症郑睁挣怔拯铮筝狰峥诤徵钲聇脀烝氶䂻鬇爭㱏埩靕鴊䥭睜眐塣晸踭䡕崢崝幁㡧㡠炡䥌鉦錚猙鏳掙揁掟抍撜愸篜箏徰䈣䦛䦶鄭㽀癥姃媜佂凧䋊䋫糽䛫証諍證",
+ "zhi": "之只制质知指直至志织支值致职止植置纸智执殖枝脂秩肢滞拓汁旨址稚芝吱帜蜘挚掷侄趾治识酯窒峙炙桎栉雉祗芷咫痣栀氏胝祇跖踯鸷蛭枳帙痔徵贽姪沚陟骘陟膣豸埴郅踬轾轵忮黹祉觯卮摭絷夂彘蘵芖䓌䛗䓜迣茋䓡藢䕌聀阯騭隲䏄㝂䎺職犆馶駤馽厔㕄砋礩䐭䐈䏯胑䑇乿膱墆鳷䧴坧䟈㙷覟墌疐坁垁漐縶贄慹騺鷙䥍摯執瓡驇臸瓆璏歭㫖淽滯滍汥洔淔洷㴛潌汦泜潪瀄晊蟙跱蹠躓躑㗌㗧㘉畤䡹輊軹豑豒剬䞃幟崻懥懫翐恉庤庢廌㡶熫寘衼襧衹袟禃祬祑帋觶觗䚦鋕銍铚䳅䱨鯯㩼锧鑕狾猘釞劧㨁貭搘挃㨖巵㧻抧摨搱扺扻劕質䭁擳擲梽榰㮹梔櫍椥柣櫛䵂栺樴㲛䝷鼅䵹鴙䅩秓徝稙憄䉅秷製䱥䄺䇛徏軄徴筫穉䆈稺秖䇽䉜㣥瘈痓䦯疻疷㜼娡㛿妷嬂値俧凪傂儨倁偫䬹隻綕緻䌤鴲紙紩織誌訨袠戠䫕旘",
+ "zhong": "中种重众终钟忠肿仲衷踵盅冢锺忪螽舯茽蔠刣尰鼨腫塚堹歱泈汷蚛蜙蹱喠眾幒煄炂衶衳祌銿鈡䱰鴤鍾狆㹣鐘㲴柊衆籦種㣫徸彸筗瘇妕媑妐偅伀㐺終螤諥蚣",
+ "zhou": "周州洲宙轴骤皱昼舟咒粥肘帚绉胄纣诌妯繇啁调荮碡酎籀䩜菷葤㔌䎻驟駎騆駲䐍䶇霌盩珘睭淍䖞晭嚋呪喌噣咮輖軸輈辀冑郮週㼙賙赒㥮粙炿烐皺鯞銂矪徟甃籒籕鸼鵃箒䈙䇠疛㾭晝婤㛩伷㑳㑇僽侜紂縐䋓诪譸詋䛆謅",
+ "zhu": "主住注助逐著宁筑诸珠猪竹朱柱祝驻株贮嘱煮铸烛蛛瞩竺蛀拄伫褚诛侏澍潴箸渚炷躅铢瘃苎术属茱翥洙麈橥杼槠邾舳疰丶茿莇藸蓫䕽苧蕏陼䎷逫馵䮱駯駐劯硃砫䐢墸䟉壴坾䬡煑䝒豬櫫矚眝㵭瀦濐灟乼蝫蠋曯蠾蠩跦跓囑鸀罜軴帾貯嵀䝬劚斸㤖㔉㫂燝燭爥炢麆㝉㿾䘢袾窋宔祩鋳鑄钃鯺鱁鮢䥮㺛銖㹥鉒拀㧣柷欘樦櫧笁篫築笜筯䍆鴸鼄篴䇧簗䇡秼㾻竚羜孎㑏佇䭖飳䰞䌵纻紵絑紸諸迬殶詝誅註",
+ "zhua": "抓爪髽膼撾檛簻挝",
+ "zhuai": "拽转跩",
+ "zhuan": "转专砖赚撰篆传颛馔啭蒃孨磚磗膞腞塼堟瑼鄟專甎叀専瑑蟤囀䡱転轉顓賺灷襈鱄篹籑䉵竱嫥僎饌縳諯譔",
+ "zhuang": "状装庄壮撞桩妆幢僮奘戆庒荘莊壵湷糚粧樁梉狀壯焋娤裝妝",
+ "zhui": "追缀椎坠锥赘惴骓隹缒墜騅硾礈腏膇贅沝畷䄌錣鑆鵻錐醊甀笍娷綴縋諈揣",
+ "zhun": "准谆淳屯肫窀埻迍準啍㡒宒衠稕凖綧訰諄",
+ "zhuo": "捉桌著卓着浊灼啄琢拙酌镯茁斫濯淖涿棹擢焯浞禚倬诼斮斲䕴䪼叕硺䶂龺圴斱琸鵫灂濁汋晫蠗啅罬斀劅㣿㪬蠿烵炪丵窡窧鐯鋜鐲㺟犳斵擆撯棳椓㭬槕櫡棁梲穱籗籱篧彴䅵穛娺妰諁諑謶鷟缴",
+ "zi": "自子资字紫仔姿滋兹姊籽咨孜渍梓髭恣滓谘淄呲孳鲻龇辎甾眦秭赀吱齐茈趑耔觜訾嵫锱笫粢缁芓蓻茡荢䔂茊葘菑茲孖牸矷頾頿胏䐉胾嗭赼趦鼒㺭剚鄑㱴㰷齜眥呰啙貲胔鈭㰣姕漬澬湽虸吇嗞輺輜崰䘣禌釨鰦鯔鎡镃鍿錙㧗杍橴榟椔秄䅆稵資栥秶㾅㜽姉鶅倳紎緇緕纃訿齍諮孶玆",
+ "zong": "总宗综纵踪棕粽鬃熜偬从腙葼蓗骔騌騣惣㹅鬉䰌碂磫朡堫䝋豵鬷昮蝬䗥蹤踨䍟嵏嵕嵸惾翪燪糭㷓糉㢔焧鑁鯮鯼鍐猔猣㚇揔摠搃捴㯶椶稯熧瘲疭倧傯倊綜緫緵總繌縦縱縂総緃",
+ "zou": "走奏邹揍陬鄹驺鲰诹菆郰棸騶赱㔿齱齺㵵䠫黀鄒鯫鯐掫棷箃緅諏",
+ "zu": "组族足祖阻租卒诅镞俎菹靻䔃蒩葅䯿珇䖕唨踤哫㞺崒崪䚝䱣鎺鏃爼椊䅸箤卆組䘚詛㲞㰵",
+ "zuan": "钻攥纂躜缵繤䂎躦鑚鉆鑽䤸劗籫纉纘䌣",
+ "zui": "最嘴罪醉咀蕞䮔厜璻蟕晬嗺噿嶵㠑嶊冣㝡䘹祽鋷錊酻酔樶檌㰎栬槜檇辠䘒稡纗絊",
+ "zun": "尊遵樽鳟撙墫噂嶟鶎銌鱒鐏捘罇鷷僔繜譐",
+ "zuo": "作做坐左座昨佐琢撮柞唑祚捽阼胙嘬怍酢笮葄葃蓙䔘苲莋㸲㝾䞰䎰咗㘀㘴岝岞䝫糳袏鈼㭮稓穝秨筰㛗㑅飵侳繓䋏"
+}
diff --git a/dotfiles/.config/mpv/scripts/uosc/elements/BufferingIndicator.lua b/dotfiles/.config/mpv/scripts/uosc/elements/BufferingIndicator.lua
new file mode 100644
index 0000000..13674f3
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/elements/BufferingIndicator.lua
@@ -0,0 +1,39 @@
+local Element = require('elements/Element')
+
+---@class BufferingIndicator : Element
+local BufferingIndicator = class(Element)
+
+function BufferingIndicator:new() return Class.new(self) --[[@as BufferingIndicator]] end
+function BufferingIndicator:init()
+ Element.init(self, 'buffering_indicator', {ignores_curtain = true, render_order = 2})
+ self.enabled = false
+ self:decide_enabled()
+end
+
+function BufferingIndicator:decide_enabled()
+ local cache = state.cache_underrun or state.cache_buffering and state.cache_buffering < 100
+ local player = state.core_idle and not state.eof_reached
+ if self.enabled then
+ if not player or (state.pause and not cache) then self.enabled = false end
+ elseif player and cache and state.uncached_ranges then
+ self.enabled = true
+ end
+end
+
+function BufferingIndicator:on_prop_pause() self:decide_enabled() end
+function BufferingIndicator:on_prop_core_idle() self:decide_enabled() end
+function BufferingIndicator:on_prop_eof_reached() self:decide_enabled() end
+function BufferingIndicator:on_prop_uncached_ranges() self:decide_enabled() end
+function BufferingIndicator:on_prop_cache_buffering() self:decide_enabled() end
+function BufferingIndicator:on_prop_cache_underrun() self:decide_enabled() end
+
+function BufferingIndicator:render()
+ local ass = assdraw.ass_new()
+ ass:rect(0, 0, display.width, display.height, {color = bg, opacity = config.opacity.buffering_indicator})
+ local size = round(30 + math.min(display.width, display.height) / 10)
+ local opacity = (Elements.menu and Elements.menu:is_alive()) and 0.3 or 0.8
+ ass:spinner(display.width / 2, display.height / 2, size, {color = fg, opacity = opacity})
+ return ass
+end
+
+return BufferingIndicator
diff --git a/dotfiles/.config/mpv/scripts/uosc/elements/Button.lua b/dotfiles/.config/mpv/scripts/uosc/elements/Button.lua
new file mode 100644
index 0000000..6ae84a3
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/elements/Button.lua
@@ -0,0 +1,100 @@
+local Element = require('elements/Element')
+
+---@alias ButtonProps {icon: string; on_click?: function; is_clickable?: boolean; anchor_id?: string; active?: boolean; badge?: string|number; foreground?: string; background?: string; tooltip?: string}
+
+---@class Button : Element
+local Button = class(Element)
+
+---@param id string
+---@param props ButtonProps
+function Button:new(id, props) return Class.new(self, id, props) --[[@as Button]] end
+---@param id string
+---@param props ButtonProps
+function Button:init(id, props)
+ self.icon = props.icon
+ self.active = props.active
+ self.tooltip = props.tooltip
+ self.badge = props.badge
+ self.foreground = props.foreground or fg
+ self.background = props.background or bg
+ self.is_clickable = true
+ ---@type fun()|nil
+ self.on_click = props.on_click
+ Element.init(self, id, props)
+end
+
+function Button:on_coordinates() self.font_size = round((self.by - self.ay) * 0.7) end
+function Button:handle_cursor_click()
+ if not self.on_click or not self.is_clickable then return end
+ -- We delay the callback to next tick, otherwise we are risking race
+ -- conditions as we are in the middle of event dispatching.
+ -- For example, handler might add a menu to the end of the element stack, and that
+ -- than picks up this click event we are in right now, and instantly closes itself.
+ mp.add_timeout(0.01, self.on_click)
+end
+
+function Button:render()
+ local visibility = self:get_visibility()
+ if visibility <= 0 then return end
+ cursor:zone('primary_click', self, function() self:handle_cursor_click() end)
+
+ local ass = assdraw.ass_new()
+ local is_clickable = self.is_clickable and self.on_click ~= nil
+ local is_hover = self.proximity_raw == 0
+ local foreground = self.active and self.background or self.foreground
+ local background = self.active and self.foreground or self.background
+ local background_opacity = self.active and 1 or config.opacity.controls
+
+ if is_hover and is_clickable and background_opacity < 0.3 then background_opacity = 0.3 end
+
+ -- Background
+ if background_opacity > 0 then
+ ass:rect(self.ax, self.ay, self.bx, self.by, {
+ color = (self.active or not is_hover) and background or foreground,
+ radius = state.radius,
+ opacity = visibility * background_opacity,
+ })
+ end
+
+ -- Tooltip on hover
+ if is_hover and self.tooltip then ass:tooltip(self, self.tooltip) end
+
+ -- Badge
+ local icon_clip
+ if self.badge then
+ local badge_font_size = self.font_size * 0.6
+ local badge_opts = {size = badge_font_size, color = background, opacity = visibility}
+ local badge_width = text_width(self.badge, badge_opts)
+ local width, height = math.ceil(badge_width + (badge_font_size / 7) * 2), math.ceil(badge_font_size * 0.93)
+ local bx, by = self.bx - 1, self.by - 1
+ ass:rect(bx - width, by - height, bx, by, {
+ color = foreground,
+ radius = state.radius,
+ opacity = visibility,
+ border = self.active and 0 or 1,
+ border_color = background,
+ })
+ ass:txt(bx - width / 2, by - height / 2, 5, self.badge, badge_opts)
+
+ local clip_border = math.max(self.font_size / 20, 1)
+ local clip_path = assdraw.ass_new()
+ clip_path:round_rect_cw(
+ math.floor((bx - width) - clip_border), math.floor((by - height) - clip_border), bx, by, 3
+ )
+ icon_clip = '\\iclip(' .. clip_path.scale .. ', ' .. clip_path.text .. ')'
+ end
+
+ -- Icon
+ local x, y = round(self.ax + (self.bx - self.ax) / 2), round(self.ay + (self.by - self.ay) / 2)
+ ass:icon(x, y, self.font_size, self.icon, {
+ color = foreground,
+ border = self.active and 0 or options.text_border * state.scale,
+ border_color = background,
+ opacity = visibility,
+ clip = icon_clip,
+ })
+
+ return ass
+end
+
+return Button
diff --git a/dotfiles/.config/mpv/scripts/uosc/elements/Controls.lua b/dotfiles/.config/mpv/scripts/uosc/elements/Controls.lua
new file mode 100644
index 0000000..be418e8
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/elements/Controls.lua
@@ -0,0 +1,389 @@
+local Element = require('elements/Element')
+local Button = require('elements/Button')
+local CycleButton = require('elements/CycleButton')
+local ManagedButton = require('elements/ManagedButton')
+local Speed = require('elements/Speed')
+
+-- sizing:
+-- static - shrink, have highest claim on available space, disappear when there's not enough of it
+-- dynamic - shrink to make room for static elements until they reach their ratio_min, then disappear
+-- gap - shrink if there's no space left
+-- space - expands to fill available space, shrinks as needed
+-- scale - `options.controls_size` scale factor.
+-- ratio - Width/height ratio of a static or dynamic element.
+-- ratio_min Min ratio for 'dynamic' sized element.
+---@alias ControlItem {element?: Element; kind: string; sizing: 'space' | 'static' | 'dynamic' | 'gap'; scale: number; ratio?: number; ratio_min?: number; hide: boolean; dispositions?: table}
+
+---@class Controls : Element
+local Controls = class(Element)
+
+function Controls:new() return Class.new(self) --[[@as Controls]] end
+function Controls:init()
+ Element.init(self, 'controls', {render_order = 6})
+ ---@type ControlItem[] All control elements serialized from `options.controls`.
+ self.controls = {}
+ ---@type ControlItem[] Only controls that match current dispositions.
+ self.layout = {}
+
+ self:init_options()
+end
+
+function Controls:destroy()
+ self:destroy_elements()
+ Element.destroy(self)
+end
+
+function Controls:init_options()
+ -- Serialize control elements
+ local shorthands = {
+ ['play-pause'] = 'cycle:pause:pause:no/yes=play_arrow?' .. t('Play/Pause'),
+ menu = 'command:menu:script-binding uosc/menu-blurred?' .. t('Menu'),
+ subtitles = 'command:subtitles:script-binding uosc/subtitles#sub>0?' .. t('Subtitles'),
+ audio = 'command:graphic_eq:script-binding uosc/audio#audio>1?' .. t('Audio'),
+ ['audio-device'] = 'command:speaker:script-binding uosc/audio-device?' .. t('Audio device'),
+ video = 'command:theaters:script-binding uosc/video#video>1?' .. t('Video'),
+ playlist = 'command:list_alt:script-binding uosc/playlist?' .. t('Playlist'),
+ chapters = 'command:bookmark:script-binding uosc/chapters#chapters>0?' .. t('Chapters'),
+ ['editions'] = 'command:bookmarks:script-binding uosc/editions#editions>1?' .. t('Editions'),
+ ['stream-quality'] = 'command:high_quality:script-binding uosc/stream-quality?' .. t('Stream quality'),
+ ['open-file'] = 'command:file_open:script-binding uosc/open-file?' .. t('Open file'),
+ ['items'] = 'command:list_alt:script-binding uosc/items?' .. t('Playlist/Files'),
+ prev = 'command:arrow_back_ios:script-binding uosc/prev?' .. t('Previous'),
+ next = 'command:arrow_forward_ios:script-binding uosc/next?' .. t('Next'),
+ first = 'command:first_page:script-binding uosc/first?' .. t('First'),
+ last = 'command:last_page:script-binding uosc/last?' .. t('Last'),
+ ['loop-playlist'] = 'cycle:repeat:loop-playlist:no/inf!?' .. t('Loop playlist'),
+ ['loop-file'] = 'cycle:repeat_one:loop-file:no/inf!?' .. t('Loop file'),
+ shuffle = 'toggle:shuffle:shuffle?' .. t('Shuffle'),
+ autoload = 'toggle:hdr_auto:autoload@uosc?' .. t('Autoload'),
+ fullscreen = 'cycle:crop_free:fullscreen:no/yes=fullscreen_exit!?' .. t('Fullscreen'),
+ }
+
+ -- Parse out disposition/config pairs
+ local items = {}
+ local in_disposition = false
+ local current_item = nil
+ for c in options.controls:gmatch('.') do
+ if not current_item then current_item = {disposition = '', config = ''} end
+ if c == '<' and #current_item.config == 0 then
+ in_disposition = true
+ elseif c == '>' and #current_item.config == 0 then
+ in_disposition = false
+ elseif c == ',' and not in_disposition then
+ items[#items + 1] = current_item
+ current_item = nil
+ else
+ local prop = in_disposition and 'disposition' or 'config'
+ current_item[prop] = current_item[prop] .. c
+ end
+ end
+ items[#items + 1] = current_item
+
+ -- Create controls
+ self.controls = {}
+ for i, item in ipairs(items) do
+ local config = shorthands[item.config] and shorthands[item.config] or item.config
+ local config_tooltip = split(config, ' *%? *')
+ local tooltip = config_tooltip[2]
+ config = shorthands[config_tooltip[1]]
+ and split(shorthands[config_tooltip[1]], ' *%? *')[1] or config_tooltip[1]
+ local config_badge = split(config, ' *# *')
+ config = config_badge[1]
+ local badge = config_badge[2]
+ local parts = split(config, ' *: *')
+ local kind, params = parts[1], itable_slice(parts, 2)
+
+ -- Serialize dispositions
+ local dispositions = {}
+ for _, definition in ipairs(comma_split(item.disposition)) do
+ if #definition > 0 then
+ local value = definition:sub(1, 1) ~= '!'
+ local name = not value and definition:sub(2) or definition
+ local prop = name:sub(1, 4) == 'has_' and name or 'is_' .. name
+ dispositions[prop] = value
+ end
+ end
+
+ -- Convert toggles into cycles
+ if kind == 'toggle' then
+ kind = 'cycle'
+ params[#params + 1] = 'no/yes!'
+ end
+
+ -- Create a control element
+ local control = {dispositions = dispositions, kind = kind}
+
+ if kind == 'space' then
+ control.sizing = 'space'
+ elseif kind == 'gap' then
+ table_assign(control, {sizing = 'gap', scale = 1, ratio = params[1] or 0.3, ratio_min = 0})
+ elseif kind == 'command' then
+ if #params ~= 2 then
+ mp.error(string.format(
+ 'command button needs 2 parameters, %d received: %s', #params, table.concat(params, '/')
+ ))
+ else
+ local element = Button:new('control_' .. i, {
+ render_order = self.render_order,
+ icon = params[1],
+ anchor_id = 'controls',
+ on_click = function() mp.command(params[2]) end,
+ tooltip = tooltip,
+ count_prop = 'sub',
+ })
+ table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
+ if badge then self:register_badge_updater(badge, element) end
+ end
+ elseif kind == 'cycle' then
+ if #params ~= 3 then
+ mp.error(string.format(
+ 'cycle button needs 3 parameters, %d received: %s',
+ #params, table.concat(params, '/')
+ ))
+ else
+ local state_configs = split(params[3], ' */ *')
+ local states = {}
+
+ for _, state_config in ipairs(state_configs) do
+ local active = false
+ if state_config:sub(-1) == '!' then
+ active = true
+ state_config = state_config:sub(1, -2)
+ end
+ local state_params = split(state_config, ' *= *')
+ local value, icon = state_params[1], state_params[2] or params[1]
+ states[#states + 1] = {value = value, icon = icon, active = active}
+ end
+
+ local element = CycleButton:new('control_' .. i, {
+ render_order = self.render_order,
+ prop = params[2],
+ anchor_id = 'controls',
+ states = states,
+ tooltip = tooltip,
+ })
+ table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
+ if badge then self:register_badge_updater(badge, element) end
+ end
+ elseif kind == 'button' then
+ if #params ~= 1 then
+ mp.error(string.format(
+ 'managed button needs 1 parameter, %d received: %s', #params, table.concat(params, '/')
+ ))
+ else
+ local element = ManagedButton:new('control_' .. i, {
+ name = params[1],
+ render_order = self.render_order,
+ anchor_id = 'controls',
+ })
+ table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
+ end
+ elseif kind == 'speed' then
+ if not Elements.speed then
+ local element = Speed:new({anchor_id = 'controls', render_order = self.render_order})
+ local scale = tonumber(params[1]) or 1.3
+ table_assign(control, {
+ element = element, sizing = 'dynamic', scale = scale, ratio = 3.5, ratio_min = 2,
+ })
+ else
+ msg.error('there can only be 1 speed slider')
+ end
+ else
+ msg.error('unknown element kind "' .. kind .. '"')
+ break
+ end
+
+ self.controls[#self.controls + 1] = control
+ end
+
+ self:reflow()
+end
+
+function Controls:reflow()
+ -- Populate the layout only with items that match current disposition
+ self.layout = {}
+ for _, control in ipairs(self.controls) do
+ local matches = true
+ for prop, value in pairs(control.dispositions) do
+ if state[prop] ~= value then
+ matches = false
+ break
+ end
+ end
+ if control.element then control.element.enabled = matches end
+ if matches then self.layout[#self.layout + 1] = control end
+ end
+
+ self:update_dimensions()
+ Elements:trigger('controls_reflow')
+end
+
+---@param badge string
+---@param element Element An element that supports `badge` property.
+function Controls:register_badge_updater(badge, element)
+ local prop_and_limit = split(badge, ' *> *')
+ local prop, limit = prop_and_limit[1], tonumber(prop_and_limit[2] or -1)
+ local observable_name, serializer, is_external_prop = prop, nil, false
+
+ if itable_index_of({'sub', 'audio', 'video'}, prop) then
+ observable_name = 'track-list'
+ serializer = function(value)
+ local count = 0
+ for _, track in ipairs(value) do if track.type == prop then count = count + 1 end end
+ return count
+ end
+ else
+ local parts = split(prop, '@')
+ -- Support both new `prop@owner` and old `@prop` syntaxes
+ if #parts > 1 then prop, is_external_prop = parts[1] ~= '' and parts[1] or parts[2], true end
+ serializer = function(value) return value and (type(value) == 'table' and #value or tostring(value)) or nil end
+ end
+
+ local function handler(_, value)
+ local new_value = serializer(value) --[[@as nil|string|integer]]
+ local value_number = tonumber(new_value)
+ if value_number then new_value = value_number > limit and value_number or nil end
+ element.badge = new_value
+ request_render()
+ end
+
+ if is_external_prop then
+ element['on_external_prop_' .. prop] = function(_, value) handler(prop, value) end
+ else
+ self:observe_mp_property(observable_name, handler)
+ end
+end
+
+function Controls:get_visibility()
+ return Elements:v('speed', 'dragging') and 1 or Elements:maybe('timeline', 'get_is_hovered')
+ and -1 or Element.get_visibility(self)
+end
+
+function Controls:update_dimensions()
+ local window_border = Elements:v('window_border', 'size', 0)
+ local size = round(options.controls_size * state.scale)
+ local spacing = round(options.controls_spacing * state.scale)
+ local margin = round(options.controls_margin * state.scale)
+
+ -- Disable when not enough space
+ local available_space = display.height - window_border * 2 - Elements:v('top_bar', 'size', 0)
+ - Elements:v('timeline', 'size', 0)
+ self.enabled = available_space > size + 10
+
+ -- Reset hide/enabled flags
+ for c, control in ipairs(self.layout) do
+ control.hide = false
+ if control.element then control.element.enabled = self.enabled end
+ end
+
+ if not self.enabled then return end
+
+ -- Container
+ self.bx = display.width - window_border - margin
+ self.by = Elements:v('timeline', 'ay', display.height - window_border) - margin
+ self.ax, self.ay = window_border + margin, self.by - size
+
+ -- Controls
+ local available_width, statics_width = self.bx - self.ax, 0
+ local min_content_width = statics_width
+ local max_dynamics_width, dynamic_units, spaces, gaps = 0, 0, 0, 0
+
+ -- Calculate statics_width, min_content_width, and count spaces & gaps
+ for c, control in ipairs(self.layout) do
+ if control.sizing == 'space' then
+ spaces = spaces + 1
+ elseif control.sizing == 'gap' then
+ gaps = gaps + control.scale * control.ratio
+ elseif control.sizing == 'static' then
+ local width = size * control.scale * control.ratio + (c ~= #self.layout and spacing or 0)
+ statics_width = statics_width + width
+ min_content_width = min_content_width + width
+ elseif control.sizing == 'dynamic' then
+ local spacing = (c ~= #self.layout and spacing or 0)
+ statics_width = statics_width + spacing
+ min_content_width = min_content_width + size * control.scale * control.ratio_min + spacing
+ max_dynamics_width = max_dynamics_width + size * control.scale * control.ratio
+ dynamic_units = dynamic_units + control.scale * control.ratio
+ end
+ end
+
+ -- Hide & disable elements in the middle until we fit into available width
+ if min_content_width > available_width then
+ local i = math.ceil(#self.layout / 2 + 0.1)
+ for a = 0, #self.layout - 1, 1 do
+ i = i + (a * (a % 2 == 0 and 1 or -1))
+ local control = self.layout[i]
+
+ if control.sizing ~= 'gap' and control.sizing ~= 'space' then
+ control.hide = true
+ if control.element then control.element.enabled = false end
+ if control.sizing == 'static' then
+ local width = size * control.scale * control.ratio
+ min_content_width = min_content_width - width - spacing
+ statics_width = statics_width - width - spacing
+ elseif control.sizing == 'dynamic' then
+ statics_width = statics_width - spacing
+ min_content_width = min_content_width - size * control.scale * control.ratio_min - spacing
+ max_dynamics_width = max_dynamics_width - size * control.scale * control.ratio
+ dynamic_units = dynamic_units - control.scale * control.ratio
+ end
+
+ if min_content_width < available_width then break end
+ end
+ end
+ end
+
+ -- Lay out the elements
+ local current_x = self.ax
+ local width_for_dynamics = available_width - statics_width
+ local empty_space_width = width_for_dynamics - max_dynamics_width
+ local width_for_gaps = math.min(empty_space_width, size * gaps)
+ local individual_space_width = spaces > 0 and ((empty_space_width - width_for_gaps) / spaces) or 0
+
+ for c, control in ipairs(self.layout) do
+ if not control.hide then
+ local sizing, element, scale, ratio = control.sizing, control.element, control.scale, control.ratio
+ local width, height = 0, 0
+
+ if sizing == 'space' then
+ if individual_space_width > 0 then width = individual_space_width end
+ elseif sizing == 'gap' then
+ if width_for_gaps > 0 then width = width_for_gaps * (ratio / gaps) end
+ elseif sizing == 'static' then
+ height = size * scale
+ width = height * ratio
+ elseif sizing == 'dynamic' then
+ height = size * scale
+ width = max_dynamics_width < width_for_dynamics
+ and height * ratio or width_for_dynamics * ((scale * ratio) / dynamic_units)
+ end
+
+ local bx = current_x + width
+ if element then element:set_coordinates(round(current_x), round(self.by - height), bx, self.by) end
+ current_x = element and bx + spacing or bx
+ end
+ end
+
+ Elements:update_proximities()
+ request_render()
+end
+
+function Controls:on_dispositions() self:reflow() end
+function Controls:on_display() self:update_dimensions() end
+function Controls:on_prop_border() self:update_dimensions() end
+function Controls:on_prop_title_bar() self:update_dimensions() end
+function Controls:on_prop_fullormaxed() self:update_dimensions() end
+function Controls:on_timeline_enabled() self:update_dimensions() end
+
+function Controls:destroy_elements()
+ for _, control in ipairs(self.controls) do
+ if control.element then control.element:destroy() end
+ end
+end
+
+function Controls:on_options()
+ self:destroy_elements()
+ self:init_options()
+end
+
+return Controls
diff --git a/dotfiles/.config/mpv/scripts/uosc/elements/Curtain.lua b/dotfiles/.config/mpv/scripts/uosc/elements/Curtain.lua
new file mode 100644
index 0000000..ccebfb0
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/elements/Curtain.lua
@@ -0,0 +1,35 @@
+local Element = require('elements/Element')
+
+---@class Curtain : Element
+local Curtain = class(Element)
+
+function Curtain:new() return Class.new(self) --[[@as Curtain]] end
+function Curtain:init()
+ Element.init(self, 'curtain', {render_order = 999})
+ self.opacity = 0
+ ---@type string[]
+ self.dependents = {}
+end
+
+---@param id string
+function Curtain:register(id)
+ self.dependents[#self.dependents + 1] = id
+ if #self.dependents == 1 then self:tween_property('opacity', self.opacity, 1) end
+end
+
+---@param id string
+function Curtain:unregister(id)
+ self.dependents = itable_filter(self.dependents, function(item) return item ~= id end)
+ if #self.dependents == 0 then self:tween_property('opacity', self.opacity, 0) end
+end
+
+function Curtain:render()
+ if self.opacity == 0 or config.opacity.curtain == 0 then return end
+ local ass = assdraw.ass_new()
+ ass:rect(0, 0, display.width, display.height, {
+ color = config.color.curtain, opacity = config.opacity.curtain * self.opacity,
+ })
+ return ass
+end
+
+return Curtain
diff --git a/dotfiles/.config/mpv/scripts/uosc/elements/CycleButton.lua b/dotfiles/.config/mpv/scripts/uosc/elements/CycleButton.lua
new file mode 100644
index 0000000..8aa8175
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/elements/CycleButton.lua
@@ -0,0 +1,86 @@
+local Button = require('elements/Button')
+
+---@alias CycleState {value: any; icon: string; active?: boolean}
+---@alias CycleButtonProps {prop: string; states: CycleState[]; anchor_id?: string; tooltip?: string}
+
+local function yes_no_to_boolean(value)
+ if type(value) ~= 'string' then return value end
+ local lowercase = trim(value):lower()
+ if lowercase == 'yes' or lowercase == 'no' then
+ return lowercase == 'yes'
+ else
+ return value
+ end
+end
+
+---@class CycleButton : Button
+local CycleButton = class(Button)
+
+---@param id string
+---@param props CycleButtonProps
+function CycleButton:new(id, props) return Class.new(self, id, props) --[[@as CycleButton]] end
+---@param id string
+---@param props CycleButtonProps
+function CycleButton:init(id, props)
+ local is_state_prop = itable_index_of({'shuffle'}, props.prop)
+ self.prop = props.prop
+ self.states = props.states
+
+ Button.init(self, id, props)
+
+ self.icon = self.states[1].icon
+ self.active = self.states[1].active
+ self.current_state_index = 1
+ self.on_click = function()
+ local new_state = self.states[self.current_state_index + 1] or self.states[1]
+ local new_value = new_state.value
+ if self.owner == 'uosc' then
+ if type(options[self.prop]) == 'number' then
+ options[self.prop] = tonumber(new_value) or 0
+ else
+ options[self.prop] = yes_no_to_boolean(new_value)
+ end
+ handle_options({[self.prop] = options[self.prop]})
+ elseif self.owner then
+ mp.commandv('script-message-to', self.owner, 'set', self.prop, new_value)
+ elseif is_state_prop then
+ set_state(self.prop, yes_no_to_boolean(new_value))
+ else
+ mp.set_property(self.prop, new_value)
+ end
+ end
+
+ local function handle_change(name, value)
+ -- Removes unnecessary floating point digits from values like `2.00000`.
+ -- This happens when observing properties like `speed`.
+ if type(value) == 'string' and string.match(value, '^[%+%-]?%d+%.%d+$') then
+ value = tonumber(value)
+ end
+
+ value = type(value) == 'boolean' and (value and 'yes' or 'no') or tostring(value or '')
+ local index = itable_find(self.states, function(state) return state.value == value end)
+ self.current_state_index = index or 1
+ self.icon = self.states[self.current_state_index].icon
+ self.active = self.states[self.current_state_index].active
+ request_render()
+ end
+
+ local prop_parts = split(self.prop, '@')
+ if #prop_parts == 2 then -- External prop with a script owner
+ self.prop, self.owner = prop_parts[1], prop_parts[2]
+ if self.owner == 'uosc' then
+ self['on_options'] = function() handle_change(self.prop, options[self.prop]) end
+ handle_change(self.prop, options[self.prop])
+ else
+ self['on_external_prop_' .. self.prop] = function(_, value) handle_change(self.prop, value) end
+ handle_change(self.prop, external[self.prop])
+ end
+ elseif is_state_prop then -- uosc's state props
+ self['on_prop_' .. self.prop] = function(self, value) handle_change(self.prop, value) end
+ handle_change(self.prop, state[self.prop])
+ else
+ self:observe_mp_property(self.prop, 'string', handle_change)
+ end
+end
+
+return CycleButton
diff --git a/dotfiles/.config/mpv/scripts/uosc/elements/Element.lua b/dotfiles/.config/mpv/scripts/uosc/elements/Element.lua
new file mode 100644
index 0000000..3877b90
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/elements/Element.lua
@@ -0,0 +1,260 @@
+---@alias ElementProps {enabled?: boolean; render_order?: number; ax?: number; ay?: number; bx?: number; by?: number; ignores_curtain?: boolean; anchor_id?: string;}
+
+-- Base class all elements inherit from.
+---@class Element : Class
+local Element = class()
+
+---@param id string
+---@param props? ElementProps
+function Element:init(id, props)
+ self.id = id
+ self.render_order = 1
+ -- `false` means element won't be rendered, or receive events
+ self.enabled = true
+ -- Element coordinates
+ self.ax, self.ay, self.bx, self.by = 0, 0, 0, 0
+ -- Relative proximity from `0` - mouse outside `proximity_max` range, to `1` - mouse within `proximity_min` range.
+ self.proximity = 0
+ -- Raw proximity in pixels.
+ self.proximity_raw = math.huge
+ ---@type number `0-1` factor to force min visibility. Used for toggling element's permanent visibility.
+ self.min_visibility = 0
+ ---@type number `0-1` factor to force a visibility value. Used for flashing, fading out, and other animations
+ self.forced_visibility = nil
+ ---@type boolean Show this element even when curtain is visible.
+ self.ignores_curtain = false
+ ---@type nil|string ID of an element from which this one should inherit visibility.
+ self.anchor_id = nil
+ ---@type fun()[] Disposer functions called when element is destroyed.
+ self._disposers = {}
+ ---@type table> Namespaced active key bindings. Default namespace is `_`.
+ self._key_bindings = {}
+
+ if props then table_assign(self, props) end
+
+ -- Flash timer
+ self._flash_out_timer = mp.add_timeout(options.flash_duration / 1000, function()
+ local function getTo() return self.proximity end
+ local function onTweenEnd() self.forced_visibility = nil end
+ if self.enabled then
+ self:tween_property('forced_visibility', self:get_visibility(), getTo, onTweenEnd)
+ else
+ onTweenEnd()
+ end
+ end)
+ self._flash_out_timer:kill()
+
+ Elements:add(self)
+end
+
+function Element:destroy()
+ for _, disposer in ipairs(self._disposers) do disposer() end
+ self.destroyed = true
+ self:remove_key_bindings()
+ Elements:remove(self)
+end
+
+function Element:reset_proximity() self.proximity, self.proximity_raw = 0, math.huge end
+
+---@param ax number
+---@param ay number
+---@param bx number
+---@param by number
+function Element:set_coordinates(ax, ay, bx, by)
+ self.ax, self.ay, self.bx, self.by = ax, ay, bx, by
+ Elements:update_proximities()
+ self:maybe('on_coordinates')
+end
+
+function Element:update_proximity()
+ if cursor.hidden then
+ self:reset_proximity()
+ else
+ local range = options.proximity_out - options.proximity_in
+ self.proximity_raw = get_point_to_rectangle_proximity(cursor, self)
+ self.proximity = 1 - (clamp(0, self.proximity_raw - options.proximity_in, range) / range)
+ end
+end
+
+function Element:is_persistent()
+ local persist = config[self.id .. '_persistency']
+ return persist and (
+ (persist.audio and state.is_audio)
+ or (
+ persist.paused and state.pause
+ and (not Elements.timeline or not Elements.timeline.pressed or Elements.timeline.pressed.pause)
+ )
+ or (persist.video and state.is_video)
+ or (persist.image and state.is_image)
+ or (persist.idle and state.is_idle)
+ or (persist.windowed and not state.fullormaxed)
+ or (persist.fullscreen and state.fullormaxed)
+ )
+end
+
+-- Decide elements visibility based on proximity and various other factors
+function Element:get_visibility()
+ -- Hide when curtain is visible, unless this elements ignores it
+ local min_order = (Elements.curtain.opacity > 0 and not self.ignores_curtain) and Elements.curtain.render_order or 0
+ if self.render_order < min_order then return 0 end
+
+ -- Persistency
+ if self:is_persistent() then return 1 end
+
+ -- Forced visibility
+ if self.forced_visibility then return math.max(self.forced_visibility, self.min_visibility) end
+
+ -- Anchor inheritance
+ -- If anchor returns -1, it means all attached elements should force hide.
+ local anchor = self.anchor_id and Elements[self.anchor_id]
+ local anchor_visibility = anchor and anchor:get_visibility() or 0
+
+ return anchor_visibility == -1 and 0 or math.max(self.proximity, anchor_visibility, self.min_visibility)
+end
+
+-- Call method if it exists
+function Element:maybe(name, ...)
+ if self[name] then return self[name](self, ...) end
+end
+
+-- Attach a tweening animation to this element
+---@param from number
+---@param to number|fun():number
+---@param setter fun(value: number)
+---@param duration_or_callback? number|fun() Duration in milliseconds or a callback function.
+---@param callback? fun() Called either on animation end, or when animation is killed.
+function Element:tween(from, to, setter, duration_or_callback, callback)
+ self:tween_stop()
+ self._kill_tween = self.enabled and tween(
+ from, to, setter, duration_or_callback,
+ function()
+ self._kill_tween = nil
+ if callback then callback() end
+ end
+ )
+end
+
+function Element:is_tweening() return self and self._kill_tween end
+function Element:tween_stop() self:maybe('_kill_tween') end
+
+-- Animate an element property between 2 values.
+---@param prop string
+---@param from number
+---@param to number|fun():number
+---@param duration_or_callback? number|fun() Duration in milliseconds or a callback function.
+---@param callback? fun() Called either on animation end, or when animation is killed.
+function Element:tween_property(prop, from, to, duration_or_callback, callback)
+ self:tween(from, to, function(value) self[prop] = value end, duration_or_callback, callback)
+end
+
+---@param name string
+function Element:trigger(name, ...)
+ local result = self:maybe('on_' .. name, ...)
+ request_render()
+ return result
+end
+
+-- Briefly flashes the element for `options.flash_duration` milliseconds.
+-- Useful to visualize changes of volume and timeline when changed via hotkeys.
+function Element:flash()
+ if self.enabled and options.flash_duration > 0 and (self.proximity < 1 or self._flash_out_timer:is_enabled()) then
+ self:tween_stop()
+ self.forced_visibility = 1
+ request_render()
+ self._flash_out_timer.timeout = options.flash_duration / 1000
+ self._flash_out_timer:kill()
+ self._flash_out_timer:resume()
+ end
+end
+
+-- Register disposer to be called when element is destroyed.
+---@param disposer fun()
+function Element:register_disposer(disposer)
+ if not itable_index_of(self._disposers, disposer) then
+ self._disposers[#self._disposers + 1] = disposer
+ end
+end
+
+-- Automatically registers disposer for the passed callback.
+---@param event string
+---@param callback fun()
+function Element:register_mp_event(event, callback)
+ mp.register_event(event, callback)
+ self:register_disposer(function() mp.unregister_event(callback) end)
+end
+
+-- Automatically registers disposer for the observer.
+---@param name string
+---@param type_or_callback string|fun(name: string, value: any)
+---@param callback_maybe nil|fun(name: string, value: any)
+function Element:observe_mp_property(name, type_or_callback, callback_maybe)
+ local callback = type(type_or_callback) == 'function' and type_or_callback or callback_maybe
+ local prop_type = type(type_or_callback) == 'string' and type_or_callback or 'native'
+ mp.observe_property(name, prop_type, callback)
+ self:register_disposer(function() mp.unobserve_property(callback) end)
+end
+
+-- Adds a keybinding for the lifetime of the element, or until removed manually.
+---@param key string mpv key identifier.
+---@param fnFlags fun()|string|table Callback, or `{callback, flags}` tuple. Callback can be just a method name, in which case it'll be wrapped in `create_action(callback)`.
+---@param namespace? string Keybinding namespace. Default is `_`.
+function Element:add_key_binding(key, fnFlags, namespace)
+ local name = self.id .. '-' .. key
+ local isTuple = type(fnFlags) == 'table'
+ local fn = (isTuple and fnFlags[1] or fnFlags)
+ local flags = isTuple and fnFlags[2] or nil
+ namespace = namespace or '_'
+ local names = self._key_bindings[namespace]
+ if not names then
+ names = {}
+ self._key_bindings[namespace] = names
+ end
+ names[name] = true
+ if type(fn) == 'string' then
+ fn = self:create_action(fn)
+ end
+ mp.add_forced_key_binding(key, name, fn, flags)
+end
+
+-- Remove all or only keybindings belonging to a specific namespace.
+---@param namespace? string Optional keybinding namespace to remove.
+function Element:remove_key_bindings(namespace)
+ local namespaces = namespace and {namespace} or table_keys(self._key_bindings)
+ for _, namespace in ipairs(namespaces) do
+ local names = self._key_bindings[namespace]
+ if names then
+ for name, _ in pairs(names) do
+ mp.remove_key_binding(name)
+ end
+ self._key_bindings[namespace] = nil
+ end
+ end
+end
+
+-- Checks if there are any (at all or namespaced) keybindings for this element.
+---@param namespace? string Only check this namespace.
+function Element:has_keybindings(namespace)
+ if namespace then
+ return self._key_bindings[namespace] ~= nil
+ else
+ return #table_keys(self._key_bindings) > 0
+ end
+end
+
+-- Check if element is not destroyed or otherwise disabled.
+-- Intended to be overridden by inheriting elements to add more checks.
+function Element:is_alive() return not self.destroyed end
+
+-- Wraps a function into a callback that won't run if element is destroyed or otherwise disabled.
+---@param fn fun(...)|string Function or a name of a method on this class to call.
+function Element:create_action(fn)
+ if type(fn) == 'string' then
+ local method = fn
+ fn = function(...) self[method](self, ...) end
+ end
+ return function(...)
+ if self:is_alive() then fn(...) end
+ end
+end
+
+return Element
diff --git a/dotfiles/.config/mpv/scripts/uosc/elements/Elements.lua b/dotfiles/.config/mpv/scripts/uosc/elements/Elements.lua
new file mode 100644
index 0000000..4e913f2
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/elements/Elements.lua
@@ -0,0 +1,152 @@
+local Elements = {_all = {}}
+
+---@param element Element
+function Elements:add(element)
+ if not element.id then
+ msg.error('attempt to add element without "id" property')
+ return
+ end
+
+ if self:has(element.id) then Elements:remove(element.id) end
+
+ self._all[#self._all + 1] = element
+ self[element.id] = element
+
+ -- Sort by render order
+ table.sort(self._all, function(a, b) return a.render_order < b.render_order end)
+
+ request_render()
+end
+
+function Elements:remove(idOrElement)
+ if not idOrElement then return end
+ local id = type(idOrElement) == 'table' and idOrElement.id or idOrElement
+ local element = Elements[id]
+ if element then
+ if not element.destroyed then element:destroy() end
+ element.enabled = false
+ self._all = itable_delete_value(self._all, self[id])
+ self[id] = nil
+ request_render()
+ end
+end
+
+function Elements:update_proximities()
+ local curtain_render_order = Elements.curtain.opacity > 0 and Elements.curtain.render_order or 0
+ local mouse_leave_elements = {}
+ local mouse_enter_elements = {}
+
+ -- Calculates proximities for all elements
+ for _, element in self:ipairs() do
+ if element.enabled then
+ local previous_proximity_raw = element.proximity_raw
+
+ -- If curtain is open, we disable all elements set to rendered below it
+ if not element.ignores_curtain and element.render_order < curtain_render_order then
+ element:reset_proximity()
+ else
+ element:update_proximity()
+ end
+
+ if element.proximity_raw == 0 then
+ -- Mouse entered element area
+ if previous_proximity_raw ~= 0 then
+ mouse_enter_elements[#mouse_enter_elements + 1] = element
+ end
+ else
+ -- Mouse left element area
+ if previous_proximity_raw == 0 then
+ mouse_leave_elements[#mouse_leave_elements + 1] = element
+ end
+ end
+ end
+ end
+
+ -- Trigger `mouse_leave` and `mouse_enter` events
+ for _, element in ipairs(mouse_leave_elements) do element:trigger('mouse_leave') end
+ for _, element in ipairs(mouse_enter_elements) do element:trigger('mouse_enter') end
+end
+
+-- Toggles passed elements' min visibilities between 0 and 1.
+---@param ids string[] IDs of elements to peek.
+function Elements:toggle(ids)
+ local has_invisible = itable_find(ids, function(id)
+ return Elements[id] and Elements[id].enabled and Elements[id]:get_visibility() ~= 1
+ end)
+
+ self:set_min_visibility(has_invisible and 1 or 0, ids)
+
+ -- Reset proximities when toggling off. Has to happen after `set_min_visibility`,
+ -- as that is using proximity as a tween starting point.
+ if not has_invisible then
+ for _, id in ipairs(ids) do
+ if Elements[id] then Elements[id]:reset_proximity() end
+ end
+ end
+end
+
+-- Set (animate) elements' min visibilities to passed value.
+---@param visibility number 0-1 floating point.
+---@param ids string[] IDs of elements to peek.
+function Elements:set_min_visibility(visibility, ids)
+ for _, id in ipairs(ids) do
+ local element = Elements[id]
+ if element then
+ local from = math.max(0, element:get_visibility())
+ element:tween_property('min_visibility', from, visibility)
+ end
+ end
+end
+
+-- Flash passed elements.
+---@param ids string[] IDs of elements to peek.
+function Elements:flash(ids)
+ local elements = itable_filter(self._all, function(element) return itable_has(ids, element.id) end)
+ for _, element in ipairs(elements) do element:flash() end
+
+ -- Special case for 'progress' since it's a state of timeline, not an element
+ if itable_has(ids, 'progress') and not itable_has(ids, 'timeline') then
+ Elements:maybe('timeline', 'flash_progress')
+ end
+end
+
+---@param name string Event name.
+function Elements:trigger(name, ...)
+ for _, element in self:ipairs() do element:trigger(name, ...) end
+end
+
+-- Trigger two events, `name` and `global_name`, depending on element-cursor proximity.
+-- Disabled elements don't receive these events.
+---@param name string Event name.
+function Elements:proximity_trigger(name, ...)
+ for i = #self._all, 1, -1 do
+ local element = self._all[i]
+ if element.enabled then
+ if element.proximity_raw == 0 then
+ if element:trigger(name, ...) == 'stop_propagation' then break end
+ end
+ if element:trigger('global_' .. name, ...) == 'stop_propagation' then break end
+ end
+ end
+end
+
+-- Returns a property of an element with a passed `id` if it exists, with an optional fallback.
+---@param id string
+---@param prop string
+---@param fallback any
+function Elements:v(id, prop, fallback)
+ if self[id] and self[id].enabled and self[id][prop] ~= nil then return self[id][prop] end
+ return fallback
+end
+
+-- Calls a method on an element with passed `id` if it exists.
+---@param id string
+---@param method string
+function Elements:maybe(id, method, ...)
+ if self[id] then return self[id]:maybe(method, ...) end
+end
+
+function Elements:has(id) return self[id] ~= nil end
+function Elements:ipairs() return ipairs(self._all) end
+
+return Elements
diff --git a/dotfiles/.config/mpv/scripts/uosc/elements/ManagedButton.lua b/dotfiles/.config/mpv/scripts/uosc/elements/ManagedButton.lua
new file mode 100644
index 0000000..615cde1
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/elements/ManagedButton.lua
@@ -0,0 +1,29 @@
+local Button = require('elements/Button')
+
+---@alias ManagedButtonProps {name: string; anchor_id?: string; render_order?: number}
+
+---@class ManagedButton : Button
+local ManagedButton = class(Button)
+
+---@param id string
+---@param props ManagedButtonProps
+function ManagedButton:new(id, props) return Class.new(self, id, props) --[[@as ManagedButton]] end
+---@param id string
+---@param props ManagedButtonProps
+function ManagedButton:init(id, props)
+ ---@type string | table | nil
+ self.command = nil
+
+ Button.init(self, id, table_assign({}, props, {on_click = function() execute_command(self.command) end}))
+
+ self:register_disposer(buttons:subscribe(props.name, function(data) self:update(data) end))
+end
+
+function ManagedButton:update(data)
+ for _, prop in ipairs({'icon', 'active', 'badge', 'command', 'tooltip'}) do
+ self[prop] = data[prop]
+ end
+ self.is_clickable = self.command ~= nil
+end
+
+return ManagedButton
diff --git a/dotfiles/.config/mpv/scripts/uosc/elements/Menu.lua b/dotfiles/.config/mpv/scripts/uosc/elements/Menu.lua
new file mode 100644
index 0000000..b8e9949
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/elements/Menu.lua
@@ -0,0 +1,1602 @@
+local Element = require('elements/Element')
+
+---@alias MenuAction {name: string; icon: string; label?: string; filter_hidden?: boolean;}
+
+-- Menu data structure accepted by `Menu:open(menu)`.
+---@alias MenuData {id?: string; type?: string; title?: string; hint?: string; footnote: string; search_style?: 'on_demand' | 'palette' | 'disabled'; item_actions?: MenuAction[]; item_actions_place?: 'inside' | 'outside'; callback?: string[]; keep_open?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; items?: MenuDataChild[]; selected_index?: integer; on_search?: string|string[]; on_paste?: string|string[]; on_move?: string|string[]; on_close?: string|string[]; search_debounce?: number|string; search_submenus?: boolean; search_suggestion?: string}
+---@alias MenuDataChild MenuDataItem|MenuData
+---@alias MenuDataItem {title?: string; hint?: string; icon?: string; value: any; actions?: MenuAction[]; actions_place?: 'inside' | 'outside'; active?: boolean; keep_open?: boolean; selectable?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'}
+---@alias MenuOptions {mouse_nav?: boolean;}
+
+-- Internal data structure created from `MenuData`.
+---@alias MenuStack {id?: string; type?: string; title?: string; hint?: string; footnote: string; search_style?: 'on_demand' | 'palette' | 'disabled'; item_actions?: MenuAction[]; item_actions_place?: 'inside' | 'outside'; callback?: string[]; selected_index?: number; action_index?: number; keep_open?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; items: MenuStackChild[]; on_search?: string|string[]; on_paste?: string|string[]; on_move?: string|string[]; on_close?: string|string[]; search_debounce?: number|string; search_submenus?: boolean; search_suggestion?: string; parent_menu?: MenuStack; submenu_path: integer[]; active?: boolean; width: number; height: number; top: number; scroll_y: number; scroll_height: number; title_width: number; hint_width: number; max_width: number; is_root?: boolean; fling?: Fling, search?: Search, ass_safe_title?: string}
+---@alias MenuStackChild MenuStackItem|MenuStack
+---@alias MenuStackItem {title?: string; hint?: string; icon?: string; value: any; actions?: MenuAction[]; actions_place?: 'inside' | 'outside'; active?: boolean; keep_open?: boolean; selectable?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; title_width: number; hint_width: number; ass_safe_hint?: string}
+---@alias Fling {y: number, distance: number, time: number, easing: fun(x: number), duration: number, update_cursor?: boolean}
+---@alias Search {query: string; timeout: unknown; min_top: number; max_width: number; source: {width: number; top: number; scroll_y: number; selected_index?: integer; items?: MenuStackChild[]}}
+
+---@alias MenuEventActivate {type: 'activate'; index: number; value: any; action?: string; modifiers?: string; alt: boolean; ctrl: boolean; shift: boolean; is_pointer: boolean; keep_open?: boolean; menu_id: string;}
+---@alias MenuEventMove {type: 'move'; from_index: number; to_index: number; menu_id: string;}
+---@alias MenuEventSearch {type: 'search'; query: string; menu_id: string;}
+---@alias MenuEventKey {type: 'key'; id: string; key: string; modifiers?: string; alt: boolean; ctrl: boolean; shift: boolean; menu_id: string; selected_item?: {index: number; value: any; action?: string;}}
+---@alias MenuEventPaste {type: 'paste'; value: string; menu_id: string; selected_item?: {index: number; value: any; action?: string;}}
+---@alias MenuEventBack {type: 'back';}
+---@alias MenuEventClose {type: 'close';}
+---@alias MenuEvent MenuEventActivate | MenuEventMove | MenuEventSearch | MenuEventKey | MenuEventPaste | MenuEventBack | MenuEventClose
+---@alias MenuCallback fun(data: MenuEvent)
+
+---@class Menu : Element
+local Menu = class(Element)
+
+---@param data MenuData
+---@param callback MenuCallback
+---@param opts? MenuOptions
+function Menu:open(data, callback, opts)
+ local open_menu = Menu:is_open()
+ if open_menu then
+ open_menu.is_being_replaced = true
+ open_menu:close(true)
+ end
+ return Menu:new(data, callback, opts)
+end
+
+---@param menu_type? string
+---@return Menu|nil
+function Menu:is_open(menu_type)
+ return Elements.menu and (not menu_type or Elements.menu.type == menu_type) and Elements.menu or nil
+end
+
+---@param immediate? boolean Close immediately without fadeout animation.
+---@param callback? fun() Called after the animation (if any) ends and element is removed and destroyed.
+---@overload fun(callback: fun())
+function Menu:close(immediate, callback)
+ if type(immediate) ~= 'boolean' then callback = immediate end
+
+ local menu = self == Menu and Elements.menu or self
+
+ if menu and not menu.destroyed then
+ if menu.is_closing then
+ menu:tween_stop()
+ return
+ end
+
+ local function close()
+ local on_close = menu.root.on_close -- removed in menu:destroy()
+ Elements:remove('menu') -- calls menu:destroy() under the hood
+ Elements:update_proximities()
+ cursor:queue_autohide()
+
+ -- Call :close() callback
+ if callback then callback() end
+
+ -- Call callbacks/events defined on menu config
+ local close_event = {type = 'close'}
+ if not on_close or menu:command_or_event(on_close, {}, close_event) ~= 'event' then
+ menu.callback(close_event)
+ end
+
+ request_render()
+ end
+
+ menu.is_closing = true
+
+ if immediate then
+ close()
+ else
+ menu:fadeout(close)
+ end
+ end
+end
+
+---@param data MenuData
+---@param callback MenuCallback
+---@param opts? MenuOptions
+---@return Menu
+function Menu:new(data, callback, opts) return Class.new(self, data, callback, opts) --[[@as Menu]] end
+---@param data MenuData
+---@param callback MenuCallback
+---@param opts? MenuOptions
+function Menu:init(data, callback, opts)
+ Element.init(self, 'menu', {render_order = 1001})
+
+ -----@type fun()
+ self.callback = callback
+ self.opts = opts or {}
+ self.offset_x = 0 -- Used for submenu transition animation.
+ self.mouse_nav = self.opts.mouse_nav -- Stops pre-selecting items
+ self.item_height = nil
+ self.min_width = nil
+ self.item_spacing = 1
+ self.item_padding = nil
+ self.separator_size = nil
+ self.padding = nil
+ self.gap = nil
+ self.font_size = nil
+ self.font_size_hint = nil
+ self.scroll_step = nil -- Item height + item spacing.
+ self.scroll_height = nil -- Items + spacings - container height.
+ self.opacity = 0 -- Used to fade in/out.
+ self.type = data.type
+ ---@type MenuStack Root MenuStack.
+ self.root = nil
+ ---@type MenuStack Current MenuStack.
+ self.current = nil
+ ---@type MenuStack[] All menus in a flat array.
+ self.all = nil
+ ---@type table Map of submenus by their ids, such as `'Tools > Aspect ratio'`.
+ self.by_id = {}
+ self.type_to_search = options.menu_type_to_search
+ self.is_being_replaced = false
+ self.is_closing = false
+ self.drag_last_y = nil
+ self.is_dragging = false
+
+ if utils.shared_script_property_set then
+ utils.shared_script_property_set('uosc-menu-type', self.type or 'undefined')
+ end
+ mp.set_property_native('user-data/uosc/menu/type', self.type or 'undefined')
+ self:update(data)
+
+ for _, menu in ipairs(self.all) do self:scroll_to_index(menu.selected_index, menu.id) end
+ if self.mouse_nav then self.current.selected_index = nil end
+
+ self:tween_property('opacity', 0, 1)
+ self:enable_key_bindings()
+ Elements:maybe('curtain', 'register', self.id)
+end
+
+function Menu:destroy()
+ Element.destroy(self)
+ self.is_closing = false
+ if not self.is_being_replaced then Elements:maybe('curtain', 'unregister', self.id) end
+ if utils.shared_script_property_set then
+ utils.shared_script_property_set('uosc-menu-type', nil)
+ end
+ mp.set_property_native('user-data/uosc/menu/type', nil)
+end
+
+---@param data MenuData
+function Menu:update(data)
+ local new_root = {is_root = true, submenu_path = {}}
+ local new_all = {}
+ local new_menus = {} -- menus that didn't exist before this `update()`
+ local new_by_id = {}
+ local menus_to_serialize = {{new_root, data}}
+ local old_current_id = self.current and self.current.id
+ local menu_state_props = {'selected_index', 'action_index', 'scroll_y', 'fling', 'search'}
+ local internal_props_set = create_set(itable_append({'is_root', 'submenu_path', 'id', 'items'}, menu_state_props))
+
+ table_assign_exclude(new_root, data, internal_props_set)
+
+ local i = 0
+ while i < #menus_to_serialize do
+ i = i + 1
+ local menu, menu_data = menus_to_serialize[i][1], menus_to_serialize[i][2]
+ local parent_id = menu.parent_menu and not menu.parent_menu.is_root and menu.parent_menu.id
+ if menu_data.id then
+ menu.id = menu_data.id
+ elseif not menu.is_root then
+ menu.id = (parent_id and parent_id .. ' > ' or '') .. (menu_data.title or i)
+ else
+ menu.id = '{root}'
+ end
+ menu.icon = 'chevron_right'
+
+ -- Normalize `search_debounce`
+ if type(menu_data.search_debounce) == 'number' then
+ menu.search_debounce = math.max(0, menu_data.search_debounce --[[@as number]])
+ elseif menu_data.search_debounce == 'submit' then
+ menu.search_debounce = 'submit'
+ else
+ menu.search_debounce = menu.on_search and 300 or 0
+ end
+
+ -- Update items
+ local first_active_index = nil
+ menu.items = {
+ {title = t('Empty'), value = 'ignore', italic = 'true', muted = 'true', selectable = false, align = 'center'},
+ }
+
+ for i, item_data in ipairs(menu_data.items or {}) do
+ if item_data.active and not first_active_index then first_active_index = i end
+
+ local item = {}
+ table_assign_exclude(item, item_data, internal_props_set)
+ if item.keep_open == nil then item.keep_open = menu.keep_open end
+
+ -- Submenu
+ if item_data.items then
+ item.parent_menu = menu
+ item.submenu_path = itable_join(menu.submenu_path, {i})
+ menus_to_serialize[#menus_to_serialize + 1] = {item, item_data}
+ end
+
+ menu.items[i] = item
+ end
+
+ if menu.is_root then menu.selected_index = menu_data.selected_index or first_active_index end
+
+ -- Retain old state
+ local old_menu = self.by_id[menu.id]
+ if old_menu then
+ table_assign_props(menu, old_menu, menu_state_props)
+ else
+ new_menus[#new_menus + 1] = menu
+ end
+
+ new_all[#new_all + 1] = menu
+ new_by_id[menu.id] = menu
+ end
+
+ self.root, self.all, self.by_id = new_root, new_all, new_by_id
+ self.current = self.by_id[old_current_id] or self.root
+
+ self:update_content_dimensions()
+ self:reset_navigation()
+
+ -- Ensure palette menus have active searches, and clean empty searches from menus that lost the `palette` flag
+ local update_dimensions_again = false
+ for _, menu in ipairs(self.all) do
+ local is_palette = menu.search_style == 'palette'
+ if not menu.search and (is_palette or (menu.search_suggestion and itable_index_of(new_menus, menu))) then
+ update_dimensions_again = true
+ self:search_init(menu.id)
+ elseif not is_palette and menu.search and menu.search.query == '' then
+ update_dimensions_again = true
+ menu.search = nil
+ end
+ end
+ -- We update before _and_ after because search_inits need the initial un-searched
+ -- menu's position and scroll state to save on the `search.source` table.
+ if update_dimensions_again then
+ self:update_content_dimensions()
+ self:reset_navigation()
+ end
+ -- Apply search suggestions
+ for _, menu in ipairs(new_menus) do
+ if menu.search_suggestion then menu.search.query = menu.search_suggestion end
+ end
+ for _, menu in ipairs(self.all) do
+ if menu.search then
+ -- the menu items are new objects and the search needs to contain those
+ menu.search.source.items = not menu.on_search and menu.items or nil
+ -- Only internal searches are immediately submitted
+ if not menu.on_search then self:search_internal(menu.id, true) end
+ end
+
+ if menu.selected_index then self:select_by_offset(0, menu) end
+ end
+
+ self:search_ensure_key_bindings()
+end
+
+---@param items MenuDataChild[]
+function Menu:update_items(items)
+ local data = table_assign({}, self.root)
+ data.items = items
+ self:update(data)
+end
+
+function Menu:update_content_dimensions()
+ self.item_height = round(options.menu_item_height * state.scale)
+ self.min_width = round(options.menu_min_width * state.scale)
+ self.separator_size = round(1 * state.scale)
+ self.scrollbar_size = round(2 * state.scale)
+ self.padding = round(options.menu_padding * state.scale)
+ self.gap = round(2 * state.scale)
+ self.font_size = round(self.item_height * 0.48 * options.font_scale)
+ self.font_size_hint = self.font_size - 1
+ self.item_padding = round((self.item_height - self.font_size) * 0.6)
+ self.scroll_step = self.item_height + self.item_spacing
+
+ local title_opts = {size = self.font_size, italic = false, bold = false}
+ local hint_opts = {size = self.font_size_hint}
+
+ for _, menu in ipairs(self.all) do
+ title_opts.bold, title_opts.italic = true, false
+ local max_width = text_width(menu.title, title_opts) + 2 * self.padding + 2 * self.item_padding
+
+ -- Estimate width of a widest item
+ for _, item in ipairs(menu.items) do
+ local icon_width = item.icon and self.font_size or 0
+ item.title_width = text_width(item.title, title_opts)
+ item.hint_width = text_width(item.hint, hint_opts)
+ local spacings_in_item = 1 + (item.title_width > 0 and 1 or 0)
+ + (item.hint_width > 0 and 1 or 0) + (icon_width > 0 and 1 or 0)
+ local estimated_width = item.title_width + item.hint_width + icon_width
+ + (self.item_padding * spacings_in_item)
+ if estimated_width > max_width then max_width = estimated_width end
+ end
+
+ menu.max_width = max_width + 2 * self.padding
+ end
+
+ self:update_dimensions()
+end
+
+function Menu:update_dimensions()
+ -- Coordinates and sizes are of the scrollable area. Title is rendered
+ -- above it, so we need to account for that in max_height and ay position.
+ -- This is a debt from an era where we had different cursor event handling,
+ -- and dumb titles with no search inputs. It could use a refactor.
+ local margin = round(self.item_height / 2)
+ local external_buttons_reserve = display.width / self.item_height > 14 and self.scroll_step * 6 - margin * 2 or 0
+ local width_available = display.width - margin * 2 - external_buttons_reserve
+ local height_available = display.height - margin * 2
+ local min_width = math.min(self.min_width, width_available)
+
+ for _, menu in ipairs(self.all) do
+ local width = math.max(menu.search and menu.search.max_width or 0, menu.max_width)
+ menu.width = round(clamp(min_width, width, width_available))
+ local title_height = (menu.is_root and menu.title or menu.search) and self.scroll_step + self.padding or 0
+ local footnote_height = self.font_size * 1.5
+ local max_height = height_available - title_height - footnote_height
+ local content_height = self.scroll_step * #menu.items
+ menu.height = math.min(content_height - self.item_spacing, max_height)
+ menu.top = clamp(
+ title_height + margin,
+ menu.search and math.min(menu.search.min_top, menu.search.source.top) or height_available,
+ round((height_available - menu.height + title_height) / 2)
+ )
+ if menu.search then
+ menu.search.min_top = math.min(menu.search.min_top, menu.top)
+ menu.search.max_width = math.max(menu.search.max_width, menu.width)
+ end
+ menu.scroll_height = math.max(content_height - menu.height - self.item_spacing, 0)
+ self:set_scroll_to(menu.scroll_y, menu.id) -- clamps scroll_y to scroll limits
+ end
+
+ self:update_coordinates()
+end
+
+-- Updates element coordinates to match currently open (sub)menu.
+function Menu:update_coordinates()
+ local ax = round((display.width - self.current.width) / 2) + self.offset_x
+ self:set_coordinates(ax, self.current.top, ax + self.current.width, self.current.top + self.current.height)
+end
+
+function Menu:reset_navigation()
+ local menu = self.current
+
+ -- Reset indexes and scroll
+ self:set_scroll_to(menu.scroll_y) -- clamps scroll_y to scroll limits
+ if menu.items and #menu.items > 0 then
+ -- Normalize existing selected_index always, and force it only in keyboard navigation
+ if not self.mouse_nav then
+ self:select_by_offset(0)
+ end
+ else
+ self:select_index(nil)
+ end
+
+ -- Walk up the parent menu chain and activate items that lead to current menu
+ local parent = menu.parent_menu
+ while parent do
+ parent.selected_index = itable_index_of(parent.items, menu)
+ menu, parent = parent, parent.parent_menu
+ end
+
+ request_render()
+end
+
+function Menu:set_offset_x(offset)
+ local delta = offset - self.offset_x
+ self.offset_x = offset
+ self:set_coordinates(self.ax + delta, self.ay, self.bx + delta, self.by)
+end
+
+function Menu:fadeout(callback) self:tween_property('opacity', 1, 0, callback) end
+
+-- If `menu_id` is provided, will return menu with that id or `nil`. If `menu_id` is `nil`, will return current menu.
+---@param menu_id? string
+---@return MenuStack | nil
+function Menu:get_menu(menu_id) return menu_id == nil and self.current or self.by_id[menu_id] end
+
+function Menu:get_first_active_index(menu_id)
+ local menu = self:get_menu(menu_id)
+ if not menu then return end
+ for index, item in ipairs(menu.items) do
+ if item.active then return index end
+ end
+end
+
+---@param pos? number
+---@param menu_id? string
+function Menu:set_scroll_to(pos, menu_id)
+ local menu = self:get_menu(menu_id)
+ if not menu then return end
+ menu.scroll_y = clamp(0, pos or 0, menu.scroll_height)
+ request_render()
+end
+
+---@param delta? number
+---@param menu_id? string
+function Menu:set_scroll_by(delta, menu_id)
+ local menu = self:get_menu(menu_id)
+ if not menu then return end
+ self:set_scroll_to(menu.scroll_y + delta, menu_id)
+end
+
+---@param pos? number
+---@param menu_id? string
+---@param fling_options? table
+function Menu:scroll_to(pos, menu_id, fling_options)
+ local menu = self:get_menu(menu_id)
+ if not menu then return end
+ menu.fling = {
+ y = menu.scroll_y,
+ distance = clamp(-menu.scroll_y, pos - menu.scroll_y, menu.scroll_height - menu.scroll_y),
+ time = mp.get_time(),
+ duration = 0.1,
+ easing = ease_out_sext,
+ }
+ if fling_options then table_assign(menu.fling, fling_options) end
+ request_render()
+end
+
+---@param delta? number
+---@param menu_id? string
+---@param fling_options? Fling
+function Menu:scroll_by(delta, menu_id, fling_options)
+ local menu = self:get_menu(menu_id)
+ if not menu then return end
+ self:scroll_to((menu.fling and (menu.fling.y + menu.fling.distance) or menu.scroll_y) + delta, menu_id, fling_options)
+end
+
+---@param index? integer
+---@param menu_id? string
+---@param immediate? boolean
+function Menu:scroll_to_index(index, menu_id, immediate)
+ local menu = self:get_menu(menu_id)
+ if not menu then return end
+ if (index and index >= 1 and index <= #menu.items) then
+ local position = round((self.scroll_step * (index - 1)) - ((menu.height - self.scroll_step) / 2))
+ if immediate then
+ self:set_scroll_to(position, menu_id)
+ else
+ self:scroll_to(position, menu_id)
+ end
+ end
+end
+
+---@param index? integer
+---@param menu_id? string
+function Menu:select_index(index, menu_id)
+ local menu = self:get_menu(menu_id)
+ if not menu then return end
+ menu.selected_index = (index and index >= 1 and index <= #menu.items) and index or nil
+ self:select_action(menu.action_index, menu_id) -- normalize selected action index
+ request_render()
+end
+
+---@param index? integer
+---@param menu_id? string
+function Menu:select_action(index, menu_id)
+ local menu = self:get_menu(menu_id)
+ if not menu then return end
+ local actions = menu.items[menu.selected_index] and menu.items[menu.selected_index].actions or menu.item_actions
+ if not index or not actions or type(actions) ~= 'table' or index < 1 or index > #actions then
+ menu.action_index = nil
+ return
+ end
+ menu.action_index = index
+ request_render()
+end
+
+---@param delta? integer
+---@param menu_id? string
+function Menu:navigate_action(delta, menu_id)
+ local menu = self:get_menu(menu_id)
+ if not menu then return end
+ local actions = menu.items[menu.selected_index] and menu.items[menu.selected_index].actions or menu.item_actions
+ if actions and delta ~= 0 then
+ -- Circular navigation where zero gets converted to nil
+ local index = (menu.action_index or (delta > 0 and 0 or #actions + 1)) + delta
+ self:select_action(index <= #actions and index > 0 and (index - 1) % #actions + 1 or nil, menu_id)
+ else
+ self:select_action(nil, menu_id)
+ end
+ request_render()
+end
+
+function Menu:next_action() self:navigate_action(1) end
+function Menu:prev_action() self:navigate_action(-1) end
+
+---@param value? any
+---@param menu_id? string
+function Menu:select_value(value, menu_id)
+ local menu = self:get_menu(menu_id)
+ if not menu then return end
+ local index = itable_find(menu.items, function(item) return item.value == value end)
+ self:select_index(index)
+end
+
+---@param menu_id? string
+function Menu:deactivate_items(menu_id)
+ local menu = self:get_menu(menu_id)
+ if not menu then return end
+ for _, item in ipairs(menu.items) do item.active = false end
+ request_render()
+end
+
+---@param index? integer
+---@param menu_id? string
+function Menu:activate_index(index, menu_id)
+ local menu = self:get_menu(menu_id)
+ if not menu then return end
+ if index and index >= 1 and index <= #menu.items then menu.items[index].active = true end
+ request_render()
+end
+
+---@param index? integer
+---@param menu_id? string
+function Menu:activate_one_index(index, menu_id)
+ local menu = self:get_menu(menu_id)
+ if not menu then return end
+ self:activate_index(index, menu_id)
+end
+
+---@param value? any
+---@param menu_id? string
+function Menu:activate_value(value, menu_id)
+ local menu = self:get_menu(menu_id)
+ if not menu then return end
+ local index = itable_find(menu.items, function(item) return item.value == value end)
+ self:activate_index(index, menu_id)
+end
+
+---@param value? any
+---@param menu_id? string
+function Menu:activate_one_value(value, menu_id)
+ local menu = self:get_menu(menu_id)
+ if not menu then return end
+ local index = itable_find(menu.items, function(item) return item.value == value end)
+ self:activate_one_index(index, menu_id)
+end
+
+---@param id string One of menus in `self.all`.
+function Menu:activate_menu(id)
+ local menu = self:get_menu(id)
+ if menu then
+ self.current = menu
+ self:update_coordinates()
+ self:reset_navigation()
+ self:search_ensure_key_bindings()
+ local parent = menu.parent_menu
+ while parent do
+ parent.selected_index = itable_index_of(parent.items, menu)
+ self:scroll_to_index(parent.selected_index, parent)
+ menu, parent = parent, parent.parent_menu
+ end
+ request_render()
+ end
+end
+
+---@param index? integer
+---@param menu_id? string
+function Menu:delete_index(index, menu_id)
+ local menu = self:get_menu(menu_id)
+ if not menu then return end
+ if (index and index >= 1 and index <= #menu.items) then
+ table.remove(menu.items, index)
+ self:update_content_dimensions()
+ self:scroll_to_index(menu.selected_index, menu_id)
+ end
+end
+
+---@param value? any
+---@param menu_id? string
+function Menu:delete_value(value, menu_id)
+ local menu = self:get_menu(menu_id)
+ if not menu then return end
+ local index = itable_find(menu.items, function(item) return item.value == value end)
+ self:delete_index(index)
+end
+
+---@param id string Menu id.
+---@param x number `x` coordinate to slide from.
+function Menu:slide_in_menu(id, x)
+ local menu = self:get_menu(id)
+ if not menu then return end
+ self:activate_menu(id)
+ self:tween(-(display.width / 2 - menu.width / 2 - x), 0, function(offset) self:set_offset_x(offset) end)
+ self.opacity = 1 -- in case tween above canceled fade in animation
+end
+
+function Menu:back()
+ if not self:is_alive() then return end
+
+ local current = self.current
+ local parent = current.parent_menu
+
+ if parent then
+ self:slide_in_menu(parent.id, display.width / 2 - current.width / 2 - parent.width / 2 + self.offset_x)
+ else
+ self.callback({type = 'back'})
+ end
+end
+
+---@param shortcut? Shortcut
+---@param is_pointer? boolean Whether this was called by a pointer.
+function Menu:activate_selected_item(shortcut, is_pointer)
+ local menu = self.current
+ local item = menu.items[menu.selected_index]
+ if item then
+ -- Is submenu
+ if item.items then
+ if not self.mouse_nav then
+ self:select_index(1, item.id)
+ end
+ self:activate_menu(item.id)
+ self:tween(self.offset_x + menu.width / 2, 0, function(offset) self:set_offset_x(offset) end)
+ self.opacity = 1 -- in case tween above canceled fade in animation
+ else
+ local actions = item.actions or menu.item_actions
+ local action = actions and actions[menu.action_index]
+ self.callback({
+ type = 'activate',
+ index = menu.selected_index,
+ value = item.value,
+ is_pointer = is_pointer == true,
+ action = action and action.name,
+ keep_open = item.keep_open or menu.keep_open,
+ modifiers = shortcut and shortcut.modifiers or nil,
+ alt = shortcut and shortcut.alt or false,
+ ctrl = shortcut and shortcut.ctrl or false,
+ shift = shortcut and shortcut.shift or false,
+ menu_id = menu.id,
+ })
+ end
+ end
+end
+
+---@param index integer
+function Menu:move_selected_item_to(index)
+ if self.current.search then return end -- Moving filtered items is an undefined behavior
+ local callback = self.current.on_move
+ local from, items_count = self.current.selected_index, self.current.items and #self.current.items or 0
+ if callback and from and from ~= index and index >= 1 and index <= items_count then
+ local event = {type = 'move', from_index = from, to_index = index, menu_id = self.current.id}
+ self:command_or_event(callback, {from, index, self.current.id}, event)
+ self:select_index(index, self.current.id)
+ self:scroll_to_index(index, self.current.id, true)
+ end
+end
+
+---@param delta number
+function Menu:move_selected_item_by(delta)
+ local current_index, items_count = self.current.selected_index, self.current.items and #self.current.items or 0
+ if current_index and items_count > 1 then
+ local new_index = clamp(1, current_index + delta, items_count)
+ if current_index ~= new_index then
+ self:move_selected_item_to(new_index)
+ end
+ end
+end
+
+function Menu:on_display() self:update_dimensions() end
+function Menu:on_prop_fullormaxed() self:update_content_dimensions() end
+function Menu:on_options() self:update_content_dimensions() end
+
+function Menu:handle_cursor_down()
+ if self.proximity_raw == 0 then
+ self.drag_last_y = cursor.y
+ self.current.fling = nil
+ else
+ self:close()
+ end
+end
+
+---@param shortcut? Shortcut
+function Menu:handle_cursor_up(shortcut)
+ if self.proximity_raw == 0 and self.drag_last_y and not self.is_dragging then
+ self:activate_selected_item(shortcut, true)
+ end
+ if self.is_dragging then
+ local distance = cursor:get_velocity().y / -3
+ if math.abs(distance) > 50 then
+ self.current.fling = {
+ y = self.current.scroll_y,
+ distance = distance,
+ time = cursor.history:head().time,
+ easing = ease_out_quart,
+ duration = 0.5,
+ update_cursor = true,
+ }
+ request_render()
+ end
+ end
+ self.is_dragging = false
+ self.drag_last_y = nil
+end
+
+function Menu:on_global_mouse_move()
+ self.mouse_nav = true
+ if self.drag_last_y then
+ self.is_dragging = self.is_dragging or math.abs(cursor.y - self.drag_last_y) >= 10
+ local distance = self.drag_last_y - cursor.y
+ if distance ~= 0 then self:set_scroll_by(distance) end
+ if self.is_dragging then self.drag_last_y = cursor.y end
+ end
+ request_render()
+end
+
+function Menu:handle_wheel_up() self:scroll_by(self.scroll_step * -3, nil, {update_cursor = true}) end
+function Menu:handle_wheel_down() self:scroll_by(self.scroll_step * 3, nil, {update_cursor = true}) end
+
+---@param offset integer
+---@param menu? MenuStack
+function Menu:select_by_offset(offset, menu)
+ menu = menu or self.current
+
+ -- Blur selected_index when navigating off bounds and submittable search is active.
+ -- Blurred selected_index is an implied focused input, so enter can submit it.
+ if menu.search and menu.search_debounce == 'submit' and (
+ (menu.selected_index == 1 and offset < 0) or (menu.selected_index == #menu.items and offset > 0)
+ ) then
+ self:select_index(nil, menu.id)
+ else
+ local index = clamp(1, (menu.selected_index or offset >= 0 and 0 or #menu.items + 1) + offset, #menu.items)
+ local prev_index = itable_find(menu.items, function(item) return item.selectable ~= false end, index, 1)
+ local next_index = itable_find(menu.items, function(item) return item.selectable ~= false end, index)
+ if prev_index and next_index then
+ if offset == 0 then
+ self:select_index(index - prev_index <= next_index - index and prev_index or next_index, menu.id)
+ elseif offset > 0 then
+ self:select_index(next_index, menu.id)
+ else
+ self:select_index(prev_index, menu.id)
+ end
+ else
+ self:select_index(prev_index or next_index or nil, menu.id)
+ end
+ end
+
+ request_render()
+end
+
+---@param offset integer
+---@param immediate? boolean
+function Menu:navigate_by_offset(offset, immediate)
+ self:select_by_offset(offset)
+ if self.current.selected_index then
+ self:scroll_to_index(self.current.selected_index, self.current.id, immediate)
+ end
+end
+
+function Menu:paste()
+ local menu = self.current
+ local payload = get_clipboard()
+ if not payload then return end
+ if menu.on_paste then
+ local selected_item = menu.items and menu.selected_index and menu.items[menu.selected_index]
+ local actions = selected_item and selected_item.actions or menu.item_actions
+ local selected_action = actions and menu.action_index and actions[menu.action_index]
+ self:command_or_event(menu.on_paste, {payload, menu.id}, {
+ type = 'paste',
+ value = payload,
+ menu_id = menu.id,
+ selected_item = selected_item and {
+ index = menu.selected_index, value = selected_item.value, action = selected_action,
+ },
+ })
+ elseif menu.search then
+ self:search_query_update(menu.search.query .. payload)
+ elseif menu.search_style ~= 'disabled' then
+ self:search_start(menu.id)
+ self:search_query_update(payload, menu.id)
+ end
+end
+
+---@param menu_id string
+---@param no_select_first? boolean
+function Menu:search_internal(menu_id, no_select_first)
+ local menu = self:get_menu(menu_id)
+ if not menu then return end
+ local query = menu.search.query:lower()
+ if query == '' then
+ -- Reset menu state to what it was before search
+ for key, value in pairs(menu.search.source) do menu[key] = value end
+ else
+ -- Inherit `search_submenus` from parent menus
+ local search_submenus, parent_menu = menu.search_submenus, menu.parent_menu
+ while not search_submenus and parent_menu do
+ search_submenus, parent_menu = parent_menu.search_submenus, parent_menu.parent_menu
+ end
+ menu.items = search_items(menu.search.source.items, query, search_submenus)
+ -- Select 1st item in search results
+ if not no_select_first then
+ menu.scroll_y = 0
+ self:select_index(1, menu_id)
+ end
+ end
+ self:update_content_dimensions()
+end
+
+---@param items MenuStackChild[]
+---@param query string
+---@param recursive? boolean
+---@param prefix? string
+---@return MenuStackChild[]
+function search_items(items, query, recursive, prefix)
+ local result = {}
+ local concat = table.concat
+ for _, item in ipairs(items) do
+ if item.selectable ~= false then
+ local prefixed_title = prefix and prefix .. ' / ' .. (item.title or '') or item.title
+ if item.items and recursive then
+ itable_append(result, search_items(item.items, query, recursive, prefixed_title))
+ else
+ local title = item.title and item.title:lower()
+ local hint = item.hint and item.hint:lower()
+ local initials_title = title and concat(initials(title)) --[[@as string]]
+ local romanization = need_romanization()
+ if romanization then
+ ligature_conv_title = title and char_conv(title, true)
+ initials_conv_title = title and concat(initials(char_conv(title, false)))
+ end
+ if title and title:find(query, 1, true) or
+ title and romanization and ligature_conv_title:find(query, 1, true) or
+ hint and hint:find(query, 1, true) or
+ title and initials_title:find(query, 1, true) or
+ title and romanization and initials_conv_title:find(query, 1, true) or
+ hint and concat(initials(hint)):find(query, 1, true) then
+ item = table_assign({}, item)
+ item.title = prefixed_title
+ item.ass_safe_title = nil
+ result[#result + 1] = item
+ end
+ end
+ end
+ end
+ return result
+end
+
+---@param menu_id? string
+function Menu:search_submit(menu_id)
+ local menu = self:get_menu(menu_id)
+ if not menu or not menu.search then return end
+ local callback, query = menu.on_search, menu.search.query
+ if callback then
+ self:command_or_event(callback, {query, menu.id}, {type = 'search', query = query, menu_id = menu.id})
+ else
+ self:search_internal(menu.id)
+ end
+end
+
+---@param query string
+---@param menu_id? string
+---@param immediate? boolean
+function Menu:search_query_update(query, menu_id, immediate)
+ local menu = self:get_menu(menu_id)
+ if not menu or not menu.search then return end
+ menu.search.query = query
+ if menu.search_debounce ~= 'submit' then
+ if menu.search.timeout then menu.search.timeout:kill() end
+ if menu.search.timeout and not immediate then
+ menu.search.timeout:resume()
+ else
+ self:search_submit(menu_id)
+ end
+ else
+ -- `search_debounce='submit'` behavior: We blur selected item when query
+ -- changes to let [enter] key submit searches instead of activating items.
+ self:select_index(nil, menu.id)
+ end
+ request_render()
+end
+
+---@param event? string
+---@param word_mode? boolean Delete by words.
+function Menu:search_backspace(event, word_mode)
+ local pos, old_query = #self.current.search.query, self.current.search.query
+ local is_palette = self.current.search_style == 'palette'
+ if word_mode and #old_query > 1 then
+ local word_pat, other_pat = '[^%c%s%p]+$', '[%c%s%p]+$'
+ local init_pat = old_query:sub(#old_query):match(word_pat) and word_pat or other_pat
+ -- First we match all same type consecutive chars at the end
+ local tail = old_query:match(init_pat) or ''
+ -- If there's only one, we extend the tail with opposite type chars
+ if tail and #tail == 1 then
+ tail = tail .. old_query:sub(1, #old_query - #tail):match(init_pat == word_pat and other_pat or word_pat)
+ end
+ pos = pos - #tail
+ else
+ -- The while loop is for skipping utf8 continuation bytes
+ while pos > 1 and old_query:byte(pos) >= 0x80 and old_query:byte(pos) <= 0xbf do
+ pos = pos - 1
+ end
+ pos = pos - 1
+ end
+ local new_query = old_query:sub(1, pos)
+ if new_query ~= old_query and (is_palette or not self.type_to_search or pos > 0) then
+ self:search_query_update(new_query)
+ elseif not is_palette and self.type_to_search then
+ self:search_cancel()
+ elseif is_palette and event ~= 'repeat' then
+ self:back()
+ end
+end
+
+function Menu:search_text_input(info)
+ local menu = self.current
+ if not menu.search and menu.search_style == 'disabled' then return end
+ if info.event ~= 'up' then
+ local key_text = info.key_text
+ if not key_text then
+ -- might be KP0 to KP9 or KP_DEC
+ key_text = info.key_name:match('KP_?(.+)')
+ if not key_text then return end
+ if key_text == 'DEC' then key_text = '.' end
+ end
+ if not menu.search then self:search_start() end
+ self:search_query_update(menu.search.query .. key_text)
+ end
+end
+
+---@param menu_id? string
+function Menu:search_cancel(menu_id)
+ local menu = self:get_menu(menu_id)
+ if not menu or not menu.search or menu.search_style == 'palette' then return end
+ self:search_query_update('', menu_id, true)
+ menu.search = nil
+ self:search_ensure_key_bindings()
+ self:update_dimensions()
+ self:reset_navigation()
+end
+
+---@param menu_id? string
+function Menu:search_init(menu_id)
+ local menu = self:get_menu(menu_id)
+ if not menu then return end
+ if menu.search then return end
+ local timeout
+ if menu.search_debounce ~= 'submit' and menu.search_debounce > 0 then
+ timeout = mp.add_timeout(menu.search_debounce / 1000, self:create_action(function()
+ self:search_submit(menu.id)
+ end))
+ timeout:kill()
+ end
+ menu.search = {
+ query = '',
+ timeout = timeout,
+ min_top = menu.top,
+ max_width = menu.width,
+ source = {
+ width = menu.width,
+ top = menu.top,
+ scroll_y = menu.scroll_y,
+ selected_index = menu.selected_index,
+ items = not menu.on_search and menu.items or nil,
+ },
+ }
+end
+
+---@param menu_id? string
+function Menu:search_start(menu_id)
+ local menu = self:get_menu(menu_id)
+ if not menu or menu.search_style == 'disabled' then return end
+ self:search_init(menu_id)
+ self:search_ensure_key_bindings()
+ self:update_dimensions()
+end
+
+---@param menu_id? string
+function Menu:search_clear_query(menu_id)
+ local menu = self:get_menu(menu_id)
+ if not menu then return end
+ if not self.current.search_style == 'palette' and self.type_to_search then
+ self:search_cancel(menu_id)
+ else
+ self:search_query_update('', menu_id)
+ end
+end
+
+function Menu:search_enable_key_bindings()
+ if self:has_keybindings('search') then return end
+ local flags = {repeatable = true, complex = true}
+ self:add_key_binding('any_unicode', {self:create_key_handler('search_text_input'), flags}, 'search')
+ -- KP0 to KP9 and KP_DEC are not included in any_unicode
+ -- despite typically producing characters, they don't have a info.key_text
+ self:add_key_binding('kp_dec', {self:create_key_handler('search_text_input'), flags}, 'search')
+ for i = 0, 9 do
+ self:add_key_binding('kp' .. i, {self:create_key_handler('search_text_input'), flags}, 'search')
+ end
+end
+
+function Menu:search_ensure_key_bindings()
+ if self.current.search or (self.type_to_search and self.current.search_style ~= 'disabled') then
+ self:search_enable_key_bindings()
+ else
+ self:remove_key_bindings('search')
+ end
+end
+
+function Menu:enable_key_bindings()
+ -- `+` at the end enables `repeatable` flag
+ local standalone_keys = {
+ 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12', '/', 'kp_divide', 'mbtn_back',
+ {'f', 'ctrl'}, {'v', 'ctrl'}, {'c', 'ctrl'},
+ }
+ local modifiable_keys = {'up+', 'down+', 'left', 'right', 'enter', 'kp_enter', 'bs', 'tab', 'esc', 'pgup+',
+ 'pgdwn+', 'home', 'end', 'del'}
+ local modifiers = {nil, 'alt', 'alt+ctrl', 'alt+shift', 'alt+ctrl+shift', 'ctrl', 'ctrl+shift', 'shift'}
+ local normalized = {kp_enter = 'enter'}
+
+ local function bind(key, modifier, flags)
+ local binding = modifier and modifier .. '+' .. key or key
+ local shortcut = create_shortcut(normalized[key] or key, modifier)
+ local handler = self:create_action(function(info) self:handle_shortcut(shortcut, info) end)
+ self:add_key_binding(binding, {handler, flags})
+ end
+
+ for i, key_mods in ipairs(standalone_keys) do
+ local is_table = type(key_mods) == 'table'
+ local key, mods = is_table and key_mods[1] or key_mods, is_table and key_mods[2] or nil
+ bind(key, mods, {repeatable = false, complex = true})
+ end
+
+ for i, key in ipairs(modifiable_keys) do
+ local flags = {repeatable = false, complex = true}
+
+ if key:sub(-1) == '+' then
+ key = key:sub(1, -2)
+ flags.repeatable = true
+ end
+
+ for j = 1, #modifiers do
+ bind(key, modifiers[j], flags)
+ end
+ end
+
+ self:search_ensure_key_bindings()
+end
+
+-- Handles all key and mouse button shortcuts, except unicode inputs.
+---@param shortcut Shortcut
+---@param info ComplexBindingInfo
+function Menu:handle_shortcut(shortcut, info)
+ if not self:is_alive() then return end
+
+ self.mouse_nav = info.is_mouse
+ local menu, id, key, modifiers = self.current, shortcut.id, shortcut.key, shortcut.modifiers
+ local selected_index = menu.selected_index
+ local selected_item = menu and selected_index and menu.items[selected_index]
+ local is_submenu = selected_item and selected_item.items ~= nil
+ local actions = selected_item and selected_item.actions or menu.item_actions
+ local selected_action = actions and menu.action_index and actions[menu.action_index]
+
+ if info.event == 'up' then return end
+
+ if (key == 'enter' and selected_item) or (id == 'right' and is_submenu) then
+ self:activate_selected_item(shortcut)
+ elseif id == 'enter' and menu.search and menu.search_debounce == 'submit' then
+ self:search_submit()
+ elseif id == 'up' or id == 'down' then
+ self:navigate_by_offset(id == 'up' and -1 or 1, true)
+ elseif id == 'pgup' or id == 'pgdwn' then
+ local items_per_page = round((menu.height / self.scroll_step) * 0.4)
+ self:navigate_by_offset(items_per_page * (id == 'pgup' and -1 or 1))
+ elseif id == 'home' or id == 'end' then
+ self:navigate_by_offset(id == 'home' and -math.huge or math.huge)
+ elseif id == 'shift+tab' then
+ self:prev_action()
+ elseif id == 'tab' then
+ self:next_action()
+ elseif id == 'ctrl+up' then
+ self:move_selected_item_by(-1)
+ elseif id == 'ctrl+down' then
+ self:move_selected_item_by(1)
+ elseif id == 'ctrl+pgup' then
+ self:move_selected_item_by(-round((menu.height / self.scroll_step) * 0.4))
+ elseif id == 'ctrl+pgdwn' then
+ self:move_selected_item_by(round((menu.height / self.scroll_step) * 0.4))
+ elseif id == 'ctrl+home' then
+ self:move_selected_item_by(-math.huge)
+ elseif id == 'ctrl+end' then
+ self:move_selected_item_by(math.huge)
+ elseif id == '/' or id == 'kp_divide' or id == 'ctrl+f' then
+ self:search_start()
+ elseif key == 'esc' then
+ if menu.search and menu.search_style ~= 'palette' then
+ self:search_cancel()
+ else
+ self:close()
+ end
+ elseif id == 'left' and menu.parent_menu then
+ self:back()
+ elseif key == 'bs' then
+ if menu.search then
+ if modifiers == 'shift' then
+ self:search_clear_query()
+ elseif not modifiers or modifiers == 'ctrl' then
+ self:search_backspace(info.event, modifiers == 'ctrl')
+ end
+ elseif not modifiers and info.event ~= 'repeat' then
+ self:back()
+ end
+ elseif key == 'mbtn_back' then
+ self:back()
+ elseif id == 'ctrl+v' then
+ self:paste()
+ else
+ self.callback(table_assign({}, shortcut, {
+ type = 'key',
+ menu_id = menu.id,
+ selected_item = selected_item and {
+ index = selected_index, value = selected_item.value, action = selected_action,
+ },
+ }))
+ end
+end
+
+-- Check if menu is not closed or closing.
+function Menu:is_alive() return not self.is_closing and not self.destroyed end
+
+---@param name string
+function Menu:create_key_handler(name)
+ return self:create_action(function(...)
+ self.mouse_nav = false
+ self:maybe(name, ...)
+ end)
+end
+
+-- Sends command with params, or triggers a callback event if `command == 'callback'`.
+-- Intended to handle `on_{event}: 'callback' | string | string[]` events.
+-- Returns what happened.
+---@param command string|number|string[]|number[]
+---@param params string[]|number[]
+---@param event MenuEvent
+---@return 'event' | 'command' | nil
+function Menu:command_or_event(command, params, event)
+ if command == 'callback' then
+ self.callback(event)
+ return 'event'
+ elseif type(command) == 'table' then
+ ---@diagnostic disable-next-line: deprecated
+ mp.command_native(itable_join(command, params))
+ return 'command'
+ elseif type(command) == 'string' then
+ mp.command(command .. ' ' .. table.concat(params, ' '))
+ return 'command'
+ end
+ return nil
+end
+
+function Menu:render()
+ for _, menu in ipairs(self.all) do
+ if menu.fling then
+ local time_delta = state.render_last_time - menu.fling.time
+ local progress = menu.fling.easing(math.min(time_delta / menu.fling.duration, 1))
+ self:set_scroll_to(round(menu.fling.y + menu.fling.distance * progress), menu.id)
+ if progress < 1 then request_render() else menu.fling = nil end
+ end
+ end
+
+ local display_rect = {ax = 0, ay = 0, bx = display.width, by = display.height}
+ cursor:zone('primary_down', display_rect, self:create_action(function() self:handle_cursor_down() end))
+ cursor:zone('primary_up', display_rect, self:create_action(function(shortcut) self:handle_cursor_up(shortcut) end))
+ cursor:zone('wheel_down', self, function() self:handle_wheel_down() end)
+ cursor:zone('wheel_up', self, function() self:handle_wheel_up() end)
+
+ local ass = assdraw.ass_new()
+ local spacing = self.item_padding
+ local icon_size = self.font_size
+
+ ---@param menu MenuStack
+ ---@param x number
+ ---@param pos number Horizontal position index. 0 = current menu, <0 parent menus, >1 submenu.
+ local function draw_menu(menu, x, pos)
+ local is_current, is_parent, is_submenu = pos == 0, pos < 0, pos > 0
+ local menu_opacity = (pos == 0 and 1 or config.opacity.submenu ^ math.abs(pos)) * self.opacity
+ local ax, ay, bx, by = x, menu.top, x + menu.width, menu.top + menu.height
+ local draw_title = menu.is_root and menu.title or menu.search
+ local scroll_clip = '\\clip(0,' .. ay .. ',' .. display.width .. ',' .. by .. ')'
+ local start_index = math.floor(menu.scroll_y / self.scroll_step) + 1
+ local end_index = math.ceil((menu.scroll_y + menu.height) / self.scroll_step)
+ local menu_rect = {
+ ax = ax,
+ ay = ay - (draw_title and self.scroll_step + self.padding or 0) - self.padding,
+ bx = bx,
+ by = by + self.padding,
+ }
+ local blur_action_index = self.mouse_nav and menu.action_index ~= nil
+
+ -- Background
+ ass:rect(menu_rect.ax, menu_rect.ay, menu_rect.bx, menu_rect.by, {
+ color = bg,
+ opacity = menu_opacity * config.opacity.menu,
+ radius = state.radius > 0 and state.radius + self.padding or 0,
+ })
+
+ if is_parent then
+ cursor:zone('primary_down', menu_rect, self:create_action(function() self:slide_in_menu(menu.id, x) end))
+ end
+
+ -- Scrollbar
+ if menu.scroll_height > 0 then
+ local groove_height = menu.height - 2
+ local thumb_height = math.max((menu.height / (menu.scroll_height + menu.height)) * groove_height, 40)
+ local thumb_y = ay + 1 + ((menu.scroll_y / menu.scroll_height) * (groove_height - thumb_height))
+ local sax = bx - round(self.scrollbar_size / 2)
+ local sbx = sax + self.scrollbar_size
+ ass:rect(sax, thumb_y, sbx, thumb_y + thumb_height, {color = fg, opacity = menu_opacity * 0.8})
+ end
+
+ -- Draw submenu if selected
+ local submenu_rect, current_item = nil, is_current and menu.selected_index and menu.items[menu.selected_index]
+ local submenu_is_hovered = false
+ if current_item and current_item.items then
+ submenu_rect = draw_menu(current_item --[[@as MenuStack]], menu_rect.bx + self.gap, 1)
+ cursor:zone('primary_down', submenu_rect, self:create_action(function(shortcut)
+ self:activate_selected_item(shortcut, true)
+ end))
+ end
+
+ ---@type MenuAction|nil
+ local selected_action
+ for index = start_index, end_index, 1 do
+ local item = menu.items[index]
+
+ if not item then break end
+
+ local item_ax = menu_rect.ax + self.padding
+ local item_bx = menu_rect.bx - self.padding
+ local item_ay = ay - menu.scroll_y + self.scroll_step * (index - 1)
+ local item_by = item_ay + self.item_height
+ local item_center_y = item_ay + (self.item_height / 2)
+ local item_clip = (item_ay < ay or item_by > by) and scroll_clip or nil
+ local content_ax, content_bx = ax + self.padding + spacing, bx - self.padding - spacing
+ local is_selected = menu.selected_index == index
+ local item_rect_hitbox = {
+ ax = item_ax,
+ ay = math.max(item_ay, menu_rect.ay),
+ bx = menu_rect.bx + (item.items and self.gap or -self.padding), -- to bridge the gap with cursor
+ by = math.min(item_ay + self.scroll_step, menu_rect.by),
+ }
+
+ -- Select hovered item
+ if is_current and self.mouse_nav and item.selectable ~= false
+ -- Do not select items if cursor is moving towards a submenu
+ and (not submenu_rect or not cursor:direction_to_rectangle_distance(submenu_rect))
+ and (submenu_is_hovered or get_point_to_rectangle_proximity(cursor, item_rect_hitbox) == 0) then
+ menu.selected_index = index
+ if not is_selected then
+ is_selected = true
+ request_render()
+ end
+ end
+
+ local has_background = is_selected or item.active
+ local next_item = menu.items[index + 1]
+ local next_is_active = next_item and next_item.active
+ local next_has_background = menu.selected_index == index + 1 or next_is_active
+ local font_color = item.active and fgt or bgt
+ local actions = is_selected and (item.actions or menu.item_actions) -- not nil = actions are visible
+ local action = actions and actions[menu.action_index] -- not nil = action is selected
+
+ if action then selected_action = action end
+
+ -- Separator
+ if item_by < by and ((not has_background and not next_has_background) or item.separator) then
+ local separator_ay, separator_by = item_by, item_by + self.separator_size
+ if has_background then
+ separator_ay, separator_by = separator_ay + self.separator_size, separator_by + self.separator_size
+ elseif next_has_background then
+ separator_ay, separator_by = separator_ay - self.separator_size, separator_by - self.separator_size
+ end
+ ass:rect(ax + spacing, separator_ay, bx - spacing, separator_by, {
+ color = fg, opacity = menu_opacity * (item.separator and 0.13 or 0.04),
+ })
+ end
+
+ -- Background
+ local highlight_opacity = 0 + (item.active and 0.8 or 0) + (is_selected and 0.15 or 0)
+ if highlight_opacity > 0 then
+ ass:rect(ax + self.padding, item_ay, bx - self.padding, item_by, {
+ radius = state.radius,
+ color = fg,
+ opacity = highlight_opacity * menu_opacity,
+ clip = item_clip,
+ })
+ end
+
+ local title_clip_bx = content_bx
+
+ -- Actions
+ local actions_rect
+ if is_selected and actions and #actions > 0 and not item.items then
+ local place = item.actions_place or menu.item_actions_place
+ local margin = self.gap * 2
+ local size = item_by - item_ay - margin * 2
+ local rect_width = size * #actions + margin * (#actions - 1)
+
+ -- Place actions outside of menu when requested and there's enough space for it
+ actions_rect = {
+ ay = item_ay + margin,
+ by = item_by - margin,
+ is_outside = place == 'outside' and display.width - menu_rect.bx + margin * 2 > rect_width,
+ }
+ actions_rect.bx = actions_rect.is_outside and menu_rect.bx + margin + rect_width or item_bx - margin
+ actions_rect.ax = actions_rect.bx
+
+ for i = 1, #actions, 1 do
+ local action_index = #actions - (i - 1)
+ local action = actions[action_index]
+
+ -- Hide when the action shouldn't be displayed when the item is a result of a search/filter
+ if not (action.filter_hidden and menu.search) then
+ local is_active = action_index == menu.action_index
+ local bx = actions_rect.ax - (i == 1 and 0 or margin)
+ local rect = {
+ ay = actions_rect.ay,
+ by = actions_rect.by,
+ ax = bx - size,
+ bx = bx,
+ }
+ actions_rect.ax = rect.ax
+
+ ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
+ radius = state.radius > 2 and state.radius - 1 or state.radius,
+ color = is_active and fg or bg,
+ border = is_active and self.gap or nil,
+ border_color = bg,
+ opacity = menu_opacity,
+ clip = item_clip,
+ })
+ ass:icon(rect.ax + size / 2, rect.ay + size / 2, size * 0.66, action.icon, {
+ color = is_active and bg or fg, opacity = menu_opacity, clip = item_clip,
+ })
+
+ -- Re-use rect as a hitbox by growing it so it bridges gaps to prevent flickering
+ rect.ay, rect.by, rect.bx = item_ay, item_ay + self.scroll_step, rect.bx + margin
+
+ -- Select action on cursor hover
+ if self.mouse_nav and get_point_to_rectangle_proximity(cursor, rect) == 0 then
+ cursor:zone('primary_click', rect, self:create_action(function(shortcut)
+ self:activate_selected_item(shortcut, true)
+ end))
+ blur_action_index = false
+ if not is_active then
+ menu.action_index = action_index
+ selected_action = actions[action_index]
+ request_render()
+ end
+ end
+ end
+ end
+
+ title_clip_bx = actions_rect.ax - self.gap * 2
+ end
+
+ -- Selected item indicator line
+ if is_selected and not selected_action then
+ local size = round(2 * state.scale)
+ local v_padding = math.min(state.radius, math.ceil(self.item_height / 3))
+ ass:rect(ax + self.padding - size - 1, item_ay + v_padding, ax + self.padding - 1,
+ item_by - v_padding, {
+ radius = 1 * state.scale, color = fg, opacity = menu_opacity, clip = item_clip,
+ })
+ end
+
+ -- Icon
+ if item.icon then
+ if not actions_rect or actions_rect.is_outside then
+ local x = (not item.title and not item.hint and item.align == 'center')
+ and menu_rect.ax + (menu_rect.bx - menu_rect.ax) / 2
+ or content_bx - (icon_size / 2)
+ if item.icon == 'spinner' then
+ ass:spinner(x, item_center_y, icon_size * 1.5, {color = font_color, opacity = menu_opacity * 0.8})
+ else
+ ass:icon(x, item_center_y, icon_size * 1.5, item.icon, {
+ color = font_color, opacity = menu_opacity, clip = item_clip,
+ })
+ end
+ end
+ content_bx = content_bx - icon_size - spacing
+ title_clip_bx = math.min(content_bx, title_clip_bx)
+ end
+
+ local hint_clip_bx = title_clip_bx
+ if item.hint_width > 0 then
+ -- controls title & hint clipping proportional to the ratio of their widths
+ -- both title and hint get at least 50% of the width, unless they are smaller then that
+ local width = content_bx - content_ax - spacing
+ local title_min = math.min(item.title_width, width * 0.5)
+ local hint_min = math.min(item.hint_width, width * 0.5)
+ local title_ratio = item.title_width / (item.title_width + item.hint_width)
+ title_clip_bx = math.min(
+ title_clip_bx,
+ round(content_ax + clamp(title_min, width * title_ratio, width - hint_min))
+ )
+ end
+
+ -- Hint
+ if item.hint then
+ item.ass_safe_hint = item.ass_safe_hint or ass_escape(item.hint)
+ local clip = '\\clip(' .. title_clip_bx + spacing .. ',' ..
+ math.max(item_ay, ay) .. ',' .. hint_clip_bx .. ',' .. math.min(item_by, by) .. ')'
+ ass:txt(content_bx, item_center_y, 6, item.ass_safe_hint, {
+ size = self.font_size_hint,
+ color = font_color,
+ wrap = 2,
+ opacity = 0.5 * menu_opacity,
+ clip = clip,
+ })
+ end
+
+ -- Title
+ if item.title then
+ item.ass_safe_title = item.ass_safe_title or ass_escape(item.title)
+ local clip = '\\clip(' .. ax .. ',' .. math.max(item_ay, ay) .. ','
+ .. title_clip_bx .. ',' .. math.min(item_by, by) .. ')'
+ local title_x, align = content_ax, 4
+ if item.align == 'right' then
+ title_x, align = title_clip_bx, 6
+ elseif item.align == 'center' then
+ title_x, align = content_ax + (title_clip_bx - content_ax) / 2, 5
+ end
+ ass:txt(title_x, item_center_y, align, item.ass_safe_title, {
+ size = self.font_size,
+ color = font_color,
+ italic = item.italic,
+ bold = item.bold,
+ wrap = 2,
+ opacity = menu_opacity * (item.muted and 0.5 or 1),
+ clip = clip,
+ })
+ end
+ end
+
+ -- Footnote / Selected action label
+ if is_current and (menu.footnote or selected_action) then
+ local height_half = self.font_size
+ local icon_x, icon_y = menu_rect.ax + self.padding + self.font_size / 2, menu_rect.by + height_half
+ local is_icon_hovered = false
+ local icon_hitbox = {
+ ax = icon_x - height_half,
+ ay = icon_y - height_half,
+ bx = icon_x + height_half,
+ by = icon_y + height_half,
+ }
+ is_icon_hovered = get_point_to_rectangle_proximity(cursor, icon_hitbox) == 0
+ local text = selected_action and selected_action.label or is_icon_hovered and menu.footnote
+ local opacity = (is_icon_hovered and 1 or 0.5) * menu_opacity
+ ass:icon(icon_x, icon_y, self.font_size, is_icon_hovered and 'help' or 'help_outline', {
+ color = fg, border = state.scale, border_color = bg, opacity = opacity,
+ })
+ if text then
+ ass:txt(icon_x + self.font_size * 0.75, icon_y, 4, text, {
+ size = self.font_size,
+ color = fg,
+ border = state.scale,
+ border_color = bg,
+ opacity = menu_opacity,
+ italic = true,
+ })
+ end
+ end
+
+ -- Menu title
+ if draw_title then
+ local requires_submit = menu.search_debounce == 'submit'
+ local rect = {
+ ax = round(ax + spacing / 2 + self.padding),
+ ay = ay - self.scroll_step - self.padding * 2,
+ bx = round(bx - spacing / 2 - self.padding),
+ by = math.min(by, ay - self.padding),
+ }
+ -- centers
+ rect.cx, rect.cy = round(rect.ax + (rect.bx - rect.ax) / 2), round(rect.ay + (rect.by - rect.ay) / 2)
+
+ if menu.title and not menu.ass_safe_title then
+ menu.ass_safe_title = ass_escape(menu.title)
+ end
+
+ -- Bottom border
+ ass:rect(ax, rect.by - self.separator_size, bx, rect.by, {color = fg, opacity = menu_opacity * 0.2})
+
+ -- Do nothing when user clicks title
+ if is_current then
+ cursor:zone('primary_down', rect, function() end)
+ end
+
+ -- Title
+ if menu.search then
+ -- Icon
+ local icon_size, icon_opacity = self.font_size * 1.3, menu_opacity * (requires_submit and 0.5 or 1)
+ local icon_rect = {ax = rect.ax, ay = rect.ay, bx = ax + icon_size + spacing * 1.5, by = rect.by}
+
+ if is_current and requires_submit then
+ cursor:zone('primary_down', icon_rect, function() self:search_submit() end)
+ if get_point_to_rectangle_proximity(cursor, icon_rect) == 0 then
+ icon_opacity = menu_opacity
+ end
+ end
+
+ ass:icon(rect.ax + icon_size / 2, rect.cy, icon_size, 'search', {
+ color = fg,
+ opacity = icon_opacity,
+ clip = '\\clip(' ..
+ icon_rect.ax .. ',' .. icon_rect.ay .. ',' .. icon_rect.bx .. ',' .. icon_rect.by .. ')',
+ })
+
+ -- Query/Placeholder
+ if menu.search.query ~= '' then
+ -- Add a ZWNBSP suffix to prevent libass from trimming trailing spaces
+ local query = ass_escape(menu.search.query) .. '\239\187\191'
+ ass:txt(rect.bx, rect.cy, 6, query, {
+ size = self.font_size,
+ color = bgt,
+ wrap = 2,
+ opacity = menu_opacity,
+ clip = '\\clip(' .. icon_rect.bx .. ',' .. rect.ay .. ',' .. rect.bx .. ',' .. rect.by .. ')',
+ })
+ else
+ local placeholder = (menu.search_style == 'palette' and menu.ass_safe_title)
+ and menu.ass_safe_title
+ or (requires_submit and t('type & ctrl+enter to search') or t('type to search'))
+ ass:txt(rect.bx, rect.cy, 6, placeholder, {
+ size = self.font_size,
+ italic = true,
+ color = bgt,
+ wrap = 2,
+ opacity = menu_opacity * 0.4,
+ clip = '\\clip(' .. rect.ax .. ',' .. rect.ay .. ',' .. rect.bx .. ',' .. rect.by .. ')',
+ })
+ end
+
+ -- Selected input indicator for submittable searches.
+ -- (input is selected when `selected_index` is `nil`)
+ if menu.search_debounce == 'submit' and not menu.selected_index then
+ local size_half = round(1 * state.scale)
+ ass:rect(ax, rect.by - size_half, bx, rect.by + size_half, {color = fg, opacity = menu_opacity})
+ end
+ local input_is_blurred = menu.search_debounce == 'submit' and menu.selected_index
+
+ -- Cursor
+ local cursor_height_half, cursor_thickness = round(self.font_size * 0.6), round(self.font_size / 12)
+ local cursor_ax, cursor_bx = rect.bx + 1, rect.bx + 1 + cursor_thickness
+ ass:rect(cursor_ax, rect.cy - cursor_height_half, cursor_bx, rect.cy + cursor_height_half, {
+ color = fg,
+ opacity = menu_opacity * (input_is_blurred and 0.5 or 1),
+ clip = '\\clip(' .. cursor_ax .. ',' .. rect.ay .. ',' .. cursor_bx .. ',' .. rect.by .. ')',
+ })
+ else
+ ass:txt(rect.cx, rect.cy, 5, menu.ass_safe_title, {
+ size = self.font_size,
+ bold = true,
+ color = bgt,
+ wrap = 2,
+ opacity = menu_opacity,
+ clip = '\\clip(' .. rect.ax .. ',' .. rect.ay .. ',' .. rect.bx .. ',' .. rect.by .. ')',
+ })
+ end
+ end
+
+ if blur_action_index then
+ menu.action_index = nil
+ request_render()
+ end
+
+ return menu_rect
+ end
+
+ -- Active menu
+ draw_menu(self.current, self.ax, 0)
+
+ -- Parent menus
+ local parent_menu = self.current.parent_menu
+ local parent_offset_x, parent_horizontal_index = self.ax, -1
+
+ while parent_menu do
+ parent_offset_x = parent_offset_x - parent_menu.width - self.gap
+ draw_menu(parent_menu, parent_offset_x, parent_horizontal_index)
+ parent_horizontal_index = parent_horizontal_index - 1
+ parent_menu = parent_menu.parent_menu
+ end
+
+ return ass
+end
+
+return Menu
diff --git a/dotfiles/.config/mpv/scripts/uosc/elements/PauseIndicator.lua b/dotfiles/.config/mpv/scripts/uosc/elements/PauseIndicator.lua
new file mode 100644
index 0000000..004a9fe
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/elements/PauseIndicator.lua
@@ -0,0 +1,83 @@
+local Element = require('elements/Element')
+
+---@class PauseIndicator : Element
+local PauseIndicator = class(Element)
+
+function PauseIndicator:new() return Class.new(self) --[[@as PauseIndicator]] end
+function PauseIndicator:init()
+ Element.init(self, 'pause_indicator', {render_order = 3})
+ self.ignores_curtain = true
+ self.paused = state.pause
+ self.opacity = 0
+ self.fadeout = false
+ self:init_options()
+end
+
+function PauseIndicator:init_options()
+ self.base_icon_opacity = options.pause_indicator == 'flash' and 1 or 0.8
+ self.type = options.pause_indicator
+ self:on_prop_pause()
+end
+
+function PauseIndicator:flash()
+ -- Can't wait for pause property event listener to set this, because when this is used inside a binding like:
+ -- cycle pause; script-binding uosc/flash-pause-indicator
+ -- The pause event is not fired fast enough, and indicator starts rendering with old icon.
+ self.paused = mp.get_property_native('pause')
+ self.fadeout, self.opacity = false, 1
+ self:tween_property('opacity', 1, 0, 300)
+end
+
+-- Decides whether static indicator should be visible or not.
+function PauseIndicator:decide()
+ self.paused = mp.get_property_native('pause') -- see flash() for why this line is necessary
+ self.fadeout, self.opacity = self.paused, self.paused and 1 or 0
+ request_render()
+
+ -- Workaround for an mpv race condition bug during pause on windows builds, which causes osd updates to be ignored.
+ -- .03 was still loosing renders, .04 was fine, but to be safe I added 10ms more
+ mp.add_timeout(.05, function() osd:update() end)
+end
+
+function PauseIndicator:on_prop_pause()
+ if Elements:v('timeline', 'pressed') then return end
+ if options.pause_indicator == 'flash' then
+ if self.paused ~= state.pause then self:flash() end
+ elseif options.pause_indicator == 'static' then
+ self:decide()
+ end
+end
+
+function PauseIndicator:on_options()
+ self:init_options()
+ if self.type == 'flash' then self.opacity = 0 end
+end
+
+function PauseIndicator:render()
+ if self.opacity == 0 then return end
+
+ local ass = assdraw.ass_new()
+
+ -- Background fadeout
+ if self.fadeout then
+ ass:rect(0, 0, display.width, display.height, {color = bg, opacity = self.opacity * 0.3})
+ end
+
+ -- Icon
+ local size = round(math.min(display.width, display.height) * (self.fadeout and 0.20 or 0.15))
+ size = size + size * (1 - self.opacity)
+
+ if self.paused then
+ ass:icon(display.width / 2, display.height / 2, size, 'pause',
+ {border = 1, opacity = self.base_icon_opacity * self.opacity}
+ )
+ else
+ ass:icon(display.width / 2, display.height / 2, size * 1.2, 'play_arrow',
+ {border = 1, opacity = self.base_icon_opacity * self.opacity}
+ )
+ end
+
+ return ass
+end
+
+return PauseIndicator
diff --git a/dotfiles/.config/mpv/scripts/uosc/elements/Speed.lua b/dotfiles/.config/mpv/scripts/uosc/elements/Speed.lua
new file mode 100644
index 0000000..f994e69
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/elements/Speed.lua
@@ -0,0 +1,195 @@
+local Element = require('elements/Element')
+
+---@alias Dragging { start_time: number; start_x: number; distance: number; speed_distance: number; start_speed: number; }
+
+---@class Speed : Element
+local Speed = class(Element)
+
+---@param props? ElementProps
+function Speed:new(props) return Class.new(self, props) --[[@as Speed]] end
+function Speed:init(props)
+ Element.init(self, 'speed', props)
+
+ self.width = 0
+ self.height = 0
+ self.notches = 10
+ self.notch_every = 0.1
+ ---@type number
+ self.notch_spacing = nil
+ ---@type number
+ self.font_size = nil
+ ---@type Dragging|nil
+ self.dragging = nil
+end
+
+function Speed:get_visibility()
+ return Elements:maybe('timeline', 'get_is_hovered') and -1 or Element.get_visibility(self)
+end
+
+function Speed:on_coordinates()
+ self.height, self.width = self.by - self.ay, self.bx - self.ax
+ self.notch_spacing = self.width / (self.notches + 1)
+ self.font_size = round(self.height * 0.48 * options.font_scale)
+end
+function Speed:on_options() self:on_coordinates() end
+
+function Speed:speed_step(speed, up)
+ if options.speed_step_is_factor then
+ if up then
+ return speed * options.speed_step
+ else
+ return speed * 1 / options.speed_step
+ end
+ else
+ if up then
+ return speed + options.speed_step
+ else
+ return speed - options.speed_step
+ end
+ end
+end
+
+function Speed:handle_cursor_down()
+ self:tween_stop() -- Stop and cleanup possible ongoing animations
+ self.dragging = {
+ start_time = mp.get_time(),
+ start_x = cursor.x,
+ distance = 0,
+ speed_distance = 0,
+ start_speed = state.speed,
+ }
+end
+
+function Speed:on_global_mouse_move()
+ if not self.dragging then return end
+
+ self.dragging.distance = cursor.x - self.dragging.start_x
+ self.dragging.speed_distance = (-self.dragging.distance / self.notch_spacing * self.notch_every)
+
+ local speed_current = state.speed
+ local speed_drag_current = self.dragging.start_speed + self.dragging.speed_distance
+ speed_drag_current = clamp(0.01, speed_drag_current, 100)
+ local drag_dir_up = speed_drag_current > speed_current
+
+ local speed_step_next = speed_current
+ local speed_drag_diff = math.abs(speed_drag_current - speed_current)
+ while math.abs(speed_step_next - speed_current) < speed_drag_diff do
+ speed_step_next = self:speed_step(speed_step_next, drag_dir_up)
+ end
+ local speed_step_prev = self:speed_step(speed_step_next, not drag_dir_up)
+
+ local speed_new = speed_step_prev
+ local speed_next_diff = math.abs(speed_drag_current - speed_step_next)
+ local speed_prev_diff = math.abs(speed_drag_current - speed_step_prev)
+ if speed_next_diff < speed_prev_diff then
+ speed_new = speed_step_next
+ end
+
+ if speed_new ~= speed_current then
+ mp.set_property_native('speed', speed_new)
+ end
+end
+
+function Speed:handle_cursor_up()
+ self.dragging = nil
+ request_render()
+end
+
+function Speed:on_global_mouse_leave()
+ self.dragging = nil
+ request_render()
+end
+
+function Speed:handle_wheel_up() mp.set_property_native('speed', self:speed_step(state.speed, true)) end
+function Speed:handle_wheel_down() mp.set_property_native('speed', self:speed_step(state.speed, false)) end
+
+function Speed:render()
+ local visibility = self:get_visibility()
+ local opacity = self.dragging and 1 or visibility
+
+ if opacity <= 0 then return end
+
+ cursor:zone('primary_down', self, function()
+ self:handle_cursor_down()
+ cursor:once('primary_up', function() self:handle_cursor_up() end)
+ end)
+ cursor:zone('secondary_click', self, function() mp.set_property_native('speed', 1) end)
+ cursor:zone('wheel_down', self, function() self:handle_wheel_down() end)
+ cursor:zone('wheel_up', self, function() self:handle_wheel_up() end)
+
+ local ass = assdraw.ass_new()
+
+ -- Background
+ ass:rect(self.ax, self.ay, self.bx, self.by, {
+ color = bg, radius = state.radius, opacity = opacity * config.opacity.speed,
+ })
+
+ -- Coordinates
+ local ax, ay = self.ax, self.ay
+ local bx, by = self.bx, ay + self.height
+ local half_width = (self.width / 2)
+ local half_x = ax + half_width
+
+ -- Notches
+ local speed_at_center = state.speed
+ if self.dragging then
+ speed_at_center = self.dragging.start_speed + self.dragging.speed_distance
+ speed_at_center = clamp(0.01, speed_at_center, 100)
+ end
+ local nearest_notch_speed = round(speed_at_center / self.notch_every) * self.notch_every
+ local nearest_notch_x = half_x + (((nearest_notch_speed - speed_at_center) / self.notch_every) * self.notch_spacing)
+ local guide_size = math.floor(self.height / 7.5)
+ local notch_by = by - guide_size
+ local notch_ay_big = ay + round(self.font_size * 1.1)
+ local notch_ay_medium = notch_ay_big + ((notch_by - notch_ay_big) * 0.2)
+ local notch_ay_small = notch_ay_big + ((notch_by - notch_ay_big) * 0.4)
+ local from_to_index = math.floor(self.notches / 2)
+
+ for i = -from_to_index, from_to_index do
+ local notch_speed = nearest_notch_speed + (i * self.notch_every)
+
+ if notch_speed >= 0 and notch_speed <= 100 then
+ local notch_x = nearest_notch_x + (i * self.notch_spacing)
+ local notch_thickness = 1
+ local notch_ay = notch_ay_small
+ if (notch_speed % (self.notch_every * 10)) < 0.00000001 then
+ notch_ay = notch_ay_big
+ notch_thickness = 1.5
+ elseif (notch_speed % (self.notch_every * 5)) < 0.00000001 then
+ notch_ay = notch_ay_medium
+ end
+
+ ass:rect(notch_x - notch_thickness, notch_ay, notch_x + notch_thickness, notch_by, {
+ color = fg,
+ border = 1,
+ border_color = bg,
+ opacity = math.min(1.2 - (math.abs((notch_x - ax - half_width) / half_width)), 1) * opacity,
+ })
+ end
+ end
+
+ -- Center guide
+ ass:new_event()
+ ass:append('{\\rDefault\\an7\\blur0\\bord1\\shad0\\1c&H' .. fg .. '\\3c&H' .. bg .. '}')
+ ass:opacity(opacity)
+ ass:pos(0, 0)
+ ass:draw_start()
+ ass:move_to(half_x, by - 2 - guide_size)
+ ass:line_to(half_x + guide_size, by - 2)
+ ass:line_to(half_x - guide_size, by - 2)
+ ass:draw_stop()
+
+ -- Speed value
+ local speed_text = (round(state.speed * 100) / 100) .. 'x'
+ ass:txt(half_x, ay + (notch_ay_big - ay) / 2, 5, speed_text, {
+ size = self.font_size,
+ color = bgt,
+ border = options.text_border * state.scale,
+ border_color = bg,
+ opacity = opacity,
+ })
+
+ return ass
+end
+
+return Speed
diff --git a/dotfiles/.config/mpv/scripts/uosc/elements/Timeline.lua b/dotfiles/.config/mpv/scripts/uosc/elements/Timeline.lua
new file mode 100644
index 0000000..608275f
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/elements/Timeline.lua
@@ -0,0 +1,483 @@
+local Element = require('elements/Element')
+
+---@class Timeline : Element
+local Timeline = class(Element)
+
+function Timeline:new() return Class.new(self) --[[@as Timeline]] end
+function Timeline:init()
+ Element.init(self, 'timeline', {render_order = 5})
+ ---@type false|{pause: boolean, distance: number, last: {x: number, y: number}}
+ self.pressed = false
+ self.obstructed = false
+ self.size = 0
+ self.progress_size = 0
+ self.min_progress_size = 0 -- used for `flash-progress`
+ self.font_size = 0
+ self.top_border = 0
+ self.line_width = 0
+ self.progress_line_width = 0
+ self.is_hovered = false
+ self.has_thumbnail = false
+
+ self:decide_progress_size()
+ self:update_dimensions()
+
+ -- Release any dragging when file gets unloaded
+ self:register_mp_event('end-file', function() self.pressed = false end)
+end
+
+function Timeline:get_visibility()
+ return math.max(Elements:maybe('controls', 'get_visibility') or 0, Element.get_visibility(self))
+end
+
+function Timeline:decide_enabled()
+ local previous = self.enabled
+ self.enabled = not self.obstructed and state.duration ~= nil and state.duration > 0 and state.time ~= nil
+ if self.enabled ~= previous then Elements:trigger('timeline_enabled', self.enabled) end
+end
+
+function Timeline:get_effective_size()
+ if Elements:v('speed', 'dragging') then return self.size end
+ local progress_size = math.max(self.min_progress_size, self.progress_size)
+ return progress_size + math.ceil((self.size - self.progress_size) * self:get_visibility())
+end
+
+function Timeline:get_is_hovered() return self.enabled and self.is_hovered end
+
+function Timeline:update_dimensions()
+ self.size = round(options.timeline_size * state.scale)
+ self.top_border = round(options.timeline_border * state.scale)
+ self.line_width = round(options.timeline_line_width * state.scale)
+ self.progress_line_width = round(options.progress_line_width * state.scale)
+ self.font_size = math.floor(math.min((self.size + 60 * state.scale) * 0.2, self.size * 0.96) * options.font_scale)
+ local window_border_size = Elements:v('window_border', 'size', 0)
+ self.ax = window_border_size
+ self.ay = display.height - window_border_size - self.size - self.top_border
+ self.bx = display.width - window_border_size
+ self.by = display.height - window_border_size
+ self.width = self.bx - self.ax
+ self.chapter_size = math.max((self.by - self.ay) / 10, 3)
+ self.chapter_size_hover = self.chapter_size * 2
+
+ -- Disable if not enough space
+ local available_space = display.height - window_border_size * 2 - Elements:v('top_bar', 'size', 0)
+ self.obstructed = available_space < self.size + 10
+ self:decide_enabled()
+end
+
+function Timeline:decide_progress_size()
+ local show = options.progress == 'always'
+ or (options.progress == 'fullscreen' and state.fullormaxed)
+ or (options.progress == 'windowed' and not state.fullormaxed)
+ self.progress_size = show and options.progress_size or 0
+end
+
+function Timeline:toggle_progress()
+ local current = self.progress_size
+ self:tween_property('progress_size', current, current > 0 and 0 or options.progress_size)
+ request_render()
+end
+
+function Timeline:flash_progress()
+ if self.enabled and options.flash_duration > 0 then
+ if not self._flash_progress_timer then
+ self._flash_progress_timer = mp.add_timeout(options.flash_duration / 1000, function()
+ self:tween_property('min_progress_size', options.progress_size, 0)
+ end)
+ self._flash_progress_timer:kill()
+ end
+
+ self:tween_stop()
+ self.min_progress_size = options.progress_size
+ request_render()
+ self._flash_progress_timer.timeout = options.flash_duration / 1000
+ self._flash_progress_timer:kill()
+ self._flash_progress_timer:resume()
+ end
+end
+
+function Timeline:get_time_at_x(x)
+ local line_width = (options.timeline_style == 'line' and self.line_width - 1 or 0)
+ local time_width = self.width - line_width - 1
+ local fax = (time_width) * state.time / state.duration
+ local fbx = fax + line_width
+ -- time starts 0.5 pixels in
+ x = x - self.ax - 0.5
+ if x > fbx then
+ x = x - line_width
+ elseif x > fax then
+ x = fax
+ end
+ local progress = clamp(0, x / time_width, 1)
+ return state.duration * progress
+end
+
+---@param fast? boolean
+function Timeline:set_from_cursor(fast)
+ if state.time and state.duration then
+ mp.commandv('seek', self:get_time_at_x(cursor.x), fast and 'absolute+keyframes' or 'absolute+exact')
+ end
+end
+
+function Timeline:clear_thumbnail()
+ mp.commandv('script-message-to', 'thumbfast', 'clear')
+ self.has_thumbnail = false
+end
+
+function Timeline:handle_cursor_down()
+ self.pressed = {pause = state.pause, distance = 0, last = {x = cursor.x, y = cursor.y}}
+ mp.set_property_native('pause', true)
+ self:set_from_cursor()
+end
+function Timeline:on_prop_duration() self:decide_enabled() end
+function Timeline:on_prop_time() self:decide_enabled() end
+function Timeline:on_prop_border() self:update_dimensions() end
+function Timeline:on_prop_title_bar() self:update_dimensions() end
+function Timeline:on_prop_fullormaxed()
+ self:decide_progress_size()
+ self:update_dimensions()
+end
+function Timeline:on_display() self:update_dimensions() end
+function Timeline:on_options()
+ self:decide_progress_size()
+ self:update_dimensions()
+end
+function Timeline:handle_cursor_up()
+ if self.pressed then
+ mp.set_property_native('pause', self.pressed.pause)
+ self.pressed = false
+ end
+end
+function Timeline:on_global_mouse_leave()
+ self.pressed = false
+end
+
+function Timeline:on_global_mouse_move()
+ if self.pressed then
+ self.pressed.distance = self.pressed.distance + get_point_to_point_proximity(self.pressed.last, cursor)
+ self.pressed.last.x, self.pressed.last.y = cursor.x, cursor.y
+ if state.is_video and math.abs(cursor:get_velocity().x) / self.width * state.duration > 30 then
+ self:set_from_cursor(true)
+ else
+ self:set_from_cursor()
+ end
+ end
+end
+
+function Timeline:render()
+ if self.size == 0 then return end
+
+ local size = self:get_effective_size()
+ local visibility = self:get_visibility()
+ self.is_hovered = false
+
+ if size < 1 then
+ if self.has_thumbnail then self:clear_thumbnail() end
+ return
+ end
+
+ if self.proximity_raw == 0 then
+ self.is_hovered = true
+ end
+ if visibility > 0 then
+ cursor:zone('primary_down', self, function()
+ self:handle_cursor_down()
+ cursor:once('primary_up', function() self:handle_cursor_up() end)
+ end)
+ if config.timeline_step ~= 0 then
+ cursor:zone('wheel_down', self, function()
+ mp.commandv('seek', -config.timeline_step, config.timeline_step_flag)
+ end)
+ cursor:zone('wheel_up', self, function()
+ mp.commandv('seek', config.timeline_step, config.timeline_step_flag)
+ end)
+ end
+ end
+
+ local ass = assdraw.ass_new()
+ local progress_size = math.max(self.min_progress_size, self.progress_size)
+
+ -- Text opacity rapidly drops to 0 just before it starts overflowing, or before it reaches progress_size
+ local hide_text_below = math.max(self.font_size * 0.8, progress_size * 2)
+ local hide_text_ramp = hide_text_below / 2
+ local text_opacity = clamp(0, size - hide_text_below, hide_text_ramp) / hide_text_ramp
+
+ local tooltip_gap = round(2 * state.scale)
+ local timestamp_gap = tooltip_gap
+
+ local spacing = math.max(math.floor((self.size - self.font_size) / 2.5), 4)
+ local progress = state.time / state.duration
+ local is_line = options.timeline_style == 'line'
+
+ -- Foreground & Background bar coordinates
+ local bax, bay, bbx, bby = self.ax, self.by - size - self.top_border, self.bx, self.by
+ local fax, fay, fbx, fby = 0, bay + self.top_border, 0, bby
+ local fcy = fay + (size / 2)
+
+ local line_width = 0
+
+ if is_line then
+ local minimized_fraction = 1 - math.min((size - progress_size) / ((self.size - progress_size) / 8), 1)
+ local progress_delta = progress_size > 0 and self.progress_line_width - self.line_width or 0
+ line_width = self.line_width + (progress_delta * minimized_fraction)
+ fax = bax + (self.width - line_width) * progress
+ fbx = fax + line_width
+ line_width = line_width - 1
+ else
+ fax, fbx = bax, bax + self.width * progress
+ end
+
+ local foreground_size = fby - fay
+ local foreground_coordinates = round(fax) .. ',' .. fay .. ',' .. round(fbx) .. ',' .. fby -- for clipping
+
+ -- time starts 0.5 pixels in
+ local time_ax = bax + 0.5
+ local time_width = self.width - line_width - 1
+
+ -- time to x: calculates x coordinate so that it never lies inside of the line
+ local function t2x(time)
+ local x = time_ax + time_width * time / state.duration
+ return time <= state.time and x or x + line_width
+ end
+
+ -- Background
+ ass:new_event()
+ ass:pos(0, 0)
+ ass:append('{\\rDefault\\an7\\blur0\\bord0\\1c&H' .. bg .. '}')
+ ass:opacity(config.opacity.timeline)
+ ass:draw_start()
+ ass:rect_cw(bax, bay, fax, bby) --left of progress
+ ass:rect_cw(fbx, bay, bbx, bby) --right of progress
+ ass:rect_cw(fax, bay, fbx, fay) --above progress
+ ass:draw_stop()
+
+ -- Progress
+ ass:rect(fax, fay, fbx, fby, {opacity = config.opacity.position})
+
+ -- Uncached ranges
+ if state.uncached_ranges then
+ local opts = {size = 80, anchor_y = fby}
+ local texture_char = visibility > 0 and 'b' or 'a'
+ local offset = opts.size / (visibility > 0 and 24 or 28)
+ for _, range in ipairs(state.uncached_ranges) do
+ if options.timeline_cache then
+ local ax = range[1] < 0.5 and bax or math.floor(t2x(range[1]))
+ local bx = range[2] > state.duration - 0.5 and bbx or math.ceil(t2x(range[2]))
+ opts.color, opts.opacity, opts.anchor_x = 'ffffff', 0.4 - (0.2 * visibility), bax
+ ass:texture(ax, fay, bx, fby, texture_char, opts)
+ opts.color, opts.opacity, opts.anchor_x = '000000', 0.6 - (0.2 * visibility), bax + offset
+ ass:texture(ax, fay, bx, fby, texture_char, opts)
+ end
+ end
+ end
+
+ -- Custom ranges
+ for _, chapter_range in ipairs(state.chapter_ranges) do
+ local rax = chapter_range.start < 0.1 and bax or t2x(chapter_range.start)
+ local rbx = chapter_range['end'] > state.duration - 0.1 and bbx
+ or t2x(math.min(chapter_range['end'], state.duration))
+ ass:rect(rax, fay, rbx, fby, {color = chapter_range.color, opacity = chapter_range.opacity})
+ end
+
+ -- Chapters
+ local hovered_chapter = nil
+ if (config.opacity.chapters > 0 and (#state.chapters > 0 or state.ab_loop_a or state.ab_loop_b)) then
+ local diamond_radius = math.min(math.max(1, foreground_size * 0.8), self.chapter_size)
+ local diamond_radius_hovered = diamond_radius * 2
+ local diamond_border = options.timeline_border and math.max(options.timeline_border, 1) or 1
+
+ if diamond_radius > 0 then
+ local function draw_chapter(time, radius)
+ local chapter_x, chapter_y = t2x(time), fay - 1
+ ass:new_event()
+ ass:append(string.format(
+ '{\\pos(0,0)\\rDefault\\an7\\blur0\\yshad0.01\\bord%f\\1c&H%s\\3c&H%s\\4c&H%s\\1a&H%X&\\3a&H00&\\4a&H00&}',
+ diamond_border, fg, bg, bg, opacity_to_alpha(config.opacity.chapters)
+ ))
+ ass:draw_start()
+ ass:move_to(chapter_x - radius, chapter_y)
+ ass:line_to(chapter_x, chapter_y - radius)
+ ass:line_to(chapter_x + radius, chapter_y)
+ ass:line_to(chapter_x, chapter_y + radius)
+ ass:draw_stop()
+ end
+
+ if #state.chapters > 0 then
+ -- Find hovered chapter indicator
+ local closest_delta = math.huge
+
+ if self.proximity_raw < diamond_radius_hovered then
+ for i, chapter in ipairs(state.chapters) do
+ local chapter_x, chapter_y = t2x(chapter.time), fay - 1
+ local cursor_chapter_delta = math.sqrt((cursor.x - chapter_x) ^ 2 + (cursor.y - chapter_y) ^ 2)
+ if cursor_chapter_delta <= diamond_radius_hovered and cursor_chapter_delta < closest_delta then
+ hovered_chapter, closest_delta = chapter, cursor_chapter_delta
+ self.is_hovered = true
+ end
+ end
+ end
+
+ for i, chapter in ipairs(state.chapters) do
+ if chapter ~= hovered_chapter then draw_chapter(chapter.time, diamond_radius) end
+ local circle = {point = {x = t2x(chapter.time), y = fay - 1}, r = diamond_radius_hovered}
+ if visibility > 0 then
+ cursor:zone('primary_click', circle, function()
+ mp.commandv('seek', chapter.time, 'absolute+exact')
+ end)
+ end
+ end
+
+ -- Render hovered chapter above others
+ if hovered_chapter then
+ draw_chapter(hovered_chapter.time, diamond_radius_hovered)
+ timestamp_gap = tooltip_gap + round(diamond_radius_hovered)
+ else
+ timestamp_gap = tooltip_gap + round(diamond_radius)
+ end
+ end
+
+ -- A-B loop indicators
+ local has_a, has_b = state.ab_loop_a and state.ab_loop_a >= 0, state.ab_loop_b and state.ab_loop_b > 0
+ local ab_radius = round(math.min(math.max(8, foreground_size * 0.25), foreground_size))
+
+ ---@param time number
+ ---@param kind 'a'|'b'
+ local function draw_ab_indicator(time, kind)
+ local x = t2x(time)
+ ass:new_event()
+ ass:append(string.format(
+ '{\\pos(0,0)\\rDefault\\an7\\blur0\\yshad0.01\\bord%f\\1c&H%s\\3c&H%s\\4c&H%s\\1a&H%X&\\3a&H00&\\4a&H00&}',
+ diamond_border, fg, bg, bg, opacity_to_alpha(config.opacity.chapters)
+ ))
+ ass:draw_start()
+ ass:move_to(x, fby - ab_radius)
+ if kind == 'b' then ass:line_to(x + 3, fby - ab_radius) end
+ ass:line_to(x + (kind == 'a' and 0 or ab_radius), fby)
+ ass:line_to(x - (kind == 'b' and 0 or ab_radius), fby)
+ if kind == 'a' then ass:line_to(x - 3, fby - ab_radius) end
+ ass:draw_stop()
+ end
+
+ if has_a then draw_ab_indicator(state.ab_loop_a, 'a') end
+ if has_b then draw_ab_indicator(state.ab_loop_b, 'b') end
+ end
+ end
+
+ local function draw_timeline_timestamp(x, y, align, timestamp, opts)
+ opts.color, opts.border_color = fgt, fg
+ opts.clip = '\\clip(' .. foreground_coordinates .. ')'
+ local func = options.time_precision > 0 and ass.timestamp or ass.txt
+ func(ass, x, y, align, timestamp, opts)
+ opts.color, opts.border_color = bgt, bg
+ opts.clip = '\\iclip(' .. foreground_coordinates .. ')'
+ func(ass, x, y, align, timestamp, opts)
+ end
+
+ -- Time values
+ if text_opacity > 0 then
+ local time_opts = {size = self.font_size, opacity = text_opacity, border = 2 * state.scale}
+ -- Upcoming cache time
+ local cache_duration = state.cache_duration and state.cache_duration / state.speed or nil
+ if cache_duration and options.buffered_time_threshold > 0
+ and cache_duration < options.buffered_time_threshold then
+ local margin = 5 * state.scale
+ local x, align = fbx + margin, 4
+ local cache_opts = {
+ size = self.font_size * 0.8, opacity = text_opacity * 0.6, border = options.text_border * state.scale,
+ }
+ local human = round(cache_duration) .. 's'
+ local width = text_width(human, cache_opts)
+ local time_width = timestamp_width(state.time_human, time_opts)
+ local time_width_end = timestamp_width(state.destination_time_human, time_opts)
+ local min_x, max_x = bax + spacing + margin + time_width, bbx - spacing - margin - time_width_end
+ if x < min_x then x = min_x elseif x + width > max_x then x, align = max_x, 6 end
+ draw_timeline_timestamp(x, fcy, align, human, cache_opts)
+ end
+
+ -- Elapsed time
+ if state.time_human then
+ draw_timeline_timestamp(bax + spacing, fcy, 4, state.time_human, time_opts)
+ end
+
+ -- End time
+ if state.destination_time_human then
+ draw_timeline_timestamp(bbx - spacing, fcy, 6, state.destination_time_human, time_opts)
+ end
+ end
+
+ -- Hovered time and chapter
+ local rendered_thumbnail = false
+ if (self.proximity_raw == 0 or self.pressed or hovered_chapter) and not Elements:v('speed', 'dragging') then
+ local cursor_x = hovered_chapter and t2x(hovered_chapter.time) or cursor.x
+ local hovered_seconds = hovered_chapter and hovered_chapter.time or self:get_time_at_x(cursor.x)
+
+ -- Cursor line
+ -- 0.5 to switch when the pixel is half filled in
+ local color = ((fax - 0.5) < cursor_x and cursor_x < (fbx + 0.5)) and bg or fg
+ local ax, ay, bx, by = cursor_x - 0.5, fay, cursor_x + 0.5, fby
+ ass:rect(ax, ay, bx, by, {color = color, opacity = 0.33})
+ local tooltip_anchor = {ax = ax, ay = ay - self.top_border, bx = bx, by = by}
+
+ -- Timestamp
+ local opts = {
+ size = self.font_size, offset = timestamp_gap, margin = tooltip_gap, timestamp = options.time_precision > 0,
+ }
+ local hovered_time_human = format_time(hovered_seconds, state.duration)
+ opts.width_overwrite = timestamp_width(hovered_time_human, opts)
+ tooltip_anchor = ass:tooltip(tooltip_anchor, hovered_time_human, opts)
+
+ -- Thumbnail
+ if not thumbnail.disabled
+ and (not self.pressed or self.pressed.distance < 5)
+ and thumbnail.width ~= 0
+ and thumbnail.height ~= 0
+ then
+ local border = math.ceil(math.max(2, state.radius / 2) * state.scale)
+ local thumb_x_margin, thumb_y_margin = border + tooltip_gap + bax, border + tooltip_gap
+ local thumb_width, thumb_height = thumbnail.width, thumbnail.height
+ local thumb_x = round(clamp(
+ thumb_x_margin,
+ cursor_x - thumb_width / 2,
+ display.width - thumb_width - thumb_x_margin
+ ))
+ local thumb_y = round(tooltip_anchor.ay - thumb_y_margin - thumb_height)
+ local ax, ay = (thumb_x - border), (thumb_y - border)
+ local bx, by = (thumb_x + thumb_width + border), (thumb_y + thumb_height + border)
+ ass:rect(ax, ay, bx, by, {
+ color = bg,
+ border = 1,
+ opacity = {main = config.opacity.thumbnail, border = 0.08 * config.opacity.thumbnail},
+ border_color = fg,
+ radius = state.radius,
+ })
+ local thumb_seconds = (state.rebase_start_time == false and state.start_time) and (hovered_seconds - state.start_time) or hovered_seconds
+ mp.commandv('script-message-to', 'thumbfast', 'thumb', thumb_seconds, thumb_x, thumb_y)
+ self.has_thumbnail, rendered_thumbnail = true, true
+ tooltip_anchor.ay = ay
+ end
+
+ -- Chapter title
+ if config.opacity.chapters > 0 and #state.chapters > 0 then
+ local _, chapter = itable_find(state.chapters, function(c) return hovered_seconds >= c.time end,
+ #state.chapters, 1)
+ if chapter and not chapter.is_end_only then
+ ass:tooltip(tooltip_anchor, chapter.title_wrapped, {
+ size = self.font_size,
+ offset = tooltip_gap,
+ responsive = false,
+ bold = true,
+ width_overwrite = chapter.title_wrapped_width * self.font_size,
+ lines = chapter.title_lines,
+ margin = tooltip_gap,
+ })
+ end
+ end
+ end
+
+ -- Clear thumbnail
+ if not rendered_thumbnail and self.has_thumbnail then self:clear_thumbnail() end
+
+ return ass
+end
+
+return Timeline
diff --git a/dotfiles/.config/mpv/scripts/uosc/elements/TopBar.lua b/dotfiles/.config/mpv/scripts/uosc/elements/TopBar.lua
new file mode 100644
index 0000000..32466a1
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/elements/TopBar.lua
@@ -0,0 +1,335 @@
+local Element = require('elements/Element')
+
+---@alias TopBarButtonProps {icon: string; hover_fg?: string; hover_bg?: string; command: (fun():string)}
+
+---@class TopBar : Element
+local TopBar = class(Element)
+
+function TopBar:new() return Class.new(self) --[[@as TopBar]] end
+function TopBar:init()
+ Element.init(self, 'top_bar', {render_order = 4})
+ self.size = 0
+ self.icon_size, self.font_size, self.title_by = 1, 1, 1
+ self.show_alt_title = false
+ self.main_title, self.alt_title = nil, nil
+
+ local function maximized_command()
+ if state.platform == 'windows' then
+ mp.command(state.border
+ and (state.fullscreen and 'set fullscreen no;cycle window-maximized' or 'cycle window-maximized')
+ or 'set window-maximized no;cycle fullscreen')
+ else
+ mp.command(state.fullormaxed and 'set fullscreen no;set window-maximized no' or 'set window-maximized yes')
+ end
+ end
+
+ local close = {icon = 'close', hover_bg = '2311e8', hover_fg = 'ffffff', command = function() mp.command('quit') end}
+ local max = {icon = 'crop_square', command = maximized_command}
+ local min = {icon = 'minimize', command = function() mp.command('cycle window-minimized') end}
+ self.buttons = options.top_bar_controls == 'left' and {close, max, min} or {min, max, close}
+
+ self:decide_titles()
+ self:decide_enabled()
+ self:update_dimensions()
+end
+
+function TopBar:decide_enabled()
+ if options.top_bar == 'no-border' then
+ self.enabled = not state.border or state.title_bar == false or state.fullscreen
+ else
+ self.enabled = options.top_bar == 'always'
+ end
+ self.enabled = self.enabled and (options.top_bar_controls or options.top_bar_title ~= 'no' or state.has_playlist)
+end
+
+function TopBar:decide_titles()
+ self.alt_title = state.alt_title ~= '' and state.alt_title or nil
+ self.main_title = state.title ~= '' and state.title or nil
+
+ if (self.main_title == 'No file') then
+ self.main_title = t('No file')
+ end
+
+ -- Fall back to alt title if main is empty
+ if not self.main_title then
+ self.main_title, self.alt_title = self.alt_title, nil
+ end
+
+ -- Deduplicate the main and alt titles by checking if one completely
+ -- contains the other, and using only the longer one.
+ if self.main_title and self.alt_title and not self.show_alt_title then
+ local longer_title, shorter_title
+ if #self.main_title < #self.alt_title then
+ longer_title, shorter_title = self.alt_title, self.main_title
+ else
+ longer_title, shorter_title = self.main_title, self.alt_title
+ end
+
+ local escaped_shorter_title = regexp_escape(shorter_title --[[@as string]])
+ if string.match(longer_title --[[@as string]], escaped_shorter_title) then
+ self.main_title, self.alt_title = longer_title, nil
+ end
+ end
+end
+
+function TopBar:update_dimensions()
+ self.size = round(options.top_bar_size * state.scale)
+ self.icon_size = round(self.size * 0.5)
+ self.font_size = math.floor((self.size - (math.ceil(self.size * 0.25) * 2)) * options.font_scale)
+ local window_border_size = Elements:v('window_border', 'size', 0)
+ self.ax = window_border_size
+ self.ay = window_border_size
+ self.bx = display.width - window_border_size
+ self.by = self.size + window_border_size
+end
+
+function TopBar:toggle_title()
+ if options.top_bar_alt_title_place ~= 'toggle' then return end
+ self.show_alt_title = not self.show_alt_title
+ request_render()
+end
+
+function TopBar:on_prop_title() self:decide_titles() end
+function TopBar:on_prop_alt_title() self:decide_titles() end
+
+function TopBar:on_prop_border()
+ self:decide_enabled()
+ self:update_dimensions()
+end
+
+function TopBar:on_prop_title_bar()
+ self:decide_enabled()
+ self:update_dimensions()
+end
+
+function TopBar:on_prop_fullscreen()
+ self:decide_enabled()
+ self:update_dimensions()
+end
+
+function TopBar:on_prop_maximized()
+ self:decide_enabled()
+ self:update_dimensions()
+end
+
+function TopBar:on_prop_has_playlist()
+ self:decide_enabled()
+ self:update_dimensions()
+end
+
+function TopBar:on_display() self:update_dimensions() end
+
+function TopBar:on_options()
+ self:decide_enabled()
+ self:update_dimensions()
+end
+
+function TopBar:render()
+ local visibility = self:get_visibility()
+ if visibility <= 0 then return end
+ local ass = assdraw.ass_new()
+ local ax, bx = self.ax, self.bx
+ local margin = math.floor((self.size - self.font_size) / 4)
+
+ -- Window controls
+ if options.top_bar_controls then
+ local is_left, button_ax = options.top_bar_controls == 'left', 0
+ if is_left then
+ button_ax = ax
+ ax = self.size * #self.buttons
+ else
+ button_ax = bx - self.size * #self.buttons
+ bx = button_ax
+ end
+
+ for _, button in ipairs(self.buttons) do
+ local rect = {ax = button_ax, ay = self.ay, bx = button_ax + self.size, by = self.by}
+ local is_hover = get_point_to_rectangle_proximity(cursor, rect) == 0
+ local opacity = is_hover and 1 or config.opacity.controls
+ local button_fg = is_hover and (button.hover_fg or bg) or fg
+ local button_bg = is_hover and (button.hover_bg or fg) or bg
+
+ cursor:zone('primary_click', rect, button.command)
+
+ local bg_size = self.size - margin
+ local bg_ax, bg_ay = rect.ax + (is_left and margin or 0), rect.ay + margin
+ local bg_bx, bg_by = bg_ax + bg_size, bg_ay + bg_size
+
+ ass:rect(bg_ax, bg_ay, bg_bx, bg_by, {
+ color = button_bg, opacity = visibility * opacity, radius = state.radius,
+ })
+
+ ass:icon(bg_ax + bg_size / 2, bg_ay + bg_size / 2, bg_size * 0.5, button.icon, {
+ color = button_fg,
+ border_color = button_bg,
+ opacity = visibility,
+ border = options.text_border * state.scale,
+ })
+
+ button_ax = button_ax + self.size
+ end
+ end
+
+ -- Window title
+ if state.title or state.has_playlist then
+ local padding = self.font_size / 2
+ local spacing = 1
+ local left_aligned = options.top_bar_controls == 'left'
+ local title_ax, title_bx, title_ay = ax + margin, bx - margin, self.ay + margin
+
+ -- Playlist position
+ if state.has_playlist then
+ local text = state.playlist_pos .. '' .. state.playlist_count
+ local formatted_text = '{\\b1}' .. state.playlist_pos .. '{\\b0\\fs' .. self.font_size * 0.9 .. '}/'
+ .. state.playlist_count
+ local opts = {size = self.font_size, wrap = 2, color = fgt, opacity = visibility}
+ local rect_width = round(text_width(text, opts) + padding * 2)
+ local ax = left_aligned and title_bx - rect_width or title_ax
+ local rect = {
+ ax = ax,
+ ay = title_ay,
+ bx = ax + rect_width,
+ by = self.by - margin,
+ }
+ local opacity = get_point_to_rectangle_proximity(cursor, rect) == 0
+ and 1 or config.opacity.playlist_position
+ if opacity > 0 then
+ ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
+ color = fg, opacity = visibility * opacity, radius = state.radius,
+ })
+ end
+ ass:txt(rect.ax + (rect.bx - rect.ax) / 2, rect.ay + (rect.by - rect.ay) / 2, 5, formatted_text, opts)
+ if left_aligned then title_bx = rect.ax - margin else title_ax = rect.bx + margin end
+
+ -- Click action
+ cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/playlist') end)
+ end
+
+ -- Skip rendering titles if there's not enough horizontal space
+ if title_bx - title_ax > self.font_size * 3 and options.top_bar_title ~= 'no' then
+ -- Main title
+ local main_title = self.show_alt_title and self.alt_title or self.main_title
+ if main_title then
+ local opts = {
+ size = self.font_size,
+ wrap = 2,
+ color = bgt,
+ opacity = visibility,
+ border = options.text_border * state.scale,
+ border_color = bg,
+ clip = string.format('\\clip(%d, %d, %d, %d)', self.ax, self.ay, title_bx, self.by),
+ }
+ local rect_ideal_width = round(text_width(main_title, opts) + padding * 2)
+ local rect_width = math.min(rect_ideal_width, title_bx - title_ax)
+ local ax = left_aligned and title_bx - rect_width or title_ax
+ local by = self.by - margin
+ local title_rect = {ax = ax, ay = title_ay, bx = ax + rect_width, by = by}
+
+ if options.top_bar_alt_title_place == 'toggle' then
+ cursor:zone('primary_click', title_rect, function() self:toggle_title() end)
+ end
+
+ ass:rect(title_rect.ax, title_rect.ay, title_rect.bx, title_rect.by, {
+ color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
+ })
+ local align = left_aligned and rect_ideal_width == rect_width and 6 or 4
+ local x = align == 6 and title_rect.bx - padding or ax + padding
+ ass:txt(x, self.ay + (self.size / 2), align, main_title, opts)
+ title_ay = by + spacing
+ end
+
+ -- Alt title
+ if self.alt_title and options.top_bar_alt_title_place == 'below' then
+ local font_size = self.font_size * 0.9
+ local height = font_size * 1.3
+ local by = title_ay + height
+ local opts = {
+ size = font_size,
+ wrap = 2,
+ color = bgt,
+ border = options.text_border * state.scale,
+ border_color = bg,
+ opacity = visibility,
+ }
+ local rect_ideal_width = round(text_width(self.alt_title, opts) + padding * 2)
+ local rect_width = math.min(rect_ideal_width, title_bx - title_ax)
+ local ax = left_aligned and title_bx - rect_width or title_ax
+ local bx = ax + rect_width
+ opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, bx, by)
+ ass:rect(ax, title_ay, bx, by, {
+ color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
+ })
+ local align = left_aligned and rect_ideal_width == rect_width and 6 or 4
+ local x = align == 6 and bx - padding or ax + padding
+ ass:txt(x, title_ay + height / 2, align, self.alt_title, opts)
+ title_ay = by + spacing
+ end
+
+ -- Current chapter
+ if state.current_chapter then
+ local padding_half = round(padding / 2)
+ local font_size = self.font_size * 0.8
+ local height = font_size * 1.3
+ local prefix, postfix = left_aligned and '' or '└ ', left_aligned and ' ┘' or ''
+ local text = prefix .. state.current_chapter.index .. ': ' .. state.current_chapter.title .. postfix
+ local next_chapter = state.chapters[state.current_chapter.index + 1]
+ local chapter_end = next_chapter and next_chapter.time or state.duration or 0
+ local remaining_time = ((state.time or 0) - chapter_end) /
+ (options.destination_time == 'time-remaining' and 1 or state.speed)
+ local remaining_human = format_time(remaining_time, math.abs(remaining_time))
+ local opts = {
+ size = font_size,
+ italic = true,
+ wrap = 2,
+ color = bgt,
+ border = options.text_border * state.scale,
+ border_color = bg,
+ opacity = visibility * 0.8,
+ }
+ local remaining_width = timestamp_width(remaining_human, opts)
+ local remaining_box_width = remaining_width + padding_half * 2
+
+ -- Title
+ local max_bx = title_bx - remaining_box_width - spacing
+ local rect_ideal_width = round(text_width(text, opts) + padding * 2)
+ local rect_width = math.min(rect_ideal_width, max_bx - title_ax)
+ local ax = left_aligned and title_bx - rect_width or title_ax
+ local rect = {
+ ax = ax,
+ ay = title_ay,
+ bx = ax + rect_width,
+ by = title_ay + height,
+ }
+ opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, rect.bx, rect.by)
+ ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
+ color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
+ })
+ local align = left_aligned and rect_ideal_width == rect_width and 6 or 4
+ local x = align == 6 and rect.bx - padding or rect.ax + padding
+ ass:txt(x, rect.ay + height / 2, align, text, opts)
+
+ -- Time
+ local time_ax = left_aligned and rect.ax - spacing - remaining_box_width or rect.bx + spacing
+ local time_bx = time_ax + remaining_box_width
+ opts.clip = nil
+ ass:rect(time_ax, rect.ay, time_bx, rect.by, {
+ color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
+ })
+ ass:txt(time_ax + padding_half, rect.ay + height / 2, 4, remaining_human, opts)
+
+ -- Click action
+ rect.bx = time_bx
+ cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/chapters') end)
+
+ title_ay = rect.by + spacing
+ end
+ end
+ self.title_by = title_ay - 1
+ else
+ self.title_by = self.ay
+ end
+
+ return ass
+end
+
+return TopBar
diff --git a/dotfiles/.config/mpv/scripts/uosc/elements/Volume.lua b/dotfiles/.config/mpv/scripts/uosc/elements/Volume.lua
new file mode 100644
index 0000000..0387019
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/elements/Volume.lua
@@ -0,0 +1,282 @@
+local Element = require('elements/Element')
+
+--[[ VolumeSlider ]]
+
+---@class VolumeSlider : Element
+local VolumeSlider = class(Element)
+---@param props? ElementProps
+function VolumeSlider:new(props) return Class.new(self, props) --[[@as VolumeSlider]] end
+function VolumeSlider:init(props)
+ Element.init(self, 'volume_slider', props)
+ self.pressed = false
+ self.nudge_y = 0 -- vertical position where volume overflows 100
+ self.nudge_size = 0
+ self.draw_nudge = false
+ self.spacing = 0
+ self.border_size = 0
+ self:update_dimensions()
+end
+
+function VolumeSlider:update_dimensions()
+ self.border_size = math.max(0, round(options.volume_border * state.scale))
+end
+
+function VolumeSlider:get_visibility() return Elements.volume:get_visibility(self) end
+
+function VolumeSlider:set_volume(volume)
+ volume = round(volume / options.volume_step) * options.volume_step
+ if state.volume == volume then return end
+ mp.commandv('set', 'volume', clamp(0, volume, state.volume_max))
+end
+
+function VolumeSlider:set_from_cursor()
+ local volume_fraction = (self.by - cursor.y - self.border_size) / (self.by - self.ay - self.border_size)
+ self:set_volume(volume_fraction * state.volume_max)
+end
+
+function VolumeSlider:on_display() self:update_dimensions() end
+function VolumeSlider:on_options() self:update_dimensions() end
+function VolumeSlider:on_coordinates()
+ if type(state.volume_max) ~= 'number' or state.volume_max <= 0 then return end
+ local width = self.bx - self.ax
+ self.nudge_y = self.by - round((self.by - self.ay) * (100 / state.volume_max))
+ self.nudge_size = round(width * 0.18)
+ self.draw_nudge = self.ay < self.nudge_y
+ self.spacing = round(width * 0.2)
+end
+function VolumeSlider:on_global_mouse_move()
+ if self.pressed then self:set_from_cursor() end
+end
+function VolumeSlider:handle_wheel_up() self:set_volume(state.volume + options.volume_step) end
+function VolumeSlider:handle_wheel_down() self:set_volume(state.volume - options.volume_step) end
+
+function VolumeSlider:render()
+ local visibility = self:get_visibility()
+ local ax, ay, bx, by = self.ax, self.ay, self.bx, self.by
+ local width, height = bx - ax, by - ay
+
+ if width <= 0 or height <= 0 or visibility <= 0 then return end
+
+ cursor:zone('primary_down', self, function()
+ self.pressed = true
+ self:set_from_cursor()
+ cursor:once('primary_up', function() self.pressed = false end)
+ end)
+ cursor:zone('wheel_down', self, function() self:handle_wheel_down() end)
+ cursor:zone('wheel_up', self, function() self:handle_wheel_up() end)
+
+ local ass = assdraw.ass_new()
+ local nudge_y, nudge_size = self.draw_nudge and self.nudge_y or -math.huge, self.nudge_size
+ local volume_y = self.ay + self.border_size +
+ ((height - (self.border_size * 2)) * (1 - math.min(state.volume / state.volume_max, 1)))
+
+ -- Draws a rectangle with nudge at requested position
+ ---@param p number Padding from slider edges.
+ ---@param r number Border radius.
+ ---@param cy? number A y coordinate where to clip the path from the bottom.
+ function create_nudged_path(p, r, cy)
+ cy = cy or ay + p
+ local ax, bx, by = ax + p, bx - p, by - p
+ local d, rh = r * 2, r / 2
+ local nudge_size = ((QUARTER_PI_SIN * (nudge_size - p)) + p) / QUARTER_PI_SIN
+ local path = assdraw.ass_new()
+ path:move_to(bx - r, by)
+ path:line_to(ax + r, by)
+ if cy > by - d then
+ local subtracted_radius = (d - (cy - (by - d))) / 2
+ local xbd = (r - subtracted_radius * 1.35) -- x bezier delta
+ path:bezier_curve(ax + xbd, by, ax + xbd, cy, ax + r, cy)
+ path:line_to(bx - r, cy)
+ path:bezier_curve(bx - xbd, cy, bx - xbd, by, bx - r, by)
+ else
+ path:bezier_curve(ax + rh, by, ax, by - rh, ax, by - r)
+ local nudge_bottom_y = nudge_y + nudge_size
+
+ if cy + rh <= nudge_bottom_y then
+ path:line_to(ax, nudge_bottom_y)
+ if cy <= nudge_y then
+ path:line_to((ax + nudge_size), nudge_y)
+ local nudge_top_y = nudge_y - nudge_size
+ if cy <= nudge_top_y then
+ local r, rh = r, rh
+ if cy > nudge_top_y - r then
+ r = nudge_top_y - cy
+ rh = r / 2
+ end
+ path:line_to(ax, nudge_top_y)
+ path:line_to(ax, cy + r)
+ path:bezier_curve(ax, cy + rh, ax + rh, cy, ax + r, cy)
+ path:line_to(bx - r, cy)
+ path:bezier_curve(bx - rh, cy, bx, cy + rh, bx, cy + r)
+ path:line_to(bx, nudge_top_y)
+ else
+ local triangle_side = cy - nudge_top_y
+ path:line_to((ax + triangle_side), cy)
+ path:line_to((bx - triangle_side), cy)
+ end
+ path:line_to((bx - nudge_size), nudge_y)
+ else
+ local triangle_side = nudge_bottom_y - cy
+ path:line_to((ax + triangle_side), cy)
+ path:line_to((bx - triangle_side), cy)
+ end
+ path:line_to(bx, nudge_bottom_y)
+ else
+ path:line_to(ax, cy + r)
+ path:bezier_curve(ax, cy + rh, ax + rh, cy, ax + r, cy)
+ path:line_to(bx - r, cy)
+ path:bezier_curve(bx - rh, cy, bx, cy + rh, bx, cy + r)
+ end
+ path:line_to(bx, by - r)
+ path:bezier_curve(bx, by - rh, bx - rh, by, bx - r, by)
+ end
+ return path
+ end
+
+ -- BG & FG paths
+ local bg_path = create_nudged_path(0, state.radius + self.border_size)
+ local fg_path = create_nudged_path(self.border_size, state.radius, volume_y)
+
+ -- Background
+ ass:new_event()
+ ass:append('{\\rDefault\\an7\\blur0\\bord0\\1c&H' .. bg ..
+ '\\iclip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')}')
+ ass:opacity(config.opacity.slider, visibility)
+ ass:pos(0, 0)
+ ass:draw_start()
+ ass:append(bg_path.text)
+ ass:draw_stop()
+
+ -- Foreground
+ ass:new_event()
+ ass:append('{\\rDefault\\an7\\blur0\\bord0\\1c&H' .. fg .. '}')
+ ass:opacity(config.opacity.slider_gauge, visibility)
+ ass:pos(0, 0)
+ ass:draw_start()
+ ass:append(fg_path.text)
+ ass:draw_stop()
+
+ -- Current volume value
+ local volume_string = tostring(round(state.volume * 10) / 10)
+ local font_size = round(((width * 0.6) - (#volume_string * (width / 20))) * options.font_scale)
+ if volume_y < self.by - self.spacing then
+ ass:txt(self.ax + (width / 2), self.by - self.spacing, 2, volume_string, {
+ size = font_size,
+ color = fgt,
+ opacity = visibility,
+ clip = '\\clip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')',
+ })
+ end
+ if volume_y > self.by - self.spacing - font_size then
+ ass:txt(self.ax + (width / 2), self.by - self.spacing, 2, volume_string, {
+ size = font_size,
+ color = bgt,
+ opacity = visibility,
+ clip = '\\iclip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')',
+ })
+ end
+
+ -- Disabled stripes for no audio
+ if not state.has_audio then
+ local fg_100_path = create_nudged_path(self.border_size, state.radius)
+ local texture_opts = {
+ size = 200,
+ color = 'ffffff',
+ opacity = visibility * 0.1,
+ anchor_x = ax,
+ clip = '\\clip(' .. fg_100_path.scale .. ',' .. fg_100_path.text .. ')',
+ }
+ ass:texture(ax, ay, bx, by, 'a', texture_opts)
+ texture_opts.color = '000000'
+ texture_opts.anchor_x = ax + texture_opts.size / 28
+ ass:texture(ax, ay, bx, by, 'a', texture_opts)
+ end
+
+ return ass
+end
+
+--[[ Volume ]]
+
+---@class Volume : Element
+local Volume = class(Element)
+
+function Volume:new() return Class.new(self) --[[@as Volume]] end
+function Volume:init()
+ Element.init(self, 'volume', {render_order = 7})
+ self.size = 0
+ self.mute_ay = 0
+ self.slider = VolumeSlider:new({anchor_id = 'volume', render_order = self.render_order})
+ self:update_dimensions()
+end
+
+function Volume:destroy()
+ self.slider:destroy()
+ Element.destroy(self)
+end
+
+function Volume:get_visibility()
+ return self.slider.pressed and 1 or Elements:maybe('timeline', 'get_is_hovered') and -1
+ or Element.get_visibility(self)
+end
+
+function Volume:update_dimensions()
+ self.size = round(options.volume_size * state.scale)
+ local min_y = Elements:v('top_bar', 'by') or Elements:v('window_border', 'size', 0)
+ local max_y = Elements:v('controls', 'ay') or Elements:v('timeline', 'ay')
+ or display.height - Elements:v('window_border', 'size', 0)
+ local available_height = max_y - min_y
+ local max_height = available_height * 0.8
+ local height = round(math.min(self.size * 8, max_height))
+ self.enabled = height > self.size * 2 -- don't render if too small
+ local margin = (self.size / 2) + Elements:v('window_border', 'size', 0)
+ self.ax = round(options.volume == 'left' and margin or display.width - margin - self.size)
+ self.ay = min_y + round((available_height - height) / 2)
+ self.bx = round(self.ax + self.size)
+ self.by = round(self.ay + height)
+ self.mute_ay = self.by - self.size
+ self.slider.enabled = self.enabled
+ self.slider:set_coordinates(self.ax, self.ay, self.bx, self.mute_ay)
+end
+
+function Volume:on_display() self:update_dimensions() end
+function Volume:on_prop_border() self:update_dimensions() end
+function Volume:on_prop_title_bar() self:update_dimensions() end
+function Volume:on_controls_reflow() self:update_dimensions() end
+function Volume:on_options() self:update_dimensions() end
+
+function Volume:render()
+ local visibility = self:get_visibility()
+ if visibility <= 0 then return end
+
+ -- Reset volume on secondary click
+ cursor:zone('secondary_click', self, function()
+ mp.set_property_native('mute', false)
+ mp.set_property_native('volume', 100)
+ end)
+
+ -- Mute button
+ local mute_rect = {ax = self.ax, ay = self.mute_ay, bx = self.bx, by = self.by}
+ cursor:zone('primary_click', mute_rect, function() mp.commandv('cycle', 'mute') end)
+ local ass = assdraw.ass_new()
+ local width_half = (mute_rect.bx - mute_rect.ax) / 2
+ local height_half = (mute_rect.by - mute_rect.ay) / 2
+ local icon_size = math.min(width_half, height_half) * 1.5
+ local icon_name, horizontal_shift = 'volume_up', 0
+ if state.mute then
+ icon_name = 'volume_off'
+ elseif state.volume <= 0 then
+ icon_name, horizontal_shift = 'volume_mute', height_half * 0.25
+ elseif state.volume <= 60 then
+ icon_name, horizontal_shift = 'volume_down', height_half * 0.125
+ end
+ local underlay_opacity = {main = visibility * 0.3, border = visibility}
+ ass:icon(mute_rect.ax + width_half, mute_rect.ay + height_half, icon_size, 'volume_up',
+ {border = options.text_border * state.scale, opacity = underlay_opacity, align = 5}
+ )
+ ass:icon(mute_rect.ax + width_half - horizontal_shift, mute_rect.ay + height_half, icon_size, icon_name,
+ {opacity = visibility, align = 5}
+ )
+ return ass
+end
+
+return Volume
diff --git a/dotfiles/.config/mpv/scripts/uosc/elements/WindowBorder.lua b/dotfiles/.config/mpv/scripts/uosc/elements/WindowBorder.lua
new file mode 100644
index 0000000..c872627
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/elements/WindowBorder.lua
@@ -0,0 +1,35 @@
+local Element = require('elements/Element')
+
+---@class WindowBorder : Element
+local WindowBorder = class(Element)
+
+function WindowBorder:new() return Class.new(self) --[[@as WindowBorder]] end
+function WindowBorder:init()
+ Element.init(self, 'window_border', {render_order = 9999})
+ self.size = 0
+ self:decide_enabled()
+end
+
+function WindowBorder:decide_enabled()
+ self.enabled = options.window_border_size > 0 and not state.fullormaxed and not state.border
+ self.size = self.enabled and round(options.window_border_size * state.scale) or 0
+end
+
+function WindowBorder:on_prop_border() self:decide_enabled() end
+function WindowBorder:on_prop_title_bar() self:decide_enabled() end
+function WindowBorder:on_prop_fullormaxed() self:decide_enabled() end
+function WindowBorder:on_options() self:decide_enabled() end
+
+function WindowBorder:render()
+ if self.size > 0 then
+ local ass = assdraw.ass_new()
+ local clip = '\\iclip(' .. self.size .. ',' .. self.size .. ',' ..
+ (display.width - self.size) .. ',' .. (display.height - self.size) .. ')'
+ ass:rect(0, 0, display.width + 1, display.height + 1, {
+ color = bg, clip = clip, opacity = config.opacity.border,
+ })
+ return ass
+ end
+end
+
+return WindowBorder
diff --git a/dotfiles/.config/mpv/scripts/uosc/intl/de.json b/dotfiles/.config/mpv/scripts/uosc/intl/de.json
new file mode 100644
index 0000000..4e488c1
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/intl/de.json
@@ -0,0 +1,83 @@
+{
+ "%s are empty": "%s sind leer",
+ "%s channel": "%s Kanal",
+ "%s channels": "%s Kanäle",
+ "%s to search": "%s um zu suchen",
+ "An error has occurred.": "Ein Fehler ist aufgetreten.",
+ "Aspect ratio": "Seitenverhältnis",
+ "Audio": "Audio",
+ "Audio device": "Audiogerät",
+ "Audio devices": "Audiogeräte",
+ "Audio tracks": "Audiospuren",
+ "Chapter %s": "Kapitel %s",
+ "Chapters": "Kapitel",
+ "Default": "Standard",
+ "Default %s": "Standard %s",
+ "Delete file & Next": "Lösche Datei & Nächstes",
+ "Delete file & Prev": "Lösche Datei & Vorheriges",
+ "Delete file & Quit": "Lösche Datei & Beenden",
+ "Disabled": "Deaktiviert",
+ "Download": "Herunterladen",
+ "Drives": "Laufwerke",
+ "Drop files or URLs to play here": "Dateien oder URLs zum Abspielen hier ablegen",
+ "Edition %s": "Edition %s",
+ "Editions": "Editionen",
+ "Empty": "Leer",
+ "First": "Erstes",
+ "Fullscreen": "Vollbild",
+ "Key bindings": "Tastenkürzel",
+ "Last": "Letztes",
+ "Load": "Hinzufügen",
+ "Load audio": "Audio hinzufügen",
+ "Load subtitles": "Untertitel hinzufügen",
+ "Load video": "Video hinzufügen",
+ "Loop file": "Datei wiederholen",
+ "Loop playlist": "Wiedergabeliste wiederholen",
+ "Menu": "Menü",
+ "Navigation": "Navigation",
+ "Next": "Nächstes",
+ "Next page": "Nächste Seite",
+ "No file": "Keine Datei",
+ "Open config folder": "Konfigurationsordner öffnen",
+ "Open file": "Datei öffnen",
+ "Play/Pause": "Abspielen/Pause",
+ "Playlist": "Wiedergabeliste",
+ "Playlist/Files": "Wiedergabeliste/Dateien",
+ "Prev": "Vorheriges",
+ "Previous": "Vorheriges",
+ "Previous page": "Vorherige Seite",
+ "Quit": "Beenden",
+ "Remaining downloads today: %s": "Verbleibende Downloads heute: %s",
+ "Resets in: %s": "Zurückgesetzt in: %s",
+ "Screenshot": "Bildschirmfoto",
+ "See above for clues.": "Siehe oben für Hinweise.",
+ "Show in directory": "Im Verzeichnis anzeigen",
+ "Shuffle": "Zufällig",
+ "Stream quality": "Streamqualität",
+ "Subtitles": "Untertitel",
+ "Subtitles loaded & enabled": "Untertitel geladen & aktiviert",
+ "Track %s": "Spur %s",
+ "Update uosc": "Aktualisiere uosc",
+ "Updating uosc": "uosc wird aktualisiert",
+ "Utils": "Werkzeuge",
+ "Video": "Video",
+ "default": "Standard",
+ "drive": "Laufwerk",
+ "enter query": "Anfrage eingeben",
+ "error": "Fehler",
+ "external": "extern",
+ "forced": "erzwungen",
+ "foreign parts only": "nur fremdsprachige Teile",
+ "hearing impaired": "Gehörgeschädigte",
+ "invalid response json (see console for details)": "Ungültige JSON-Antwort (siehe Konsole für Details)",
+ "no results": "Keine Ergebnisse",
+ "open file": "Datei öffnen",
+ "parent dir": "übergeordnetes Verzeichnis",
+ "playlist or file": "Wiedergabeliste oder Datei",
+ "process exited with code %s (see console for details)": "Prozess endete mit dem Status %s (siehe Konsole für Details)",
+ "search online": "Suche im Internet",
+ "type & ctrl+enter to search": "Tippe & Strg+Eingabe um zu suchen",
+ "type to search": "Tippe um zu suchen",
+ "unknown error": "Unbekannter Fehler",
+ "uosc has been installed. Restart mpv for it to take effect.": "uosc wurde installiert. mpv muss neu gestarted werden um es wirksam zu machen."
+}
diff --git a/dotfiles/.config/mpv/scripts/uosc/intl/es.json b/dotfiles/.config/mpv/scripts/uosc/intl/es.json
new file mode 100644
index 0000000..4679541
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/intl/es.json
@@ -0,0 +1,99 @@
+{
+ "%s are empty": "%s están vacíos",
+ "%s channel": "%s canal",
+ "%s channels": "%s canales",
+ "%s to delete": "%s para eliminar",
+ "%s to go up in tree.": "%s para subir en el árbol",
+ "%s to reorder.": "%s para reordenar",
+ "%s to search": "%s para buscar",
+ "Add to playlist": "Añadir a lista de reproducción",
+ "Added to playlist": "Añadido a lista de reproducción",
+ "An error has occurred.": "Un error ha ocurrido.",
+ "Aspect ratio": "Relación de aspecto",
+ "Audio": "Audio",
+ "Audio device": "Dispositivo de audio",
+ "Audio devices": "Dispositivos de audio",
+ "Audio tracks": "Pistas de audio",
+ "Chapter %s": "Capítulo %s",
+ "Chapters": "Capítulos",
+ "Copied to clipboard": "Copiado al portapapeles",
+ "Default": "Por defecto",
+ "Default %s": "Por defecto %s",
+ "Delete": "Eliminar",
+ "Delete file & Next": "Eliminar archivo y siguiente",
+ "Delete file & Prev": "Eliminar archivo y anterior",
+ "Delete file & Quit": "Eliminar archivo y salir",
+ "Drives": "Unidades",
+ "Drop files or URLs to play here": "Soltar archivos o URLs aquí para reproducirlas",
+ "Edition %s": "Edición %s",
+ "Editions": "Ediciones",
+ "Empty": "Vacío",
+ "First": "Primero",
+ "Fullscreen": "Pantalla completa",
+ "Key bindings": "Atajos de teclas",
+ "Last": "Último",
+ "Load": "Abrir",
+ "Load audio": "Añadir una pista de audio",
+ "Load subtitles": "Añadir una pista de subtítulos",
+ "Load video": "Añadir una pista de vídeo",
+ "Loaded audio": "Audio cargado",
+ "Loaded subtitles": "Subtítulos cargados",
+ "Loaded video": "Vídeos cargados",
+ "Loop file": "Repetir archivo",
+ "Loop playlist": "Repetir lista de reproducción",
+ "Menu": "Menú",
+ "Move down": "Moverse abajo",
+ "Move up": "Moverse arriba",
+ "Navigation": "Navegación",
+ "Next": "Siguiente",
+ "Next page": "Página siguiente",
+ "No file": "Ningún archivo",
+ "Open config folder": "Abrir carpeta de configuración",
+ "Open file": "Abrir archivo",
+ "Open in browser": "Abrir en navegador",
+ "Open in mpv": "Abrir en mpv",
+ "Paste path or url to add.": "Pegar ruta o url a añadir.",
+ "Paste path or url to open.": "Pegar ruta o url a abrir.",
+ "Play/Pause": "Reproducir/Pausa",
+ "Playlist": "Lista de reproducción",
+ "Playlist/Files": "Lista de reproducción/Archivos",
+ "Prev": "Anterior",
+ "Previous": "Anterior",
+ "Previous page": "Página anterior",
+ "Quit": "Salir",
+ "Reload": "Recargar",
+ "Remaining downloads today: %s": "Descargas restantes por hoy: %s",
+ "Remove": "Eliminar",
+ "Resets in: %s": "Restablecer en: %s",
+ "Screenshot": "Captura de pantalla",
+ "Search online": "Buscar en línea",
+ "See above for clues.": "Vea arriba para más pistas",
+ "See console for details.": "Vea la consola para más detalles",
+ "Show in directory": "Mostrar en la carpeta",
+ "Shuffle": "Reproducción aleatoria",
+ "Something went wrong.": "Algo malió sal",
+ "Stream quality": "Calidad de la transmisión",
+ "Subtitles": "Subtítulos",
+ "Subtitles loaded & enabled": "Subtítulos cargados y habilitados",
+ "Toggle to disable.": "Alternar para deshabilitar",
+ "Track %s": "Pista %s",
+ "Update uosc": "Actualizar uosc",
+ "Updating uosc": "Actualizando uosc",
+ "Use as secondary": "Utilizar como secundario",
+ "Utils": "Utilidades",
+ "Video": "Vídeo",
+ "default": "por defecto",
+ "drive": "unidad",
+ "enter query": "ingresar consulta",
+ "external": "externo",
+ "forced": "forzado",
+ "foreign parts only": "solo partes extranjeras",
+ "hearing impaired": "discapacidad auditiva",
+ "no results": "sin resultados",
+ "open file": "abrir archivo",
+ "parent dir": "directorio padre",
+ "playlist or file": "archivo o lista de reproducción",
+ "type & ctrl+enter to search": "escriba y presione ctrl+enter para buscar",
+ "type to search": "escriba para buscar",
+ "uosc has been installed. Restart mpv for it to take effect.": "uosc ha sido instalado, Reinicie mpv para que tome efecto."
+}
diff --git a/dotfiles/.config/mpv/scripts/uosc/intl/fr.json b/dotfiles/.config/mpv/scripts/uosc/intl/fr.json
new file mode 100644
index 0000000..254c74c
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/intl/fr.json
@@ -0,0 +1,59 @@
+{
+ "Aspect ratio": "Format d'image",
+ "Audio": "Audio",
+ "Audio device": "Périphérique audio",
+ "Audio devices": "Périphériques audio",
+ "Audio tracks": "Pistes audio",
+ "Autoselect device": "Sélection automatique",
+ "Chapter %s": "Chapitre %s",
+ "Chapters": "Chapitres",
+ "Default": "Par défaut",
+ "Default %s": "Par défaut %s",
+ "Delete file & Next": "Supprimer le fichier et Suivant",
+ "Delete file & Prev": "Supprimer le fichier et Précédent",
+ "Delete file & Quit": "Supprimer le fichier et Quitter",
+ "Disabled": "Désactivé",
+ "Drives": "Lecteurs",
+ "Edition": "Édition",
+ "Edition %s": "Édition %s",
+ "Editions": "Éditions",
+ "Empty": "Vide",
+ "First": "Premier",
+ "Fullscreen": "Plein écran",
+ "Last": "Dernier",
+ "Load": "Ouvrir",
+ "Load audio": "Ajouter une piste audio",
+ "Load subtitles": "Ajouter une piste de sous-titres",
+ "Load video": "Ajouter une piste vidéo",
+ "Loop file": "Lire en boucle le fichier",
+ "Loop playlist": "Lire en boucle la liste de lecture",
+ "Menu": "Menu",
+ "Navigation": "Navigation",
+ "Next": "Suivant",
+ "No file": "Aucun fichier",
+ "Open config folder": "Ouvrir le dossier de configuration",
+ "Open file": "Ouvrir un fichier",
+ "Playlist": "Liste de lecture",
+ "Playlist/Files": "Liste de lecture / Fichiers",
+ "Prev": "Précédent",
+ "Previous": "Précédent",
+ "Quit": "Quitter",
+ "Screenshot": "Capture d'écran",
+ "Show in directory": "Accéder au dossier",
+ "Shuffle": "Lecture aléatoire",
+ "Stream quality": "Qualité du flux",
+ "Subtitles": "Sous-titres",
+ "Track": "Piste",
+ "Track %s": "Piste %s",
+ "Utils": "Outils",
+ "Video": "Vidéo",
+ "%s channel": "%s canal",
+ "%s channels": "%s canaux",
+ "default": "par défaut",
+ "drive": "lecteur",
+ "external": "externe",
+ "forced": "forcé",
+ "open file": "sélectionner un fichier",
+ "parent dir": "répertoire parent",
+ "playlist or file": "fichier ou liste de lecture"
+}
diff --git a/dotfiles/.config/mpv/scripts/uosc/intl/ro.json b/dotfiles/.config/mpv/scripts/uosc/intl/ro.json
new file mode 100644
index 0000000..82bc3df
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/intl/ro.json
@@ -0,0 +1,59 @@
+{
+ "Aspect ratio": "Raportul de aspect",
+ "Audio": "Audio",
+ "Audio device": "Dispozitiv audio",
+ "Audio devices": "Dispozitive audio",
+ "Audio tracks": "Piese audio",
+ "Autoselect device": "Selectare automată",
+ "Chapter %s": "Capitolul %s",
+ "Chapters": "Capitole",
+ "Default": "Implicit",
+ "Default %s": "Implicit %s",
+ "Delete file & Next": "Ștergere fișier și următorul",
+ "Delete file & Prev": "Ștergere fișier și anteriorul",
+ "Delete file & Quit": "Ștergere fișier și ieși",
+ "Disabled": "Dezactivat",
+ "Drives": "Unități",
+ "Edition": "Ediție",
+ "Edition %s": "Ediție %s",
+ "Editions": "Ediții",
+ "Empty": "Gol",
+ "First": "Primul",
+ "Fullscreen": "Ecran complet",
+ "Last": "Ultimul",
+ "Load": "Încarcă",
+ "Load audio": "Deschide audio",
+ "Load subtitles": "Deschide subtitrările",
+ "Load video": "Deschide video",
+ "Loop file": "Repetă fișierul",
+ "Loop playlist": "Repetă lista de redare",
+ "Menu": "Meniu",
+ "Navigation": "Navigare",
+ "Next": "Următor",
+ "No file": "Niciun fisier",
+ "Open config folder": "Deschide dosarul de configurație",
+ "Open file": "Deschide fișierul",
+ "Playlist": "Listă de redare",
+ "Playlist/Files": "Listă de redare/Fișiere",
+ "Prev": "Precedent",
+ "Previous": "Precedent",
+ "Quit": "Ieșire",
+ "Screenshot": "Captură de ecran",
+ "Show in directory": "Arată în dosar",
+ "Shuffle": "Amestecă",
+ "Stream quality": "Calitatea fluxului",
+ "Subtitles": "Subtitrări",
+ "Track": "Pistă",
+ "Track %s": "Pistă %s",
+ "Utils": "Utilități",
+ "Video": "Video",
+ "%s channel": "%s canal",
+ "%s channels": "%s canale",
+ "default": "implicit",
+ "drive": "unitate",
+ "external": "extern",
+ "forced": "forțat",
+ "open file": "deschide fișierul",
+ "parent dir": "director părinte",
+ "playlist or file": "fișier sau listă de redare"
+}
diff --git a/dotfiles/.config/mpv/scripts/uosc/intl/ru.json b/dotfiles/.config/mpv/scripts/uosc/intl/ru.json
new file mode 100644
index 0000000..2dc148e
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/intl/ru.json
@@ -0,0 +1,59 @@
+{
+ "Aspect ratio": "Соотношение сторон",
+ "Audio": "Аудио",
+ "Audio device": "Аудиоустройство",
+ "Audio devices": "Аудиоустройства",
+ "Audio tracks": "Аудиодорожки",
+ "Autoselect device": "Автовыбор устройства",
+ "Chapter %s": "Глава %s",
+ "Chapters": "Главы",
+ "Default": "По умолчанию",
+ "Default %s": "По умолчанию %s",
+ "Delete file & Next": "Удалить файл и след.",
+ "Delete file & Prev": "Удалить файл и пред.",
+ "Delete file & Quit": "Удалить файл и выйти",
+ "Disabled": "Отключено",
+ "Drives": "Диски",
+ "Edition": "Редакция",
+ "Edition %s": "Редакция %s",
+ "Editions": "Редакции",
+ "Empty": "Пусто",
+ "First": "Первый",
+ "Fullscreen": "Полный экран",
+ "Last": "Последний",
+ "Load": "Загрузить",
+ "Load audio": "Загрузить аудио",
+ "Load subtitles": "Загрузить субтитры",
+ "Load video": "Загрузить видео",
+ "Loop file": "Повторять файл",
+ "Loop playlist": "Повторять плейлист",
+ "Menu": "Меню",
+ "Navigation": "Навигация",
+ "Next": "Следующий",
+ "No file": "Нет файла",
+ "Open config folder": "Открыть папку конфигурации",
+ "Open file": "Открыть файл",
+ "Playlist": "Плейлист",
+ "Playlist/Files": "Плейлист / файлы",
+ "Prev": "Предыдущий",
+ "Previous": "Предыдущий",
+ "Quit": "Выйти",
+ "Screenshot": "Скриншот",
+ "Show in directory": "Показать в папке",
+ "Shuffle": "Перемешать",
+ "Stream quality": "Качество потока",
+ "Subtitles": "Субтитры",
+ "Track": "Дорожка",
+ "Track %s": "Дорожка %s",
+ "Utils": "Инструменты",
+ "Video": "Видео",
+ "%s channels": "%s канала/-ов",
+ "%s channel": "%s канал",
+ "default": "по умолчанию",
+ "drive": "диск",
+ "external": "внешняя",
+ "forced": "форсированная",
+ "open file": "открыть файл",
+ "parent dir": "родительская папка",
+ "playlist or file": "плейлист или файл"
+}
diff --git a/dotfiles/.config/mpv/scripts/uosc/intl/tr.json b/dotfiles/.config/mpv/scripts/uosc/intl/tr.json
new file mode 100644
index 0000000..4928c2f
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/intl/tr.json
@@ -0,0 +1,107 @@
+{
+ "%s are empty": "%s boş",
+ "%s channel": "%s kanal",
+ "%s channels": "%s kanallar",
+ "%s to delete": "%s silmek için",
+ "%s to go up in tree.": "%s yukarı gitmek için.",
+ "%s to reorder.": "%s yeniden sıralamak için.",
+ "%s to search": "%s aramak için",
+ "Add to playlist": "Çalma listesine ekle",
+ "Added to playlist": "Çalma listesine eklendi",
+ "An error has occurred.": "Bir hata oluştu.",
+ "Aspect ratio": "En-boy oranı",
+ "Audio": "Ses",
+ "Audio device": "Ses cihazı",
+ "Audio devices": "Ses cihazları",
+ "Audio tracks": "Ses parçaları",
+ "Autoload": "Otomatik yükleme",
+ "Chapter %s": "Bölüm %s",
+ "Chapters": "Bölümler",
+ "Check for updates": "Güncellemeleri kontrol et",
+ "Checking for updates": "Güncellemeler kontrol ediliyor",
+ "Close": "Kapat",
+ "Copied to clipboard": "Panoya kopyalandı",
+ "Default": "Varsayılan",
+ "Default %s": "Varsayılan %s",
+ "Delete": "Sil",
+ "Delete file & Next": "Dosyayı sil & Sonraki",
+ "Delete file & Prev": "Dosyayı sil & Önceki",
+ "Delete file & Quit": "Dosyayı sil & Çık",
+ "Drives": "Sürücüler",
+ "Drop files or URLs to play here": "Dosyaları veya URL'leri buraya bırakın",
+ "Edition %s": "Sürüm %s",
+ "Editions": "Sürümler",
+ "Empty": "Boş",
+ "First": "İlk",
+ "Fullscreen": "Tam ekran",
+ "Key bindings": "Tuş atamaları",
+ "Last": "Son",
+ "Load": "Yükle",
+ "Load audio": "Ses yükle",
+ "Load subtitles": "Altyazı yükle",
+ "Load video": "Video yükle",
+ "Loaded audio": "Ses yüklendi",
+ "Loaded subtitles": "Altyazı yüklendi",
+ "Loaded video": "Video yüklendi",
+ "Loop file": "Dosyayı döngüye al",
+ "Loop playlist": "Çalma listesini döngüye al",
+ "Menu": "Menü",
+ "Move down": "Aşağı taşı",
+ "Move up": "Yukarı taşı",
+ "Navigation": "Gezinme",
+ "Next": "Sonraki",
+ "Next page": "Sonraki sayfa",
+ "No file": "Dosya yok",
+ "Nothing to copy": "Kopyalanacak bir şey yok",
+ "Open changelog": "Değişiklik günlüğünü aç",
+ "Open config folder": "Yapılandırma klasörünü aç",
+ "Open file": "Dosya aç",
+ "Open in browser": "Tarayıcıda aç",
+ "Open in mpv": "mpv'de aç",
+ "Paste path or url to add.": "Eklemek için yolu veya URL'yi yapıştırın.",
+ "Paste path or url to open.": "Açmak için yolu veya URL'yi yapıştırın.",
+ "Play/Pause": "Oynat/Duraklat",
+ "Playlist": "Çalma listesi",
+ "Playlist/Files": "Çalma listesi/Dosyalar",
+ "Prev": "Önceki",
+ "Previous": "Önceki",
+ "Previous page": "Önceki sayfa",
+ "Quit": "Çıkış",
+ "Reload": "Yeniden yükle",
+ "Remaining downloads today: %s": "Bugünkü kalan indirmeler: %s",
+ "Remove": "Kaldır",
+ "Resets in: %s": "%s içinde sıfırlanacak",
+ "Screenshot": "Ekran görüntüsü",
+ "Search online": "Çevrimiçi ara",
+ "See above for clues.": "İpuçları için yukarıya bakın.",
+ "See console for details.": "Ayrıntılar için konsola bakın.",
+ "Show in directory": "Dizinde göster",
+ "Shuffle": "Karıştır",
+ "Something went wrong.": "Bir şeyler ters gitti.",
+ "Stream quality": "Yayın kalitesi",
+ "Subtitles": "Altyazılar",
+ "Subtitles loaded & enabled": "Altyazılar yüklendi ve etkinleştirildi",
+ "Toggle to disable.": "Devre dışı bırakmak için değiştir.",
+ "Track %s": "Parça %s",
+ "Up to date": "Güncel",
+ "Update available": "Güncelleme mevcut",
+ "Update uosc": "uosc güncelle",
+ "Updating uosc": "uosc güncelleniyor",
+ "Use as secondary": "İkincil olarak kullan",
+ "Utils": "Araçlar",
+ "Video": "Video",
+ "default": "varsayılan",
+ "drive": "sürücü",
+ "enter query": "Sorgu gir",
+ "external": "harici",
+ "forced": "zorunlu",
+ "foreign parts only": "sadece yabancı bölümler",
+ "hearing impaired": "işitme engelli",
+ "no results": "Sonuç yok",
+ "open file": "Dosya aç",
+ "parent dir": "üst dizin",
+ "playlist or file": "çalma listesi veya dosya",
+ "type & ctrl+enter to search": "Yaz & aramak için Ctrl+Enter'a bas",
+ "type to search": "Aramak için yaz",
+ "uosc has been installed. Restart mpv for it to take effect.": "uosc yüklendi. Etkin olması için mpv'yi yeniden başlatın."
+}
diff --git a/dotfiles/.config/mpv/scripts/uosc/intl/uk.json b/dotfiles/.config/mpv/scripts/uosc/intl/uk.json
new file mode 100644
index 0000000..a5ced6d
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/intl/uk.json
@@ -0,0 +1,69 @@
+{
+ "Aspect ratio": "Співвідношення сторін",
+ "Audio": "Аудіо",
+ "Audio device": "Аудіопристрій",
+ "Audio devices": "Аудіопристрої",
+ "Audio tracks": "Аудіодоріжки",
+ "Autoselect device": "Автовибір пристрою",
+ "Chapter %s": "Розділ %s",
+ "Chapters": "Розділи",
+ "Default": "За замовчуванням",
+ "Default %s": "За замовчуванням %s",
+ "Delete file & Next": "Видалити файл & Наступний",
+ "Delete file & Prev": "Видалити файл & Попередній",
+ "Delete file & Quit": "Видалити файл & Вийти",
+ "Disabled": "Вимкнено",
+ "Drives": "Диски",
+ "Edition": "Видання",
+ "Edition %s": "Видання %s",
+ "Editions": "Видання",
+ "Empty": "Порожньо",
+ "First": "Перший",
+ "Fullscreen": "На весь екран",
+ "Last": "Останній",
+ "Load": "Завантажити",
+ "Load audio": "Завантажити аудіо",
+ "Load subtitles": "Завантажити субтитри",
+ "Load video": "Завантажити відео",
+ "Loop file": "Повторювати файл",
+ "Loop playlist": "Повторювати плейліст",
+ "Menu": "Меню",
+ "Navigation": "Навігація",
+ "Next": "Наступний",
+ "No file": "Файл відсутній",
+ "Open config folder": "Відкрити каталог конфігурації",
+ "Open file": "Відкрити файл",
+ "Playlist": "Плейліст",
+ "Playlist/Files": "Плейліст / Файли",
+ "Prev": "Попередній",
+ "Previous": "Попередній",
+ "Quit": "Вийти",
+ "Screenshot": "Скриншот",
+ "Show in directory": "Показати в каталозі",
+ "Shuffle": "Перемішати",
+ "Stream quality": "Якість потоку",
+ "Subtitles": "Субтитри",
+ "Track": "Трек",
+ "Track %s": "Трек %s",
+ "Utils": "Інструменти",
+ "Video": "Відео",
+ "%s channels": "%s канали/-ів",
+ "%s channel": "%s канал",
+ "default": "за замовчуванням",
+ "drive": "диск",
+ "external": "зовнішня",
+ "forced": "примусова",
+ "open file": "відкрити файл",
+ "parent dir": "батьківський каталог",
+ "playlist or file": "плейліст або файл",
+ "type to search": "Введіть для пошуку",
+ "type & ctrl+enter to search": "Введіть & Ctrl+Enter для пошуку",
+ "Key bindings": "Комбінації клавіш",
+ "Drop files or URLs to play here": "Перемістіть файли або URL-адреси для відтворення сюди",
+ "Update uosc": "Оновити uosc",
+ "Updating uosc": "uosc оновлюється",
+ "uosc has been installed. Restart mpv for it to take effect.": "uosc встановлено. mpv потрібно перезапустити.",
+ "An error has occurred.": "Сталася помилка.",
+ "See above for clues.": "Дивіться підказки вище.",
+ "Play/Pause": "Відтворення / Пауза"
+}
diff --git a/dotfiles/.config/mpv/scripts/uosc/intl/zh-hans.json b/dotfiles/.config/mpv/scripts/uosc/intl/zh-hans.json
new file mode 100644
index 0000000..5411c65
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/intl/zh-hans.json
@@ -0,0 +1,99 @@
+{
+ "%s are empty": "%s 为空",
+ "%s channel": "%s 声道",
+ "%s channels": "%s 声道",
+ "%s to delete": "使用 %s 进行删除",
+ "%s to go up in tree.": "使用 %s 返回上一级",
+ "%s to reorder.": "使用 %s 重新排序",
+ "%s to search": "使用 %s 进行搜索",
+ "Add to playlist": "添加到播放列表",
+ "Added to playlist": "已添加到播放列表",
+ "An error has occurred.": "出现错误",
+ "Aspect ratio": "纵横比",
+ "Audio": "音频",
+ "Audio device": "音频设备",
+ "Audio devices": "音频设备",
+ "Audio tracks": "音频轨道",
+ "Chapter %s": "第 %s 章",
+ "Chapters": "章节",
+ "Copied to clipboard": "已复制到剪贴板",
+ "Default": "默认",
+ "Default %s": "默认 %s",
+ "Delete": "删除",
+ "Delete file & Next": "删除文件并播放下一个",
+ "Delete file & Prev": "删除文件并播放上一个",
+ "Delete file & Quit": "删除文件并退出",
+ "Drives": "驱动器",
+ "Drop files or URLs to play here": "拖放文件或 URLs 到此处进行播放",
+ "Edition %s": "版本 %s",
+ "Editions": "版本",
+ "Empty": "空",
+ "First": "第一个",
+ "Fullscreen": "全屏",
+ "Key bindings": "键位绑定",
+ "Last": "最后一个",
+ "Load": "加载",
+ "Load audio": "加载音轨",
+ "Load subtitles": "加载字幕",
+ "Load video": "加载视频轨",
+ "Loaded audio": "已加载音轨",
+ "Loaded subtitles": "已加载字幕",
+ "Loaded video": "已加载视频轨",
+ "Loop file": "单个循环",
+ "Loop playlist": "列表循环",
+ "Menu": "菜单",
+ "Move down": "下移",
+ "Move up": "上移",
+ "Navigation": "导航",
+ "Next": "下一个",
+ "Next page": "下一页",
+ "No file": "无文件",
+ "Open config folder": "打开配置文件夹",
+ "Open file": "打开文件",
+ "Open in browser": "在浏览器中打开",
+ "Open in mpv": "在 mpv 中打开",
+ "Paste path or url to add.": "粘贴路径或网址以添加",
+ "Paste path or url to open.": "粘贴路径或网址以打开",
+ "Play/Pause": "播放/暂停",
+ "Playlist": "播放列表",
+ "Playlist/Files": "播放列表/文件列表",
+ "Prev": "上一个",
+ "Previous": "上一个",
+ "Previous page": "上一页",
+ "Quit": "退出",
+ "Reload": "重载",
+ "Remaining downloads today: %s": "今天的剩余下载量: %s",
+ "Remove": "移除",
+ "Resets in: %s": "重置: %s",
+ "Screenshot": "截图",
+ "Search online": "在线搜索",
+ "See above for clues.": "线索见上文",
+ "See console for details.": "参阅控制台了解详细信息",
+ "Show in directory": "打开所在文件夹",
+ "Shuffle": "乱序",
+ "Something went wrong.": "出错了",
+ "Stream quality": "流媒体品质",
+ "Subtitles": "字幕",
+ "Subtitles loaded & enabled": "字幕已加载并启用",
+ "Toggle to disable.": "点击切换禁用状态",
+ "Track %s": "轨道 %s",
+ "Update uosc": "更新 uosc",
+ "Updating uosc": "正在更新 uosc",
+ "Use as secondary": "设置为次字幕",
+ "Utils": "工具",
+ "Video": "视频",
+ "default": "默认",
+ "drive": "磁盘",
+ "enter query": "输入查询",
+ "external": "外置",
+ "forced": "强制",
+ "foreign parts only": "仅限外语部分",
+ "hearing impaired": "听力障碍",
+ "no results": "没有结果",
+ "open file": "打开文件",
+ "parent dir": "父文件夹",
+ "playlist or file": "播放列表或文件",
+ "type & ctrl+enter to search": "输入并按 ctrl+enter 进行搜索",
+ "type to search": "输入搜索内容",
+ "uosc has been installed. Restart mpv for it to take effect.": "uosc 已经安装,重新启动 mpv 使其生效"
+}
diff --git a/dotfiles/.config/mpv/scripts/uosc/lib/ass.lua b/dotfiles/.config/mpv/scripts/uosc/lib/ass.lua
new file mode 100644
index 0000000..c20349f
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/lib/ass.lua
@@ -0,0 +1,268 @@
+--[[ ASSDRAW EXTENSIONS ]]
+
+local ass_mt = getmetatable(assdraw.ass_new())
+
+-- Opacity.
+---@param self table|nil
+---@param opacity number|{primary?: number; border?: number, shadow?: number, main?: number} Opacity of all elements.
+---@param fraction? number Optionally adjust the above opacity by this fraction.
+---@return string|nil
+function ass_mt.opacity(self, opacity, fraction)
+ fraction = fraction ~= nil and fraction or 1
+ opacity = type(opacity) == 'table' and opacity or {main = opacity}
+ local text = ''
+ if opacity.main then
+ text = text .. string.format('\\alpha&H%X&', opacity_to_alpha(opacity.main * fraction))
+ end
+ if opacity.primary then
+ text = text .. string.format('\\1a&H%X&', opacity_to_alpha(opacity.primary * fraction))
+ end
+ if opacity.border then
+ text = text .. string.format('\\3a&H%X&', opacity_to_alpha(opacity.border * fraction))
+ end
+ if opacity.shadow then
+ text = text .. string.format('\\4a&H%X&', opacity_to_alpha(opacity.shadow * fraction))
+ end
+ if self == nil then
+ return text
+ elseif text ~= '' then
+ self.text = self.text .. '{' .. text .. '}'
+ end
+end
+
+-- Icon.
+---@param x number
+---@param y number
+---@param size number
+---@param name string
+---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; clip?: string; align?: number}
+function ass_mt:icon(x, y, size, name, opts)
+ opts = opts or {}
+ opts.font, opts.size, opts.bold = 'MaterialIconsRound-Regular', size, false
+ self:txt(x, y, opts.align or 5, name, opts)
+end
+
+-- Text.
+-- Named `txt` because `ass.text` is a value.
+---@param x number
+---@param y number
+---@param align number
+---@param value string|number
+---@param opts {size: number; font?: string; color?: string; bold?: boolean; italic?: boolean; border?: number; border_color?: string; shadow?: number; shadow_color?: string; rotate?: number; wrap?: number; opacity?: number|{primary?: number; border?: number, shadow?: number, main?: number}; clip?: string}
+function ass_mt:txt(x, y, align, value, opts)
+ local border_size = opts.border or 0
+ local shadow_size = opts.shadow or 0
+ local tags = '\\pos(' .. x .. ',' .. y .. ')\\rDefault\\an' .. align .. '\\blur0'
+ -- font
+ tags = tags .. '\\fn' .. (opts.font or config.font)
+ -- font size
+ tags = tags .. '\\fs' .. opts.size
+ -- bold
+ if opts.bold or (opts.bold == nil and options.font_bold) then tags = tags .. '\\b1' end
+ -- italic
+ if opts.italic then tags = tags .. '\\i1' end
+ -- rotate
+ if opts.rotate then tags = tags .. '\\frz' .. opts.rotate end
+ -- wrap
+ if opts.wrap then tags = tags .. '\\q' .. opts.wrap end
+ -- border
+ tags = tags .. '\\bord' .. border_size
+ -- shadow
+ tags = tags .. '\\shad' .. shadow_size
+ -- colors
+ tags = tags .. '\\1c&H' .. (opts.color or bgt)
+ if border_size > 0 then tags = tags .. '\\3c&H' .. (opts.border_color or bg) end
+ if shadow_size > 0 then tags = tags .. '\\4c&H' .. (opts.shadow_color or bg) end
+ -- opacity
+ if opts.opacity then tags = tags .. self.opacity(nil, opts.opacity) end
+ -- clip
+ if opts.clip then tags = tags .. opts.clip end
+ -- render
+ self:new_event()
+ self.text = self.text .. '{' .. tags .. '}' .. value
+end
+
+-- Tooltip.
+---@param element Rect
+---@param value string|number
+---@param opts? {size?: number; align?: number; offset?: number; bold?: boolean; italic?: boolean; width_overwrite?: number, margin?: number; responsive?: boolean; lines?: integer, timestamp?: boolean; invert_colors?: boolean}
+function ass_mt:tooltip(element, value, opts)
+ if value == '' then return end
+ opts = opts or {}
+ opts.size = opts.size or round(16 * state.scale)
+ opts.border = options.text_border * state.scale
+ opts.border_color = opts.invert_colors and fg or bg
+ opts.margin = opts.margin or round(10 * state.scale)
+ opts.lines = opts.lines or 1
+ opts.color = opts.invert_colors and bg or fg
+ local offset = opts.offset or 2
+ local padding_y = round(opts.size / 6)
+ local padding_x = round(opts.size / 3)
+ local width = (opts.width_overwrite or text_width(value, opts)) + padding_x * 2
+ local height = opts.size * opts.lines + 2 * padding_y
+ local width_half, height_half = width / 2, height / 2
+ local margin = opts.margin + Elements:v('window_border', 'size', 0)
+ local align = opts.align or 8
+
+ local x, y = 0, 0 -- center of tooltip
+
+ -- Flip alignment to other side when not enough space
+ if opts.responsive ~= false then
+ if align == 8 then
+ if element.ay - offset - height < margin then align = 2 end
+ elseif align == 2 then
+ if element.by + offset + height > display.height - margin then align = 8 end
+ elseif align == 6 then
+ if element.bx + offset + width > display.width - margin then align = 4 end
+ elseif align == 4 then
+ if element.ax - offset - width < margin then align = 6 end
+ end
+ end
+
+ -- Calculate tooltip center based on alignment
+ if align == 8 or align == 2 then
+ x = clamp(margin + width_half, element.ax + (element.bx - element.ax) / 2, display.width - margin - width_half)
+ y = align == 8 and element.ay - offset - height_half or element.by + offset + height_half
+ else
+ x = align == 6 and element.bx + offset + width_half or element.ax - offset - width_half
+ y = clamp(margin + height_half, element.ay + (element.by - element.ay) / 2, display.height - margin - height_half)
+ end
+
+ -- Draw
+ local ax, ay, bx, by = round(x - width_half), round(y - height_half), round(x + width_half), round(y + height_half)
+ self:rect(ax, ay, bx, by, {
+ color = opts.invert_colors and fg or bg, opacity = config.opacity.tooltip, radius = state.radius
+ })
+ local func = opts.timestamp and self.timestamp or self.txt
+ func(self, x, y, 5, tostring(value), opts)
+ return {ax = element.ax, ay = ay, bx = element.bx, by = by}
+end
+
+-- Timestamp with each digit positioned as if it was replaced with 0
+---@param x number
+---@param y number
+---@param align number
+---@param timestamp string
+---@param opts {size: number; opacity?: number|{primary?: number; border?: number, shadow?: number, main?: number}}
+function ass_mt:timestamp(x, y, align, timestamp, opts)
+ local widths, width_total = {}, 0
+ zero_rep = timestamp_zero_rep(timestamp)
+ for i = 1, #zero_rep do
+ local width = text_width(zero_rep:sub(i, i), opts)
+ widths[i] = width
+ width_total = width_total + width
+ end
+
+ -- shift x and y to fit align 5
+ local mod_align = align % 3
+ if mod_align == 0 then
+ x = x - width_total
+ elseif mod_align == 2 then
+ x = x - width_total / 2
+ end
+ if align < 4 then
+ y = y - opts.size / 2
+ elseif align > 6 then
+ y = y + opts.size / 2
+ end
+
+ local opacity = opts.opacity
+ local primary_opacity
+ if type(opacity) == 'table' then
+ opts.opacity = {main = opacity.main, border = opacity.border, shadow = opacity.shadow, primary = 0}
+ primary_opacity = opacity.primary or opacity.main
+ else
+ opts.opacity = {main = opacity, primary = 0}
+ primary_opacity = opacity
+ end
+ for i, width in ipairs(widths) do
+ self:txt(x + width / 2, y, 5, timestamp:sub(i, i), opts)
+ x = x + width
+ end
+ x = x - width_total
+ opts.opacity = {main = 0, primary = primary_opacity or 1}
+ for i, width in ipairs(widths) do
+ self:txt(x + width / 2, y, 5, timestamp:sub(i, i), opts)
+ x = x + width
+ end
+ opts.opacity = opacity
+end
+
+-- Rectangle.
+---@param ax number
+---@param ay number
+---@param bx number
+---@param by number
+---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number|{primary?: number; border?: number, shadow?: number, main?: number}; clip?: string, radius?: number}
+function ass_mt:rect(ax, ay, bx, by, opts)
+ opts = opts or {}
+ local border_size = opts.border or 0
+ local tags = '\\pos(0,0)\\rDefault\\an7\\blur0'
+ -- border
+ tags = tags .. '\\bord' .. border_size
+ -- colors
+ tags = tags .. '\\1c&H' .. (opts.color or fg)
+ if border_size > 0 then tags = tags .. '\\3c&H' .. (opts.border_color or bg) end
+ -- opacity
+ if opts.opacity then tags = tags .. self.opacity(nil, opts.opacity) end
+ -- clip
+ if opts.clip then
+ tags = tags .. opts.clip
+ end
+ -- draw
+ self:new_event()
+ self.text = self.text .. '{' .. tags .. '}'
+ self:draw_start()
+ if opts.radius and opts.radius > 0 then
+ self:round_rect_cw(ax, ay, bx, by, opts.radius)
+ else
+ self:rect_cw(ax, ay, bx, by)
+ end
+ self:draw_stop()
+end
+
+-- Circle.
+---@param x number
+---@param y number
+---@param radius number
+---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; clip?: string}
+function ass_mt:circle(x, y, radius, opts)
+ opts = opts or {}
+ opts.radius = radius
+ self:rect(x - radius, y - radius, x + radius, y + radius, opts)
+end
+
+-- Texture.
+---@param ax number
+---@param ay number
+---@param bx number
+---@param by number
+---@param char string Texture font character.
+---@param opts {size?: number; color: string; opacity?: number; clip?: string; anchor_x?: number, anchor_y?: number}
+function ass_mt:texture(ax, ay, bx, by, char, opts)
+ opts = opts or {}
+ local anchor_x, anchor_y = opts.anchor_x or ax, opts.anchor_y or ay
+ local clip = opts.clip or ('\\clip(' .. ax .. ',' .. ay .. ',' .. bx .. ',' .. by .. ')')
+ local tile_size, opacity = opts.size or 100, opts.opacity or 0.2
+ local x, y = ax - (ax - anchor_x) % tile_size, ay - (ay - anchor_y) % tile_size
+ local width, height = bx - x, by - y
+ local line = string.rep(char, math.ceil((width / tile_size)))
+ local lines = ''
+ for i = 1, math.ceil(height / tile_size), 1 do lines = lines .. (lines == '' and '' or '\\N') .. line end
+ self:txt(
+ x, y, 7, lines,
+ {font = 'uosc_textures', size = tile_size, color = opts.color, bold = false, opacity = opacity, clip = clip})
+end
+
+-- Rotating spinner icon.
+---@param x number
+---@param y number
+---@param size number
+---@param opts? {color?: string; opacity?: number; clip?: string; border?: number; border_color?: string;}
+function ass_mt:spinner(x, y, size, opts)
+ opts = opts or {}
+ opts.rotate = (state.render_last_time * 1.75 % 1) * -360
+ opts.color = opts.color or fg
+ self:icon(x, y, size, 'autorenew', opts)
+ request_render()
+end
diff --git a/dotfiles/.config/mpv/scripts/uosc/lib/buttons.lua b/dotfiles/.config/mpv/scripts/uosc/lib/buttons.lua
new file mode 100644
index 0000000..b5c086a
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/lib/buttons.lua
@@ -0,0 +1,69 @@
+---@alias ButtonData {icon: string; active?: boolean; badge?: string; command?: string | string[]; tooltip?: string;}
+---@alias ButtonSubscriber fun(data: ButtonData)
+
+local buttons = {
+ ---@type ButtonData[]
+ data = {},
+ ---@type table
+ subscribers = {},
+}
+
+---@param name string
+---@param callback fun(data: ButtonData)
+function buttons:subscribe(name, callback)
+ local pool = self.subscribers[name]
+ if not pool then
+ pool = {}
+ self.subscribers[name] = pool
+ end
+ pool[#pool + 1] = callback
+ self:trigger(name)
+ return function() buttons:unsubscribe(name, callback) end
+end
+
+---@param name string
+---@param callback? ButtonSubscriber
+function buttons:unsubscribe(name, callback)
+ if self.subscribers[name] then
+ if callback == nil then
+ self.subscribers[name] = {}
+ else
+ itable_delete_value(self.subscribers[name], callback)
+ end
+ end
+end
+
+---@param name string
+function buttons:trigger(name)
+ local pool = self.subscribers[name]
+ local data = self.data[name] or {icon = 'help_center', tooltip = 'Uninitialized button "' .. name .. '"'}
+ if pool then
+ for _, callback in ipairs(pool) do callback(data) end
+ end
+end
+
+---@param name string
+---@param data ButtonData
+function buttons:set(name, data)
+ buttons.data[name] = data
+ buttons:trigger(name)
+ request_render()
+end
+
+mp.register_script_message('set-button', function(name, data)
+ if type(name) ~= 'string' then
+ msg.error('Invalid set-button message parameter: 1st parameter (name) has to be a string.')
+ return
+ end
+ if type(data) ~= 'string' then
+ msg.error('Invalid set-button message parameter: 2nd parameter (data) has to be a string.')
+ return
+ end
+
+ local data = utils.parse_json(data)
+ if type(data) == 'table' and type(data.icon) == 'string' then
+ buttons:set(name, data)
+ end
+end)
+
+return buttons
diff --git a/dotfiles/.config/mpv/scripts/uosc/lib/char_conv.lua b/dotfiles/.config/mpv/scripts/uosc/lib/char_conv.lua
new file mode 100644
index 0000000..4823ce0
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/lib/char_conv.lua
@@ -0,0 +1,67 @@
+require('lib/text')
+
+local char_dir = mp.get_script_directory() .. '/char-conv/'
+local data = {}
+
+local languages = get_languages()
+for _, lang in ipairs(languages) do
+ table_assign(data, get_locale_from_json(char_dir .. lang:lower() .. '.json'))
+end
+
+local romanization = {}
+
+local function get_romanization_table()
+ for k, v in pairs(data) do
+ for _, char in utf8_iter(v) do
+ romanization[char] = k
+ end
+ end
+end
+get_romanization_table()
+
+function need_romanization()
+ return next(romanization) ~= nil
+end
+
+function char_conv(chars, use_ligature, has_separator)
+ local separator = has_separator or ' '
+ local length = 0
+ local char_conv, sp, cache = {}, {}, {}
+ local chars_length = utf8_length(chars)
+ local concat = table.concat
+ for _, char in utf8_iter(chars) do
+ if use_ligature then
+ if #char == 1 then
+ char_conv[#char_conv + 1] = char
+ else
+ char_conv[#char_conv + 1] = romanization[char] or char
+ end
+ else
+ length = length + 1
+ if #char <= 2 then
+ if (char ~= ' ' and length ~= chars_length) then
+ cache[#cache + 1] = romanization[char] or char
+ elseif (char == ' ' or length == chars_length) then
+ if length == chars_length then
+ cache[#cache + 1] = romanization[char] or char
+ end
+ sp[#sp + 1] = concat(cache)
+ itable_clear(cache)
+ end
+ else
+ if next(cache) ~= nil then
+ sp[#sp + 1] = concat(cache)
+ itable_clear(cache)
+ end
+ sp[#sp + 1] = romanization[char] or char
+ end
+ end
+ end
+ if use_ligature then
+ return concat(char_conv)
+ else
+ return concat(sp, separator)
+ end
+end
+
+return char_conv
diff --git a/dotfiles/.config/mpv/scripts/uosc/lib/cursor.lua b/dotfiles/.config/mpv/scripts/uosc/lib/cursor.lua
new file mode 100644
index 0000000..5752593
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/lib/cursor.lua
@@ -0,0 +1,443 @@
+---@alias CursorEventHandler fun(shortcut: Shortcut)
+
+local cursor = {
+ x = math.huge,
+ y = math.huge,
+ hidden = true,
+ distance = 0, -- Distance traveled during current move. Reset by `cursor.distance_reset_timer`.
+ last_hover = false, -- Stores `mouse.hover` boolean of the last mouse event for enter/leave detection.
+ -- Event handlers that are only fired on zones defined during render loop.
+ ---@type {event: string, hitbox: Hitbox; handler: CursorEventHandler}[]
+ zones = {},
+ handlers = {
+ primary_down = {},
+ primary_up = {},
+ secondary_down = {},
+ secondary_up = {},
+ wheel_down = {},
+ wheel_up = {},
+ move = {},
+ },
+ first_real_mouse_move_received = false,
+ history = CircularBuffer:new(10),
+ autohide_fs_only = nil,
+ -- Tracks current key binding levels for each event. 0: disabled, 1: enabled, 2: enabled + window dragging prevented
+ binding_levels = {
+ mbtn_left = 0,
+ mbtn_left_dbl = 0,
+ mbtn_right = 0,
+ wheel = 0,
+ },
+ is_dragging_prevented = false,
+ event_forward_map = {
+ primary_down = 'MBTN_LEFT',
+ primary_up = 'MBTN_LEFT',
+ secondary_down = 'MBTN_RIGHT',
+ secondary_up = 'MBTN_RIGHT',
+ wheel_down = 'WHEEL_DOWN',
+ wheel_up = 'WHEEL_UP',
+ },
+ event_binding_map = {
+ primary_down = 'mbtn_left',
+ primary_up = 'mbtn_left',
+ primary_click = 'mbtn_left',
+ secondary_down = 'mbtn_right',
+ secondary_up = 'mbtn_right',
+ secondary_click = 'mbtn_right',
+ wheel_down = 'wheel',
+ wheel_up = 'wheel',
+ },
+ window_dragging_blockers = create_set({'primary_click', 'primary_down'}),
+ event_propagation_blockers = {
+ primary_down = 'primary_click',
+ primary_click = 'primary_down',
+ secondary_down = 'secondary_click',
+ secondary_click = 'secondary_down',
+ },
+ event_parent_map = {
+ primary_down = {is_start = true, trigger_event = 'primary_click'},
+ primary_up = {is_end = true, start_event = 'primary_down', trigger_event = 'primary_click'},
+ secondary_down = {is_start = true, trigger_event = 'secondary_click'},
+ secondary_up = {is_end = true, start_event = 'secondary_down', trigger_event = 'secondary_click'},
+ },
+ -- Holds positions of last events.
+ ---@type {[string]: {x: number, y: number, time: number}}
+ last_event = {},
+}
+
+cursor.autohide_timer = mp.add_timeout(1, function() cursor:autohide() end)
+cursor.autohide_timer:kill()
+mp.observe_property('cursor-autohide', 'number', function(_, val)
+ cursor.autohide_timer.timeout = (val or 1000) / 1000
+end)
+
+cursor.distance_reset_timer = mp.add_timeout(0.2, function()
+ cursor.distance = 0
+ request_render()
+end)
+cursor.distance_reset_timer:kill()
+
+-- Called at the beginning of each render
+function cursor:clear_zones()
+ itable_clear(self.zones)
+end
+
+---@param hitbox Hitbox
+function cursor:collides_with(hitbox)
+ return point_collides_with(self, hitbox)
+end
+
+-- Returns zone for event at current cursor position.
+---@param event string
+function cursor:find_zone(event)
+ -- Premature optimization to ignore a high frequency event that is not needed as a zone atm.
+ if event == 'move' then return end
+
+ for i = #self.zones, 1, -1 do
+ local zone = self.zones[i]
+ local is_blocking_only = zone.event == self.event_propagation_blockers[event]
+ if (zone.event == event or is_blocking_only) and self:collides_with(zone.hitbox) then
+ return not is_blocking_only and zone or nil
+ end
+ end
+end
+
+-- Defines an event zone for a hitbox on currently rendered screen. Available events:
+-- - primary_down, primary_up, primary_click, secondary_down, secondary_up, secondary_click, wheel_down, wheel_up
+--
+-- Notes:
+-- - Zones are cleared on beginning of every `render()`, and need to be rebound.
+-- - One event type per zone: only the last bound zone per event gets triggered.
+-- - In current implementation, you have to choose between `_click` or `_down`. Binding both makes only the last bound fire.
+-- - Primary `_down` and `_click` disable dragging. Define `window_drag = true` on hitbox to re-enable.
+-- - Anything that disables dragging also implicitly disables cursor autohide.
+-- - `move` event zones are ignored due to it being a high frequency event that is currently not needed as a zone.
+---@param event string
+---@param hitbox Hitbox
+---@param callback CursorEventHandler
+function cursor:zone(event, hitbox, callback)
+ self.zones[#self.zones + 1] = {event = event, hitbox = hitbox, handler = callback}
+end
+
+-- Binds a permanent cursor event handler active until manually unbound using `cursor:off()`.
+-- `_click` events are not available as permanent global events, only as zones.
+---@param event string
+---@param callback CursorEventHandler
+---@return fun() disposer Unbinds the event.
+function cursor:on(event, callback)
+ if self.handlers[event] and not itable_index_of(self.handlers[event], callback) then
+ self.handlers[event][#self.handlers[event] + 1] = callback
+ self:decide_keybinds()
+ end
+ return function() self:off(event, callback) end
+end
+
+-- Unbinds a cursor event handler.
+---@param event string
+function cursor:off(event, callback)
+ if self.handlers[event] then
+ local index = itable_index_of(self.handlers[event], callback)
+ if index then
+ table.remove(self.handlers[event], index)
+ self:decide_keybinds()
+ end
+ end
+end
+
+-- Binds a cursor event handler to be called once.
+---@param event string
+function cursor:once(event, callback)
+ local function callback_wrap()
+ callback()
+ self:off(event, callback_wrap)
+ end
+ return self:on(event, callback_wrap)
+end
+
+-- Trigger the event.
+---@param event string
+---@param shortcut? Shortcut
+function cursor:trigger(event, shortcut)
+ local forward = true
+
+ -- Call raw event handlers.
+ local zone = self:find_zone(event)
+ local callbacks = self.handlers[event]
+ if zone or #callbacks > 0 then
+ forward = false
+ if zone and shortcut then zone.handler(shortcut) end
+ for _, callback in ipairs(callbacks) do callback(shortcut) end
+ end
+
+ -- Call compound/parent (click) event handlers if both start and end events are within `parent_zone.hitbox`.
+ local parent = self.event_parent_map[event]
+ if parent then
+ local parent_zone = self:find_zone(parent.trigger_event)
+ if parent_zone then
+ forward = false -- Canceled here so we don't forward down events if they can lead to a click.
+ if parent.is_end then
+ local last_start_event = self.last_event[parent.start_event]
+ if last_start_event and point_collides_with(last_start_event, parent_zone.hitbox) and shortcut then
+ parent_zone.handler(create_shortcut('primary_click', shortcut.modifiers))
+ end
+ end
+ end
+ end
+
+ -- Forward unhandled events.
+ if forward then
+ local forward_name = self.event_forward_map[event]
+ if forward_name then
+ -- Forward events if there was no handler.
+ local active = find_active_keybindings(forward_name)
+ if active and active.cmd then
+ local is_wheel = event:find('wheel', 1, true)
+ local is_up = event:sub(-3) == '_up'
+ if active.owner then
+ -- Binding belongs to other script, so make it look like regular key event.
+ -- Mouse bindings are simple, other keys would require repeat and pressed handling,
+ -- which can't be done with mp.set_key_bindings(), but is possible with mp.add_key_binding().
+ local state = is_wheel and 'pm' or is_up and 'um' or 'dm'
+ local name = active.cmd:sub(active.cmd:find('/') + 1, -1)
+ mp.commandv('script-message-to', active.owner, 'key-binding', name, state, forward_name)
+ elseif is_wheel or is_up then
+ -- input.conf binding, react to button release for mouse buttons
+ mp.command(active.cmd)
+ end
+ end
+ end
+ end
+
+ -- Update last event position.
+ local last = self.last_event[event] or {}
+ last.x, last.y, last.time = self.x, self.y, mp.get_time()
+ self.last_event[event] = last
+
+ -- Refresh cursor autohide timer.
+ self:queue_autohide()
+end
+
+-- Enables or disables keybinding groups based on what event listeners are bound.
+function cursor:decide_keybinds()
+ local new_levels = {mbtn_left = 0, mbtn_right = 0, wheel = 0}
+ self.is_dragging_prevented = false
+
+ -- Check global events.
+ for name, handlers in ipairs(self.handlers) do
+ local binding = self.event_binding_map[name]
+ if binding then
+ new_levels[binding] = #handlers > 0 and 1 or 0
+ end
+ end
+
+ -- Check zones.
+ for _, zone in ipairs(self.zones) do
+ local binding = self.event_binding_map[zone.event]
+ if binding and cursor:collides_with(zone.hitbox) then
+ local new_level = (self.window_dragging_blockers[zone.event] and zone.hitbox.window_drag ~= true) and 2
+ or math.max(new_levels[binding], zone.hitbox.window_drag == false and 2 or 1)
+ new_levels[binding] = new_level
+ if new_level > 1 then
+ self.is_dragging_prevented = true
+ end
+ end
+ end
+
+ -- Window dragging only gets prevented when on top of an element, which is when double clicks should be ignored.
+ new_levels.mbtn_left_dbl = new_levels.mbtn_left == 2 and 2 or 0
+
+ for name, level in pairs(new_levels) do
+ if level ~= self.binding_levels[name] then
+ local flags = level == 1 and 'allow-vo-dragging+allow-hide-cursor' or ''
+ mp[(level == 0 and 'disable' or 'enable') .. '_key_bindings'](name, flags)
+ self.binding_levels[name] = level
+ self:queue_autohide()
+ end
+ end
+end
+
+function cursor:_find_history_sample()
+ local time = mp.get_time()
+ for _, e in self.history:iter_rev() do
+ if time - e.time > 0.1 then
+ return e
+ end
+ end
+ return self.history:tail()
+end
+
+-- Returns the current velocity vector in pixels per second.
+---@return Point
+function cursor:get_velocity()
+ local snap = self:_find_history_sample()
+ if snap then
+ local x, y, time = self.x - snap.x, self.y - snap.y, mp.get_time()
+ local time_diff = time - snap.time
+ if time_diff > 0.001 then
+ return {x = x / time_diff, y = y / time_diff}
+ end
+ end
+ return {x = 0, y = 0}
+end
+
+---@param x integer
+---@param y integer
+function cursor:move(x, y)
+ local old_x, old_y = self.x, self.y
+
+ -- mpv reports initial mouse position on linux as (0, 0), which always
+ -- displays the top bar, so we hardcode cursor position as infinity until
+ -- we receive a first real mouse move event with coordinates other than 0,0.
+ if not self.first_real_mouse_move_received then
+ if x > 0 and y > 0 then
+ self.first_real_mouse_move_received = true
+ else
+ x, y = math.huge, math.huge
+ end
+ end
+
+ -- Add 0.5 to be in the middle of the pixel
+ self.x, self.y = x + 0.5, y + 0.5
+
+ if old_x ~= self.x or old_y ~= self.y then
+ if self.x == math.huge or self.y == math.huge then
+ self.hidden = true
+ self.history:clear()
+
+ -- Slowly fadeout elements that are currently visible
+ for _, id in ipairs(config.cursor_leave_fadeout_elements) do
+ local element = Elements[id]
+ if element then
+ local visibility = element:get_visibility()
+ if visibility > 0 then
+ element:tween_property('forced_visibility', visibility, 0, function()
+ element.forced_visibility = nil
+ end)
+ end
+ end
+ end
+
+ Elements:update_proximities()
+ Elements:trigger('global_mouse_leave')
+ else
+ if self.hidden then
+ -- Cancel potential fadeouts
+ for _, id in ipairs(config.cursor_leave_fadeout_elements) do
+ if Elements[id] then Elements[id]:tween_stop() end
+ end
+
+ self.hidden = false
+ Elements:trigger('global_mouse_enter')
+ end
+
+ -- Update current move travel distance
+ -- `mp.get_time() - last.time < 0.5` check is there to ignore first event after long inactivity to
+ -- filter out big jumps due to window being repositioned/rescaled (e.g. opening a different file).
+ local last = self.last_event.move
+ if last and last.x < math.huge and last.y < math.huge and mp.get_time() - last.time < 0.5 then
+ self.distance = self.distance + get_point_to_point_proximity(cursor, last)
+ cursor.distance_reset_timer:kill()
+ cursor.distance_reset_timer:resume()
+ end
+
+ Elements:update_proximities()
+ -- Update history
+ self.history:insert({x = self.x, y = self.y, time = mp.get_time()})
+ end
+
+ Elements:proximity_trigger('mouse_move')
+ self:queue_autohide()
+ end
+
+ self:trigger('move')
+
+ request_render()
+end
+
+function cursor:leave() self:move(math.huge, math.huge) end
+
+function cursor:is_autohide_allowed()
+ return options.autohide and (not self.autohide_fs_only or state.fullscreen)
+ and not self.is_dragging_prevented
+ and not Menu:is_open()
+end
+mp.observe_property('cursor-autohide-fs-only', 'bool', function(_, val) cursor.autohide_fs_only = val end)
+
+-- Cursor auto-hiding after period of inactivity.
+function cursor:autohide()
+ if self:is_autohide_allowed() then
+ self:leave()
+ self.autohide_timer:kill()
+ end
+end
+
+function cursor:queue_autohide()
+ if self:is_autohide_allowed() then
+ self.autohide_timer:kill()
+ self.autohide_timer:resume()
+ end
+end
+
+-- Calculates distance in which cursor reaches rectangle if it continues moving on the same path.
+-- Returns `nil` if cursor is not moving towards the rectangle.
+---@param rect Rect
+function cursor:direction_to_rectangle_distance(rect)
+ local prev = self:_find_history_sample()
+ if not prev then return false end
+ local end_x, end_y = self.x + (self.x - prev.x) * 1e10, self.y + (self.y - prev.y) * 1e10
+ return get_ray_to_rectangle_distance(self.x, self.y, end_x, end_y, rect)
+end
+
+---@param event string
+---@param shortcut Shortcut
+---@param cb? fun(shortcut: Shortcut)
+function cursor:create_handler(event, shortcut, cb)
+ return function()
+ if cb then cb(shortcut) end
+ self:trigger(event, shortcut)
+ end
+end
+
+-- Movement
+function handle_mouse_pos(_, mouse)
+ if not mouse then return end
+ if cursor.last_hover and not mouse.hover then
+ cursor:leave()
+ elseif not (cursor.last_hover == false and mouse.hover == false) then -- filters out duplicate mouse out events
+ cursor:move(mouse.x, mouse.y)
+ end
+ cursor.last_hover = mouse.hover
+end
+mp.observe_property('mouse-pos', 'native', handle_mouse_pos)
+
+-- Key binding groups
+local modifiers = {nil, 'alt', 'alt+ctrl', 'alt+shift', 'alt+ctrl+shift', 'ctrl', 'ctrl+shift', 'shift'}
+local primary_bindings = {}
+for i = 1, #modifiers do
+ local mods = modifiers[i]
+ local mp_name = (mods and mods .. '+' or '') .. 'mbtn_left'
+ primary_bindings[#primary_bindings + 1] = {
+ mp_name,
+ cursor:create_handler('primary_up', create_shortcut('primary_up', mods)),
+ cursor:create_handler('primary_down', create_shortcut('primary_down', mods), function(...)
+ handle_mouse_pos(nil, mp.get_property_native('mouse-pos'))
+ end),
+ }
+end
+mp.set_key_bindings(primary_bindings, 'mbtn_left', 'force')
+mp.set_key_bindings({
+ {'mbtn_left_dbl', 'ignore'},
+}, 'mbtn_left_dbl', 'force')
+mp.set_key_bindings({
+ {
+ 'mbtn_right',
+ cursor:create_handler('secondary_up', create_shortcut('secondary_up')),
+ cursor:create_handler('secondary_down', create_shortcut('secondary_down')),
+ },
+}, 'mbtn_right', 'force')
+mp.set_key_bindings({
+ {'wheel_up', cursor:create_handler('wheel_up', create_shortcut('wheel_up'))},
+ {'wheel_down', cursor:create_handler('wheel_down', create_shortcut('wheel_down'))},
+}, 'wheel', 'force')
+
+return cursor
diff --git a/dotfiles/.config/mpv/scripts/uosc/lib/intl.lua b/dotfiles/.config/mpv/scripts/uosc/lib/intl.lua
new file mode 100644
index 0000000..79a7c64
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/lib/intl.lua
@@ -0,0 +1,68 @@
+local intl_dir = mp.get_script_directory() .. '/intl/'
+local locale = {}
+local cache = {}
+
+-- https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/supported-languages?pivots=store-installer-msix#list-of-supported-languages
+function get_languages()
+ local languages = {}
+
+ for _, lang in ipairs(comma_split(options.languages)) do
+ if (lang == 'slang') then
+ local slang = mp.get_property_native('slang')
+ if slang then
+ itable_append(languages, slang)
+ end
+ else
+ languages[#languages +1] = lang
+ end
+ end
+
+ return languages
+end
+
+---@param path string
+function get_locale_from_json(path)
+ local expand_path = mp.command_native({'expand-path', path})
+
+ local meta, meta_error = utils.file_info(expand_path)
+ if not meta or not meta.is_file then
+ return nil
+ end
+
+ local json_file = io.open(expand_path, 'r')
+ if not json_file then
+ return nil
+ end
+
+ local json = json_file:read('*all')
+ json_file:close()
+
+ local json_table = utils.parse_json(json)
+ return json_table
+end
+
+---@param text string
+function t(text, a)
+ if not text then return '' end
+ local key = text
+ if a then key = key .. '|' .. a end
+ if cache[key] then return cache[key] end
+ cache[key] = string.format(locale[text] or text, a or '')
+ return cache[key]
+end
+
+-- Load locales
+local languages = get_languages()
+
+for i = #languages, 1, -1 do
+ lang = languages[i]
+ if (lang:match('.json$')) then
+ table_assign(locale, get_locale_from_json(lang))
+ elseif (lang == 'en') then
+ locale = {}
+ else
+ table_assign(locale, get_locale_from_json(intl_dir .. lang:lower() .. '.json'))
+ end
+end
+
+return {t = t}
diff --git a/dotfiles/.config/mpv/scripts/uosc/lib/menus.lua b/dotfiles/.config/mpv/scripts/uosc/lib/menus.lua
new file mode 100644
index 0000000..d224e4f
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/lib/menus.lua
@@ -0,0 +1,1135 @@
+---@alias OpenCommandMenuOptions {submenu?: string; mouse_nav?: boolean; on_close?: string | string[]}
+---@param data MenuData
+---@param opts? OpenCommandMenuOptions
+function open_command_menu(data, opts)
+ opts = opts or {}
+ local menu
+
+ local function run_command(command)
+ if type(command) == 'table' then
+ ---@diagnostic disable-next-line: deprecated
+ mp.commandv(unpack(command))
+ else
+ mp.command(tostring(command))
+ end
+ end
+
+ local function callback(event)
+ if type(menu.root.callback) == 'table' then
+ ---@diagnostic disable-next-line: deprecated
+ mp.commandv(unpack(itable_join({'script-message-to'}, menu.root.callback, {utils.format_json(event)})))
+ elseif event.type == 'activate' then
+ -- Modifiers and actions are not available on basic non-callback mode menus.
+ -- `alt` modifier should activate without closing the menu.
+ if (event.modifiers == 'alt' or not event.modifiers) and not event.action then
+ run_command(event.value)
+ end
+ -- Convention: Only pure item activations should close the menu.
+ -- Using modifiers or triggering item actions should not.
+ if not event.keep_open and not event.modifiers and not event.action then
+ menu:close()
+ end
+ end
+ end
+
+ ---@type MenuOptions
+ local menu_opts = table_assign_props({}, opts, {'mouse_nav'})
+ menu = Menu:open(data, callback, menu_opts)
+ if opts.submenu then menu:activate_menu(opts.submenu) end
+ return menu
+end
+
+---@param opts? OpenCommandMenuOptions
+function toggle_menu_with_items(opts)
+ if Menu:is_open('menu') then
+ Menu:close()
+ else
+ open_command_menu({type = 'menu', items = get_menu_items(), search_submenus = true}, opts)
+ end
+end
+
+---@alias TrackEventRemove {type: 'remove' | 'delete', index: number; value: any;}
+---@alias TrackEventReload {type: 'reload', index: number; value: any;}
+---@param opts {type: string; title: string; list_prop: string; active_prop?: string; footnote?: string; serializer: fun(list: any, active: any): MenuDataItem[]; actions?: MenuAction[]; actions_place?: 'inside'|'outside'; on_paste: fun(event: MenuEventPaste); on_move?: fun(event: MenuEventMove); on_activate?: fun(event: MenuEventActivate); on_remove?: fun(event: TrackEventRemove); on_delete?: fun(event: TrackEventRemove); on_reload?: fun(event: TrackEventReload); on_key?: fun(event: MenuEventKey, close: fun())}
+function create_self_updating_menu_opener(opts)
+ return function()
+ if Menu:is_open(opts.type) then
+ Menu:close()
+ return
+ end
+ local list = mp.get_property_native(opts.list_prop)
+ local active = opts.active_prop and mp.get_property_native(opts.active_prop) or nil
+ local menu
+
+ local function update() menu:update_items(opts.serializer(list, active)) end
+
+ local ignore_initial_list = true
+ local function handle_list_prop_change(name, value)
+ if ignore_initial_list then
+ ignore_initial_list = false
+ else
+ list = value
+ update()
+ end
+ end
+
+ local ignore_initial_active = true
+ local function handle_active_prop_change(name, value)
+ if ignore_initial_active then
+ ignore_initial_active = false
+ else
+ active = value
+ update()
+ end
+ end
+
+ local function cleanup_and_close()
+ mp.unobserve_property(handle_list_prop_change)
+ mp.unobserve_property(handle_active_prop_change)
+ menu:close()
+ end
+
+ local initial_items, selected_index = opts.serializer(list, active)
+
+ ---@type MenuAction[]
+ local actions = opts.actions or {}
+ if opts.on_move then
+ actions[#actions + 1] = {
+ name = 'move_up',
+ icon = 'arrow_upward',
+ label = t('Move up') .. ' (ctrl+up/pgup/home)',
+ filter_hidden = true,
+ }
+ actions[#actions + 1] = {
+ name = 'move_down',
+ icon = 'arrow_downward',
+ label = t('Move down') .. ' (ctrl+down/pgdwn/end)',
+ filter_hidden = true,
+ }
+ end
+ if opts.on_reload then
+ actions[#actions + 1] = {name = 'reload', icon = 'refresh', label = t('Reload') .. ' (f5)'}
+ end
+ if opts.on_remove or opts.on_delete then
+ local label = (opts.on_remove and t('Remove') or t('Delete')) .. ' (del)'
+ if opts.on_remove and opts.on_delete then
+ label = t('Remove') .. ' (' .. t('%s to delete', 'del, ctrl+del') .. ')'
+ end
+ actions[#actions + 1] = {name = 'remove', icon = 'delete', label = label}
+ end
+
+ function remove_or_delete(index, value, menu_id, modifiers)
+ if opts.on_remove and opts.on_delete then
+ local method = modifiers == 'ctrl' and 'delete' or 'remove'
+ local handler = method == 'delete' and opts.on_delete or opts.on_remove
+ if handler then
+ handler({type = method, value = value, index = index})
+ end
+ elseif opts.on_remove or opts.on_delete then
+ local method = opts.on_delete and 'delete' or 'remove'
+ local handler = opts.on_delete or opts.on_remove
+ if handler then
+ handler({type = method, value = value, index = index})
+ end
+ end
+ end
+
+ -- Items and active_index are set in the handle_prop_change callback, since adding
+ -- a property observer triggers its handler immediately, we just let that initialize the items.
+ menu = Menu:open({
+ type = opts.type,
+ title = opts.title,
+ footnote = opts.footnote,
+ items = initial_items,
+ item_actions = actions,
+ item_actions_place = opts.actions_place,
+ selected_index = selected_index,
+ on_move = opts.on_move and 'callback' or nil,
+ on_paste = opts.on_paste and 'callback' or nil,
+ }, function(event)
+ if event.type == 'activate' then
+ if (event.action == 'move_up' or event.action == 'move_down') and opts.on_move then
+ local to_index = event.index + (event.action == 'move_up' and -1 or 1)
+ if to_index >= 1 and to_index <= #menu.current.items then
+ opts.on_move({
+ type = 'move',
+ from_index = event.index,
+ to_index = to_index,
+ menu_id = menu.current.id,
+ })
+ menu:select_index(to_index)
+ if not event.is_pointer then
+ menu:scroll_to_index(to_index, nil, true)
+ end
+ end
+ elseif event.action == 'reload' and opts.on_reload then
+ opts.on_reload({type = 'reload', index = event.index, value = event.value})
+ elseif event.action == 'remove' and (opts.on_remove or opts.on_delete) then
+ remove_or_delete(event.index, event.value, event.menu_id, event.modifiers)
+ else
+ opts.on_activate(event --[[@as MenuEventActivate]])
+ if not event.modifiers and not event.action then cleanup_and_close() end
+ end
+ elseif event.type == 'key' then
+ local item = event.selected_item
+ if event.id == 'enter' then
+ -- We get here when there's no selectable item in menu and user presses enter.
+ cleanup_and_close()
+ elseif event.key == 'f5' and opts.on_reload and item then
+ opts.on_reload({type = 'reload', index = item.index, value = item.value})
+ elseif event.key == 'del' and (opts.on_remove or opts.on_delete) and item then
+ if itable_has({nil, 'ctrl'}, event.modifiers) then
+ remove_or_delete(item.index, item.value, event.menu_id, event.modifiers)
+ end
+ elseif opts.on_key then
+ opts.on_key(event --[[@as MenuEventKey]], cleanup_and_close)
+ end
+ elseif event.type == 'paste' and opts.on_paste then
+ opts.on_paste(event --[[@as MenuEventPaste]])
+ elseif event.type == 'close' then
+ cleanup_and_close()
+ elseif event.type == 'move' and opts.on_move then
+ opts.on_move(event --[[@as MenuEventMove]])
+ elseif event.type == 'remove' and opts.on_move then
+ end
+ end)
+
+ mp.observe_property(opts.list_prop, 'native', handle_list_prop_change)
+ if opts.active_prop then
+ mp.observe_property(opts.active_prop, 'native', handle_active_prop_change)
+ end
+ end
+end
+
+---@param opts {title: string; type: string; prop: string; enable_prop?: string; secondary?: {prop: string; icon: string; enable_prop?: string}; load_command: string; download_command?: string}
+function create_select_tracklist_type_menu_opener(opts)
+ local snd = opts.secondary
+ local function get_props()
+ return tonumber(mp.get_property(opts.prop)), snd and tonumber(mp.get_property(snd.prop)) or nil
+ end
+
+ local function serialize_tracklist(tracklist)
+ local items = {}
+
+ if opts.load_command then
+ items[#items + 1] = {
+ title = t('Load'),
+ bold = true,
+ italic = true,
+ hint = t('open file'),
+ value = '{load}',
+ actions = opts.download_command
+ and {{name = 'download', icon = 'language', label = t('Search online')}}
+ or nil,
+ }
+ end
+ if #items > 0 then
+ items[#items].separator = true
+ end
+
+ local track_prop_index, snd_prop_index = get_props()
+ local filename = mp.get_property_native('filename/no-ext')
+ local escaped_filename = filename and regexp_escape(filename)
+ local first_item_index = #items + 1
+ local active_index = nil
+ local disabled_item = nil
+ local track_actions = nil
+ local track_external_actions = {}
+
+ if snd then
+ local action = {
+ name = 'as_secondary', icon = snd.icon, label = t('Use as secondary') .. ' (shift+enter/click)',
+ }
+ track_actions = {action}
+ table.insert(track_external_actions, action)
+ end
+ table.insert(track_external_actions, {name = 'reload', icon = 'refresh', label = t('Reload') .. ' (f5)'})
+ table.insert(track_external_actions, {name = 'remove', icon = 'delete', label = t('Remove') .. ' (del)'})
+
+ for _, track in ipairs(tracklist) do
+ if track.type == opts.type then
+ local hint_values = {}
+ local track_selected = track.selected and track.id == track_prop_index
+ local snd_selected = snd and track.id == snd_prop_index
+ local function h(value)
+ value = trim(value)
+ if #value > 0 then hint_values[#hint_values + 1] = value end
+ end
+
+ if track.lang then h(track.lang) end
+ if track['demux-h'] then
+ h(track['demux-w'] and (track['demux-w'] .. 'x' .. track['demux-h']) or (track['demux-h'] .. 'p'))
+ end
+ if track['demux-fps'] then h(string.format('%.5gfps', track['demux-fps'])) end
+ h(track.codec)
+ if track['audio-channels'] then
+ h(track['audio-channels'] == 1
+ and t('%s channel', track['audio-channels'])
+ or t('%s channels', track['audio-channels']))
+ end
+ if track['demux-samplerate'] then h(string.format('%.3gkHz', track['demux-samplerate'] / 1000)) end
+ if track.forced then h(t('forced')) end
+ if track.default then h(t('default')) end
+ if track.external then
+ local extension = track.title:match('%.([^%.]+)$')
+ if track.title and escaped_filename and extension then
+ track.title = trim(track.title:gsub(escaped_filename .. '%.?', ''):gsub('%.?([^%.]+)$', ''))
+ if track.title == '' or track.lang and track.title:lower() == track.lang:lower() then
+ track.title = nil
+ end
+ end
+ h(t('external'))
+ end
+
+ items[#items + 1] = {
+ title = (track.title and track.title or t('Track %s', track.id)),
+ hint = table.concat(hint_values, ', '),
+ value = track.id,
+ active = track_selected or snd_selected,
+ italic = snd_selected,
+ icon = snd and snd_selected and snd.icon or nil,
+ actions = track.external and track_external_actions or track_actions,
+ }
+
+ if track_selected then
+ if disabled_item then disabled_item.active = false end
+ active_index = #items
+ end
+ end
+ end
+
+ return items, active_index or first_item_index
+ end
+
+ local function reload(id)
+ if id then mp.commandv(opts.type .. '-reload', id) end
+ end
+ local function remove(id)
+ if id then mp.commandv(opts.type .. '-remove', id) end
+ end
+
+ ---@param event MenuEventActivate
+ local function handle_activate(event)
+ if event.value == '{load}' then
+ mp.command(event.action == 'download' and opts.download_command or opts.load_command)
+ else
+ if snd and (event.action == 'as_secondary' or event.modifiers == 'shift') then
+ local _, snd_track_index = get_props()
+ mp.commandv('set', snd.prop, event.value == snd_track_index and 'no' or event.value)
+ if snd.enable_prop then
+ mp.commandv('set', snd.enable_prop, 'yes')
+ end
+ elseif event.action == 'reload' then
+ reload(event.value)
+ elseif event.action == 'remove' then
+ remove(event.value)
+ elseif not event.modifiers or event.modifiers == 'alt' then
+ mp.commandv('set', opts.prop, event.value == get_props() and 'no' or event.value)
+ if opts.enable_prop then
+ mp.commandv('set', opts.enable_prop, 'yes')
+ end
+ end
+ end
+ end
+
+ ---@param event MenuEventKey
+ local function handle_key(event)
+ if event.selected_item then
+ if event.id == 'f5' then
+ reload(event.selected_item.value)
+ elseif event.id == 'del' then
+ remove(event.selected_item.value)
+ end
+ end
+ end
+
+ return create_self_updating_menu_opener({
+ title = opts.title,
+ footnote = t('Toggle to disable.') .. ' ' .. t('Paste path or url to add.'),
+ type = opts.type,
+ list_prop = 'track-list',
+ serializer = serialize_tracklist,
+ on_activate = handle_activate,
+ on_key = handle_key,
+ actions_place = 'outside',
+ on_paste = function(event) load_track(opts.type, event.value) end,
+ })
+end
+
+---@alias NavigationMenuOptions {type: string, title?: string, allowed_types?: string[], file_actions?: MenuAction[], directory_actions?: MenuAction[], active_path?: string, selected_path?: string; on_close?: fun()}
+
+-- Opens a file navigation menu with items inside `directory_path`.
+---@param directory_path string
+---@param handle_activate fun(event: MenuEventActivate)
+---@param opts NavigationMenuOptions
+function open_file_navigation_menu(directory_path, handle_activate, opts)
+ if directory_path == '{drives}' then
+ if state.platform ~= 'windows' then directory_path = '/' end
+ else
+ directory_path = normalize_path(mp.command_native({'expand-path', directory_path}))
+ end
+
+ opts = opts or {}
+ ---@type string|nil
+ local current_directory = nil
+ ---@type Menu
+ local menu
+ ---@type string | nil
+ local back_path
+ local separator = path_separator(directory_path)
+
+ ---@param path string Can be path to a directory, or special string `'{drives}'` to get windows drives items.
+ ---@param selected_path? string Marks item with this path as active.
+ ---@return MenuStackChild[] menu_items
+ ---@return number selected_index
+ ---@return string|nil error
+ local function serialize_items(path, selected_path)
+ if path == '{drives}' then
+ local process = mp.command_native({
+ name = 'subprocess',
+ capture_stdout = true,
+ playback_only = false,
+ args = {'fsutil', 'fsinfo', 'drives'},
+ })
+ local items, selected_index = {}, 1
+
+ if process.status == 0 then
+ for drive in process.stdout:gmatch("(%a:)\\") do
+ if drive then
+ local drive_path = normalize_path(drive)
+ items[#items + 1] = {
+ title = drive, hint = t('drive'), value = drive_path, active = opts.active_path == drive_path,
+ }
+ if selected_path == drive_path then selected_index = #items end
+ end
+ end
+ else
+ return {}, 1, 'Couldn\'t open drives. Error: ' .. utils.to_string(process.stderr)
+ end
+ return items, selected_index
+ end
+
+ local serialized = serialize_path(path)
+ if not serialized then
+ return {}, 0, 'Couldn\'t serialize path "' .. path .. '.'
+ end
+ local files, directories, error = read_directory(serialized.path, {
+ types = opts.allowed_types,
+ hidden = options.show_hidden_files,
+ })
+ if error then
+ return {}, 1, error
+ end
+ local is_root = not serialized.dirname
+
+ if not files or not directories then return {}, 0 end
+
+ sort_strings(directories)
+ sort_strings(files)
+
+ -- Pre-populate items with parent directory selector if not at root
+ -- Each item value is a serialized path table it points to.
+ local items = {}
+
+ if is_root then
+ if state.platform == 'windows' then
+ items[#items + 1] = {title = '..', hint = t('Drives'), value = '{drives}', separator = true, is_to_parent = true}
+ end
+ else
+ items[#items + 1] = {title = '..', hint = t('parent dir'), value = serialized.dirname, separator = true, is_to_parent = true}
+ end
+
+ back_path = items[#items] and items[#items].value
+ local selected_index = #items + 1
+
+ for _, dir in ipairs(directories) do
+ items[#items + 1] = {
+ title = dir .. ' ' .. separator,
+ value = join_path(path, dir),
+ bold = true,
+ actions = opts
+ .directory_actions,
+ }
+ end
+
+ for _, file in ipairs(files) do
+ items[#items + 1] = {title = file, value = join_path(path, file), actions = opts.file_actions}
+ end
+
+ for index, item in ipairs(items) do
+ if not item.is_to_parent then
+ if opts.active_path == item.value then
+ item.active = true
+ if not selected_path then selected_index = index end
+ end
+
+ if selected_path == item.value then selected_index = index end
+ end
+ end
+
+ return items, selected_index
+ end
+
+ local menu_data = {
+ type = opts.type,
+ title = opts.title or '',
+ footnote = t('%s to go up in tree.', 'alt+up') .. ' ' .. t('Paste path or url to open.'),
+ items = {},
+ on_paste = 'callback',
+ }
+
+ ---@param path string
+ local function open_directory(path)
+ local items, selected_index, error = serialize_items(path, current_directory)
+ if error then
+ msg.error(error)
+ items = {{title = 'Something went wrong. See console for errors.', selectable = false, muted = true}}
+ end
+
+ local title = opts.title
+ if not title then
+ if path == '{drives}' then
+ title = 'Drives'
+ else
+ local serialized = serialize_path(path)
+ title = serialized and serialized.basename .. separator or '??'
+ end
+ end
+
+ current_directory = path
+ menu_data.title = title
+ menu_data.items = items
+ menu:search_cancel()
+ menu:update(menu_data)
+ menu:select_index(selected_index)
+ menu:scroll_to_index(selected_index, nil, true)
+ end
+
+ local function close()
+ menu:close()
+ if opts.on_close then opts.on_close() end
+ end
+
+ ---@param event MenuEventActivate
+ ---@param only_if_dir? boolean Activate item only if it's a directory.
+ local function activate(event, only_if_dir)
+ local path = event.value
+ local is_drives = path == '{drives}'
+
+ if is_drives then
+ open_directory(path)
+ return
+ end
+
+ local info, error = utils.file_info(path)
+
+ if not info then
+ msg.error('Can\'t retrieve path info for "' .. path .. '". Error: ' .. (error or ''))
+ return
+ end
+
+ if info.is_dir and not event.modifiers and not event.action then
+ open_directory(path)
+ elseif not only_if_dir then
+ handle_activate(event)
+ end
+ end
+
+ menu = Menu:open(menu_data, function(event)
+ if event.type == 'activate' then
+ activate(event --[[@as MenuEventActivate]])
+ elseif event.type == 'back' or event.type == 'key' and itable_has({'alt+up', 'left'}, event.id) then
+ if back_path then open_directory(back_path) end
+ elseif event.type == 'paste' then
+ handle_activate({type = 'activate', value = event.value})
+ elseif event.type == 'key' then
+ if event.id == 'right' then
+ local selected_item = event.selected_item
+ if selected_item then
+ activate(table_assign({}, selected_item, {type = 'activate'}), true)
+ end
+ elseif event.id == 'ctrl+c' and event.selected_item then
+ set_clipboard(event.selected_item.value)
+ end
+ elseif event.type == 'close' then
+ close()
+ end
+ end)
+
+ open_directory(directory_path)
+
+ return menu
+end
+
+-- On demand menu items loading
+do
+ ---@type {key: string; cmd: string; comment: string; is_menu_item: boolean}[]|nil
+ local all_user_bindings = nil
+ ---@type MenuStackItem[]|nil
+ local menu_items = nil
+
+ local function is_uosc_menu_comment(v) return v:match('^!') or v:match('^menu:') end
+
+ -- Returns all relevant bindings from `input.conf`, even if they are overwritten
+ -- (same key bound to something else later) or have no keys (uosc menu items).
+ function get_all_user_bindings()
+ if all_user_bindings then return all_user_bindings end
+ all_user_bindings = {}
+
+ local input_conf_property = mp.get_property_native('input-conf')
+ local input_conf_iterator
+ if input_conf_property:sub(1, 9) == 'memory://' then
+ -- mpv.net v7
+ local input_conf_lines = split(input_conf_property:sub(10), '\n')
+ local i = 0
+ input_conf_iterator = function()
+ i = i + 1
+ return input_conf_lines[i]
+ end
+ else
+ local input_conf = input_conf_property == '' and '~~/input.conf' or input_conf_property
+ local input_conf_path = mp.command_native({'expand-path', input_conf})
+ local input_conf_meta, meta_error = utils.file_info(input_conf_path)
+
+ -- File doesn't exist
+ if not input_conf_meta or not input_conf_meta.is_file then
+ menu_items = create_default_menu_items()
+ return menu_items, all_user_bindings
+ end
+
+ input_conf_iterator = io.lines(input_conf_path)
+ end
+
+ for line in input_conf_iterator do
+ local key, command, comment = string.match(line, '%s*([%S]+)%s+([^#]*)%s*(.-)%s*$')
+ local is_commented_out = key and key:sub(1, 1) == '#'
+
+ if comment and #comment > 0 then comment = comment:sub(2) end
+ if command then command = trim(command) end
+
+ local is_menu_item = comment and is_uosc_menu_comment(comment)
+
+ if key
+ -- Filter out stuff like `#F2`, which is clearly intended to be disabled
+ and not (is_commented_out and #key > 1)
+ -- Filter out comments that are not uosc menu items
+ and (not is_commented_out or is_menu_item) then
+ all_user_bindings[#all_user_bindings + 1] = {
+ key = key,
+ cmd = command,
+ comment = comment or '',
+ is_menu_item = is_menu_item,
+ }
+ end
+ end
+
+ return all_user_bindings
+ end
+
+ function get_menu_items()
+ if menu_items then return menu_items end
+
+ local all_user_bindings = get_all_user_bindings()
+ local main_menu = {items = {}, items_by_command = {}}
+ local by_id = {}
+
+ for _, bind in ipairs(all_user_bindings) do
+ local key, command, comment = bind.key, bind.cmd, bind.comment
+ local title = ''
+
+ if comment then
+ local comments = split(comment, '#')
+ local titles = itable_filter(comments, is_uosc_menu_comment)
+ if titles and #titles > 0 then
+ title = titles[1]:match('^!%s*(.*)%s*') or titles[1]:match('^menu:%s*(.*)%s*')
+ end
+ end
+
+ if title ~= '' then
+ local is_dummy = key:sub(1, 1) == '#'
+ local submenu_id = ''
+ local target_menu = main_menu
+ local title_parts = split(title or '', ' *> *')
+
+ for index, title_part in ipairs(#title_parts > 0 and title_parts or {''}) do
+ if index < #title_parts then
+ submenu_id = submenu_id .. title_part
+
+ if not by_id[submenu_id] then
+ local items = {}
+ by_id[submenu_id] = {items = items, items_by_command = {}}
+ target_menu.items[#target_menu.items + 1] = {title = title_part, items = items}
+ end
+
+ target_menu = by_id[submenu_id]
+ else
+ -- If command is already in menu, just append the key to it
+ if key ~= '#' and command ~= '' and target_menu.items_by_command[command] then
+ local hint = target_menu.items_by_command[command].hint
+ target_menu.items_by_command[command].hint = hint and hint .. ', ' .. key or key
+ else
+ -- Separator
+ if title_part:sub(1, 3) == '---' then
+ local last_item = target_menu.items[#target_menu.items]
+ if last_item then last_item.separator = true end
+ elseif command ~= 'ignore' then
+ local item = {
+ title = title_part,
+ hint = not is_dummy and key or nil,
+ value = command,
+ }
+ if command == '' then
+ item.selectable = false
+ item.muted = true
+ item.italic = true
+ else
+ target_menu.items_by_command[command] = item
+ end
+ target_menu.items[#target_menu.items + 1] = item
+ end
+ end
+ end
+ end
+ end
+ end
+
+ menu_items = #main_menu.items > 0 and main_menu.items or create_default_menu_items()
+ return menu_items
+ end
+end
+
+-- Adapted from `stats.lua`
+function get_keybinds_items()
+ local items = {}
+ -- uosc and mpv-menu-plugin binds with no keys
+ local no_key_menu_binds = itable_filter(
+ get_all_user_bindings(),
+ function(b) return b.is_menu_item and b.cmd and b.cmd ~= '' and (b.key == '#' or b.key == '_') end
+ )
+ local binds_dump = itable_join(find_active_keybindings(), no_key_menu_binds)
+ local ids = {}
+
+ -- Convert to menu items
+ for _, bind in pairs(binds_dump) do
+ local id = bind.key .. '<>' .. bind.cmd
+ if not ids[id] then
+ ids[id] = true
+ items[#items + 1] = {title = bind.cmd, hint = bind.key, value = bind.cmd}
+ end
+ end
+
+ -- Sort
+ table.sort(items, function(a, b) return a.title < b.title end)
+
+ return #items > 0 and items or {
+ {
+ title = t('%s are empty', '`input-bindings`'),
+ selectable = false,
+ align = 'center',
+ italic = true,
+ muted = true,
+ },
+ }
+end
+
+function open_stream_quality_menu()
+ if Menu:is_open('stream-quality') then
+ Menu:close()
+ return
+ end
+
+ local ytdl_format = mp.get_property_native('ytdl-format')
+ local items = {}
+ ---@type Menu
+ local menu
+
+ for _, height in ipairs(config.stream_quality_options) do
+ local format = 'bestvideo[height<=?' .. height .. ']+bestaudio/best[height<=?' .. height .. ']'
+ items[#items + 1] = {title = height .. 'p', value = format, active = format == ytdl_format}
+ end
+
+ menu = Menu:open({type = 'stream-quality', title = t('Stream quality'), items = items}, function(event)
+ if event.type == 'activate' then
+ mp.set_property('ytdl-format', event.value)
+
+ -- Reload the video to apply new format
+ -- This is taken from https://github.com/jgreco/mpv-youtube-quality
+ -- which is in turn taken from https://github.com/4e6/mpv-reload/
+ local duration = mp.get_property_native('duration')
+ local time_pos = mp.get_property('time-pos')
+
+ mp.command('playlist-play-index current')
+
+ -- Tries to determine live stream vs. pre-recorded VOD. VOD has non-zero
+ -- duration property. When reloading VOD, to keep the current time position
+ -- we should provide offset from the start. Stream doesn't have fixed start.
+ -- Decent choice would be to reload stream from it's current 'live' position.
+ -- That's the reason we don't pass the offset when reloading streams.
+ if duration and duration > 0 then
+ local function seeker()
+ mp.commandv('seek', time_pos, 'absolute')
+ mp.unregister_event(seeker)
+ end
+ mp.register_event('file-loaded', seeker)
+ end
+
+ if not event.alt then menu:close() end
+ end
+ end)
+end
+
+function open_open_file_menu()
+ if Menu:is_open('open-file') then
+ Menu:close()
+ return
+ end
+
+ ---@type Menu | nil
+ local menu
+ local directory
+ local active_file
+
+ if state.path == nil or is_protocol(state.path) then
+ directory = options.default_directory
+ active_file = nil
+ else
+ local serialized = serialize_path(state.path)
+ if serialized then
+ directory = serialized.dirname
+ active_file = serialized.path
+ end
+ end
+
+ if not directory then
+ msg.error('Couldn\'t serialize path "' .. state.path .. '".')
+ return
+ end
+
+ -- Update active file in directory navigation menu
+ local function handle_file_loaded()
+ if menu and menu:is_alive() then
+ menu:activate_one_value(normalize_path(mp.get_property_native('path')))
+ end
+ end
+
+ menu = open_file_navigation_menu(
+ directory,
+ function(event)
+ if not menu then return end
+ local command = has_any_extension(event.value, config.types.playlist) and 'loadlist' or 'loadfile'
+ if event.modifiers == 'shift' or event.action == 'add_to_playlist' then
+ mp.commandv(command, event.value, 'append')
+ local serialized = serialize_path(event.value)
+ local filename = serialized and serialized.basename or event.value
+ mp.commandv('show-text', t('Added to playlist') .. ': ' .. filename, 3000)
+ elseif itable_has({nil, 'ctrl', 'alt', 'alt+ctrl'}, event.modifiers) and itable_has({nil, 'force_open'}, event.action) then
+ mp.commandv(command, event.value)
+ if not event.alt then menu:close() end
+ end
+ end,
+ {
+ type = 'open-file',
+ allowed_types = config.types.media,
+ active_path = active_file,
+ directory_actions = {
+ {name = 'add_to_playlist', icon = 'playlist_add', label = t('Add to playlist') .. ' (shift+enter/click)'},
+ {name = 'force_open', icon = 'play_circle_outline', label = t('Open in mpv') .. ' (ctrl+enter/click)'},
+ },
+ file_actions = {
+ {name = 'add_to_playlist', icon = 'playlist_add', label = t('Add to playlist') .. ' (shift+enter/click)'},
+ },
+ keep_open = true,
+ on_close = function() mp.unregister_event(handle_file_loaded) end,
+ }
+ )
+ if menu then mp.register_event('file-loaded', handle_file_loaded) end
+end
+
+---@param opts {prop: 'sub'|'audio'|'video'; title: string; loaded_message: string; allowed_types: string[]}
+function create_track_loader_menu_opener(opts)
+ local menu_type = 'load-' .. opts.prop
+ return function()
+ if Menu:is_open(menu_type) then
+ Menu:close()
+ return
+ end
+
+ ---@type Menu
+ local menu
+ local path = state.path
+ if path then
+ if is_protocol(path) then
+ path = false
+ else
+ local serialized_path = serialize_path(path)
+ path = serialized_path ~= nil and serialized_path.dirname or false
+ end
+ end
+ if not path then
+ path = options.default_directory
+ end
+
+ local function handle_activate(event)
+ load_track(opts.prop, event.value)
+ local serialized = serialize_path(event.value)
+ local filename = serialized and serialized.basename or event.value
+ mp.commandv('show-text', opts.loaded_message .. ': ' .. filename, 3000)
+ if not event.alt then menu:close() end
+ end
+
+ menu = open_file_navigation_menu(path, handle_activate, {
+ type = menu_type, title = opts.title, allowed_types = opts.allowed_types,
+ })
+ end
+end
+
+function open_subtitle_downloader()
+ local menu_type = 'download-subtitles'
+ ---@type Menu
+ local menu
+
+ if Menu:is_open(menu_type) then
+ Menu:close()
+ return
+ end
+
+ local search_suggestion, file_path, destination_directory = '', nil, nil
+ local credentials = {'--api-key', config.open_subtitles_api_key, '--agent', config.open_subtitles_agent}
+
+ if state.path then
+ if is_protocol(state.path) then
+ if not is_protocol(state.title) then search_suggestion = state.title end
+ else
+ local serialized_path = serialize_path(state.path)
+ if serialized_path then
+ search_suggestion = serialized_path.filename
+ file_path = state.path
+ destination_directory = serialized_path.dirname
+ end
+ end
+ end
+
+ local force_destination = options.subtitles_directory:sub(1, 1) == '!'
+ if force_destination or not destination_directory then
+ local subtitles_directory = options.subtitles_directory:sub(force_destination and 2 or 1)
+ destination_directory = mp.command_native({'expand-path', subtitles_directory})
+ end
+
+ local handle_download, handle_search
+
+ -- Checks if there an error, or data is invalid. If true, reports the error,
+ -- updates menu to inform about it, and returns true.
+ ---@param error string|nil
+ ---@param data any
+ ---@param check_is_valid? fun(data: any):boolean
+ ---@return boolean abort Whether the further response handling should be aborted.
+ local function should_abort(error, data, check_is_valid)
+ if error or not data or (not check_is_valid or not check_is_valid(data)) then
+ menu:update_items({
+ {
+ title = t('Something went wrong.'),
+ align = 'center',
+ muted = true,
+ italic = true,
+ selectable = false,
+ },
+ {
+ title = t('See console for details.'),
+ align = 'center',
+ muted = true,
+ italic = true,
+ selectable = false,
+ },
+ })
+ msg.error(error or ('Invalid response: ' .. (utils.format_json(data) or tostring(data))))
+ return true
+ end
+ return false
+ end
+
+ ---@param data {kind: 'file', id: number}|{kind: 'page', query: string, page: number}
+ handle_download = function(data)
+ if data.kind == 'page' then
+ handle_search(data.query, data.page)
+ return
+ end
+
+ menu = Menu:open({
+ type = menu_type .. '-result',
+ search_style = 'disabled',
+ items = {{icon = 'spinner', align = 'center', selectable = false, muted = true}},
+ }, function(event)
+ if event.type == 'key' and event.key == 'enter' then
+ menu:close()
+ end
+ end)
+
+ local args = itable_join({'download-subtitles'}, credentials, {
+ '--file-id', tostring(data.id),
+ '--destination', destination_directory,
+ })
+
+ call_ziggy_async(args, function(error, data)
+ if not menu:is_alive() then return end
+ if should_abort(error, data, function(data) return type(data.file) == 'string' end) then return end
+
+ load_track('sub', data.file)
+
+ menu:update_items({
+ {
+ title = t('Subtitles loaded & enabled'),
+ bold = true,
+ icon = 'check',
+ selectable = false,
+ },
+ {
+ title = t('Remaining downloads today: %s', data.remaining .. '/' .. data.total),
+ italic = true,
+ muted = true,
+ icon = 'file_download',
+ selectable = false,
+ },
+ {
+ title = t('Resets in: %s', data.reset_time),
+ italic = true,
+ muted = true,
+ icon = 'schedule',
+ selectable = false,
+ },
+ })
+ end)
+ end
+
+ ---@param query string
+ ---@param page number|nil
+ handle_search = function(query, page)
+ if not menu:is_alive() then return end
+ page = math.max(1, type(page) == 'number' and round(page) or 1)
+
+ menu:update_items({{icon = 'spinner', align = 'center', selectable = false, muted = true}})
+
+ local args = itable_join({'search-subtitles'}, credentials)
+
+ local languages = itable_filter(get_languages(), function(lang) return lang:match('.json$') == nil end)
+ args[#args + 1] = '--languages'
+ args[#args + 1] = table.concat(table_keys(create_set(languages)), ',') -- deduplicates stuff like `en,eng,en`
+
+ args[#args + 1] = '--page'
+ args[#args + 1] = tostring(page)
+
+ if file_path then
+ args[#args + 1] = '--hash'
+ args[#args + 1] = file_path
+ end
+
+ if query and #query > 0 then
+ args[#args + 1] = '--query'
+ args[#args + 1] = query
+ end
+
+ call_ziggy_async(args, function(error, data)
+ if not menu:is_alive() then return end
+
+ local function check_is_valid(data)
+ return type(data.data) == 'table' and data.page and data.total_pages
+ end
+
+ if should_abort(error, data, check_is_valid) then return end
+
+ local subs = itable_filter(data.data, function(sub)
+ return sub and sub.attributes and sub.attributes.release and type(sub.attributes.files) == 'table' and
+ #sub.attributes.files > 0
+ end)
+ local items = itable_map(subs, function(sub)
+ local hints = {sub.attributes.language}
+ if sub.attributes.foreign_parts_only then hints[#hints + 1] = t('foreign parts only') end
+ if sub.attributes.hearing_impaired then hints[#hints + 1] = t('hearing impaired') end
+ local url = sub.attributes.url
+ return {
+ title = sub.attributes.release,
+ hint = table.concat(hints, ', '),
+ value = {kind = 'file', id = sub.attributes.files[1].file_id, url = url},
+ keep_open = true,
+ actions = url and
+ {{name = 'open_in_browser', icon = 'open_in_new', label = t('Open in browser') .. ' (shift)'}},
+ }
+ end)
+
+ if #items == 0 then
+ items = {
+ {title = t('no results'), align = 'center', muted = true, italic = true, selectable = false},
+ }
+ end
+
+ if data.page > 1 then
+ items[#items + 1] = {
+ title = t('Previous page'),
+ align = 'center',
+ bold = true,
+ italic = true,
+ icon = 'navigate_before',
+ keep_open = true,
+ value = {kind = 'page', query = query, page = data.page - 1},
+ }
+ end
+
+ if data.page < data.total_pages then
+ items[#items + 1] = {
+ title = t('Next page'),
+ align = 'center',
+ bold = true,
+ italic = true,
+ icon = 'navigate_next',
+ keep_open = true,
+ value = {kind = 'page', query = query, page = data.page + 1},
+ }
+ end
+
+ menu:update_items(items)
+ end)
+ end
+
+ local initial_items = {
+ {title = t('%s to search', 'enter'), align = 'center', muted = true, italic = true, selectable = false},
+ }
+
+ menu = Menu:open(
+ {
+ type = menu_type,
+ title = t('enter query'),
+ items = initial_items,
+ search_style = 'palette',
+ on_search = 'callback',
+ search_debounce = 'submit',
+ search_suggestion = search_suggestion,
+ },
+ function(event)
+ if event.type == 'activate' then
+ if event.action == 'open_in_browser' or event.modifiers == 'shift' then
+ local command = ({
+ windows = 'explorer',
+ linux = 'xdg-open',
+ darwin = 'open',
+ })[state.platform]
+ local url = event.value.url
+ mp.command_native_async({
+ name = 'subprocess',
+ capture_stderr = true,
+ capture_stdout = true,
+ playback_only = false,
+ args = {command, url},
+ }, function(success, result, error)
+ if not success then
+ local err_str = utils.to_string(error or result.stderr)
+ msg.error('Error trying to open url "' .. url .. '" in browser: ' .. err_str)
+ end
+ end)
+ elseif not event.action then
+ handle_download(event.value)
+ end
+ elseif event.type == 'search' then
+ handle_search(event.query)
+ end
+ end
+ )
+end
diff --git a/dotfiles/.config/mpv/scripts/uosc/lib/std.lua b/dotfiles/.config/mpv/scripts/uosc/lib/std.lua
new file mode 100644
index 0000000..9a7b877
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/lib/std.lua
@@ -0,0 +1,363 @@
+--[[ Stateless utilities missing in lua standard library ]]
+
+---@alias Shortcut {id: string; key: string; modifiers?: string; alt: boolean; ctrl: boolean; shift: boolean}
+
+---@param number number
+function round(number) return math.floor(number + 0.5) end
+
+---@param min number
+---@param value number
+---@param max number
+function clamp(min, value, max) return math.max(min, math.min(value, max)) end
+
+---@param rgba string `rrggbb` or `rrggbbaa` hex string.
+function serialize_rgba(rgba)
+ local a = rgba:sub(7, 8)
+ return {
+ color = rgba:sub(5, 6) .. rgba:sub(3, 4) .. rgba:sub(1, 2),
+ opacity = clamp(0, tonumber(#a == 2 and a or 'ff', 16) / 255, 1),
+ }
+end
+
+-- Trim any white space from the start and end of the string.
+---@param str string
+---@return string
+function trim(str) return str:match('^%s*(.-)%s*$') end
+
+-- Trim any `char` from the end of the string.
+---@param str string
+---@param char string
+---@return string
+function trim_end(str, char)
+ local char, end_i = char:byte(), 0
+ for i = #str, 1, -1 do
+ if str:byte(i) ~= char then
+ end_i = i
+ break
+ end
+ end
+ return str:sub(1, end_i)
+end
+
+---@param str string
+---@param pattern string
+---@return string[]
+function split(str, pattern)
+ local list = {}
+ local full_pattern = '(.-)' .. pattern
+ local last_end = 1
+ local start_index, end_index, capture = str:find(full_pattern, 1)
+ while start_index do
+ list[#list + 1] = capture
+ last_end = end_index + 1
+ start_index, end_index, capture = str:find(full_pattern, last_end)
+ end
+ if last_end <= (#str + 1) then
+ capture = str:sub(last_end)
+ list[#list + 1] = capture
+ end
+ return list
+end
+
+-- Handles common option and message inputs that need to be split by comma when strings.
+---@param input string|string[]|nil
+---@return string[]
+function comma_split(input)
+ if not input then return {} end
+ if type(input) == 'table' then return itable_map(input, tostring) end
+ local str = tostring(input)
+ return str:match('^%s*$') and {} or split(str, ' *, *')
+end
+
+-- Get index of the last appearance of `sub` in `str`.
+---@param str string
+---@param sub string
+---@return integer|nil
+function string_last_index_of(str, sub)
+ local sub_length = #sub
+ for i = #str, 1, -1 do
+ for j = 1, sub_length do
+ if str:byte(i + j - 1) ~= sub:byte(j) then break end
+ if j == sub_length then return i end
+ end
+ end
+end
+
+-- Escapes a string to be used in a matching expression.
+---@param value string
+function regexp_escape(value)
+ return string.gsub(value, '[%(%)%.%+%-%*%?%[%]%^%$%%]', '%%%1')
+end
+
+---@param itable table
+---@param value any
+---@return integer|nil
+function itable_index_of(itable, value)
+ for index = 1, #itable do
+ if itable[index] == value then return index end
+ end
+end
+
+---@param itable table
+---@param value any
+---@return boolean
+function itable_has(itable, value)
+ return itable_index_of(itable, value) ~= nil
+end
+
+---@param itable table
+---@param compare fun(value: any, index: number): boolean|integer|string|nil
+---@param from? number Where to start search, defaults to `1`.
+---@param to? number Where to end search, defaults to `#itable`.
+---@return number|nil index
+---@return any|nil value
+function itable_find(itable, compare, from, to)
+ from, to = from or 1, to or #itable
+ for index = from, to, from < to and 1 or -1 do
+ if index > 0 and index <= #itable and compare(itable[index], index) then
+ return index, itable[index]
+ end
+ end
+end
+
+---@param itable table
+---@param decider fun(value: any, index: number): boolean|integer|string|nil
+function itable_filter(itable, decider)
+ local filtered = {}
+ for index, value in ipairs(itable) do
+ if decider(value, index) then filtered[#filtered + 1] = value end
+ end
+ return filtered
+end
+
+---@param itable table
+---@param value any
+function itable_delete_value(itable, value)
+ for index = 1, #itable, 1 do
+ if itable[index] == value then table.remove(itable, index) end
+ end
+ return itable
+end
+
+---@param itable table
+---@param transformer fun(value: any, index: number) : any
+function itable_map(itable, transformer)
+ local result = {}
+ for index, value in ipairs(itable) do
+ result[index] = transformer(value, index)
+ end
+ return result
+end
+
+---@param itable table
+---@param start_pos? integer
+---@param end_pos? integer
+function itable_slice(itable, start_pos, end_pos)
+ start_pos = start_pos and start_pos or 1
+ end_pos = end_pos and end_pos or #itable
+
+ if end_pos < 0 then end_pos = #itable + end_pos + 1 end
+ if start_pos < 0 then start_pos = #itable + start_pos + 1 end
+
+ local new_table = {}
+ for index, value in ipairs(itable) do
+ if index >= start_pos and index <= end_pos then
+ new_table[#new_table + 1] = value
+ end
+ end
+ return new_table
+end
+
+---@generic T
+---@param ...T[]|nil
+---@return T[]
+function itable_join(...)
+ local args, result = {...}, {}
+ for i = 1, select('#', ...) do
+ if args[i] then for _, value in ipairs(args[i]) do result[#result + 1] = value end end
+ end
+ return result
+end
+
+---@param target any[]
+---@param source any[]
+function itable_append(target, source)
+ for _, value in ipairs(source) do target[#target + 1] = value end
+ return target
+end
+
+function itable_clear(itable)
+ for i = #itable, 1, -1 do itable[i] = nil end
+end
+
+---@generic T
+---@param input table
+---@return T[]
+function table_keys(input)
+ local keys = {}
+ for key, _ in pairs(input) do keys[#keys + 1] = key end
+ return keys
+end
+
+---@generic T
+---@param input table
+---@return T[]
+function table_values(input)
+ local values = {}
+ for _, value in pairs(input) do values[#values + 1] = value end
+ return values
+end
+
+---@generic T: table
+---@param target T
+---@param ... T|nil
+---@return T
+function table_assign(target, ...)
+ local args = {...}
+ for i = 1, select('#', ...) do
+ if type(args[i]) == 'table' then for key, value in pairs(args[i]) do target[key] = value end end
+ end
+ return target
+end
+
+---@generic T: table
+---@param target T
+---@param source T
+---@param props string[]
+---@return T
+function table_assign_props(target, source, props)
+ for _, name in ipairs(props) do target[name] = source[name] end
+ return target
+end
+
+-- Assign props from `source` to `target` that are not in `props` set.
+---@generic T: table
+---@param target T
+---@param source T
+---@param props table
+---@return T
+function table_assign_exclude(target, source, props)
+ for key, value in pairs(source) do
+ if not props[key] then target[key] = value end
+ end
+ return target
+end
+
+-- `table_assign({}, input)` without loosing types :(
+---@generic T: table
+---@param input T
+---@return T
+function table_copy(input) return table_assign({}, input) end
+
+-- Converts itable values into `table` map.
+---@param values any[]
+function create_set(values)
+ local result = {}
+ for _, value in ipairs(values) do result[value] = true end
+ return result
+end
+
+---@generic T: any
+---@param input string
+---@param value_sanitizer? fun(value: string, key: string): T
+---@return table
+function serialize_key_value_list(input, value_sanitizer)
+ local result, sanitize = {}, value_sanitizer or function(value) return value end
+ for _, key_value_pair in ipairs(comma_split(input)) do
+ local key, value = key_value_pair:match('^([%w_]+)=([%w%.]+)$')
+ if key and value then result[key] = sanitize(value, key) end
+ end
+ return result
+end
+
+---@param key string
+---@param modifiers? string
+---@return Shortcut
+function create_shortcut(key, modifiers)
+ key = key:lower()
+
+ local id_parts, modifiers_set
+ if modifiers then
+ id_parts = split(modifiers:lower(), '+')
+ table.sort(id_parts, function(a, b) return a < b end)
+ modifiers_set = create_set(id_parts)
+ modifiers = table.concat(id_parts, '+')
+ else
+ id_parts, modifiers, modifiers_set = {}, nil, {}
+ end
+ id_parts[#id_parts + 1] = key
+
+ return table_assign({id = table.concat(id_parts, '+'), key = key, modifiers = modifiers}, modifiers_set)
+end
+
+--[[ EASING FUNCTIONS ]]
+
+function ease_out_quart(x) return 1 - ((1 - x) ^ 4) end
+function ease_out_sext(x) return 1 - ((1 - x) ^ 6) end
+
+--[[ CLASSES ]]
+
+---@class Class
+Class = {}
+function Class:new(...)
+ local object = setmetatable({}, {__index = self})
+ object:init(...)
+ return object
+end
+function Class:init(...) end
+function Class:destroy() end
+
+function class(parent) return setmetatable({}, {__index = parent or Class}) end
+
+---@class CircularBuffer : Class
+CircularBuffer = class()
+
+function CircularBuffer:new(max_size) return Class.new(self, max_size) --[[@as CircularBuffer]] end
+function CircularBuffer:init(max_size)
+ self.max_size = max_size
+ self.pos = 0
+ self.data = {}
+end
+
+function CircularBuffer:insert(item)
+ self.pos = self.pos % self.max_size + 1
+ self.data[self.pos] = item
+end
+
+function CircularBuffer:get(i)
+ return i <= #self.data and self.data[(self.pos + i - 1) % #self.data + 1] or nil
+end
+
+local function iter(self, i)
+ if i == #self.data then return nil end
+ i = i + 1
+ return i, self:get(i)
+end
+
+function CircularBuffer:iter()
+ return iter, self, 0
+end
+
+local function iter_rev(self, i)
+ if i == 1 then return nil end
+ i = i - 1
+ return i, self:get(i)
+end
+
+function CircularBuffer:iter_rev()
+ return iter_rev, self, #self.data + 1
+end
+
+function CircularBuffer:head()
+ return self.data[self.pos]
+end
+
+function CircularBuffer:tail()
+ if #self.data < 1 then return nil end
+ return self.data[self.pos % #self.data + 1]
+end
+
+function CircularBuffer:clear()
+ itable_clear(self.data)
+ self.pos = 0
+end
diff --git a/dotfiles/.config/mpv/scripts/uosc/lib/text.lua b/dotfiles/.config/mpv/scripts/uosc/lib/text.lua
new file mode 100644
index 0000000..f90eab6
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/lib/text.lua
@@ -0,0 +1,515 @@
+-- https://en.wikipedia.org/wiki/Unicode_block
+---@alias CodePointRange {[1]: integer; [2]: integer}
+
+---@type CodePointRange[]
+local zero_width_blocks = {
+ {0x0000, 0x001F}, -- C0
+ {0x007F, 0x009F}, -- Delete + C1
+ {0x034F, 0x034F}, -- combining grapheme joiner
+ {0x061C, 0x061C}, -- Arabic Letter Strong
+ {0x200B, 0x200F}, -- {zero-width space, zero-width non-joiner, zero-width joiner, left-to-right mark, right-to-left mark}
+ {0x2028, 0x202E}, -- {line separator, paragraph separator, Left-to-Right Embedding, Right-to-Left Embedding, Pop Directional Format, Left-to-Right Override, Right-to-Left Override}
+ {0x2060, 0x2060}, -- word joiner
+ {0x2066, 0x2069}, -- {Left-to-Right Isolate, Right-to-Left Isolate, First Strong Isolate, Pop Directional Isolate}
+ {0xFEFF, 0xFEFF}, -- zero-width non-breaking space
+ -- Some other characters can also be combined https://en.wikipedia.org/wiki/Combining_character
+ {0x0300, 0x036F}, -- Combining Diacritical Marks 0 BMP Inherited
+ {0x1AB0, 0x1AFF}, -- Combining Diacritical Marks Extended 0 BMP Inherited
+ {0x1DC0, 0x1DFF}, -- Combining Diacritical Marks Supplement 0 BMP Inherited
+ {0x20D0, 0x20FF}, -- Combining Diacritical Marks for Symbols 0 BMP Inherited
+ {0xFE20, 0xFE2F}, -- Combining Half Marks 0 BMP Cyrillic (2 characters), Inherited (14 characters)
+ -- Egyptian Hieroglyph Format Controls and Shorthand format Controls
+ {0x13430, 0x1345F}, -- Egyptian Hieroglyph Format Controls 1 SMP Egyptian Hieroglyphs
+ {0x1BCA0, 0x1BCAF}, -- Shorthand Format Controls 1 SMP Common
+ -- not sure how to deal with those https://en.wikipedia.org/wiki/Spacing_Modifier_Letters
+ {0x02B0, 0x02FF}, -- Spacing Modifier Letters 0 BMP Bopomofo (2 characters), Latin (14 characters), Common (64 characters)
+}
+
+-- All characters have the same width as the first one
+---@type CodePointRange[]
+local same_width_blocks = {
+ {0x3400, 0x4DBF}, -- CJK Unified Ideographs Extension A 0 BMP Han
+ {0x4E00, 0x9FFF}, -- CJK Unified Ideographs 0 BMP Han
+ {0x20000, 0x2A6DF}, -- CJK Unified Ideographs Extension B 2 SIP Han
+ {0x2A700, 0x2B73F}, -- CJK Unified Ideographs Extension C 2 SIP Han
+ {0x2B740, 0x2B81F}, -- CJK Unified Ideographs Extension D 2 SIP Han
+ {0x2B820, 0x2CEAF}, -- CJK Unified Ideographs Extension E 2 SIP Han
+ {0x2CEB0, 0x2EBEF}, -- CJK Unified Ideographs Extension F 2 SIP Han
+ {0x2F800, 0x2FA1F}, -- CJK Compatibility Ideographs Supplement 2 SIP Han
+ {0x30000, 0x3134F}, -- CJK Unified Ideographs Extension G 3 TIP Han
+ {0x31350, 0x323AF}, -- CJK Unified Ideographs Extension H 3 TIP Han
+}
+
+local width_length_ratio = 0.5
+
+---@type integer, integer
+local osd_width, osd_height = 100, 100
+
+---Get byte count of utf-8 character at index i in str
+---@param str string
+---@param i integer?
+---@return integer
+local function utf8_char_bytes(str, i)
+ local char_byte = str:byte(i)
+ local max_bytes = #str - i + 1
+ if char_byte < 0xC0 then
+ return math.min(max_bytes, 1)
+ elseif char_byte < 0xE0 then
+ return math.min(max_bytes, 2)
+ elseif char_byte < 0xF0 then
+ return math.min(max_bytes, 3)
+ elseif char_byte < 0xF8 then
+ return math.min(max_bytes, 4)
+ else
+ return math.min(max_bytes, 1)
+ end
+end
+
+---Creates an iterator for an utf-8 encoded string
+---Iterates over utf-8 characters instead of bytes
+---@param str string
+---@return fun(): integer?, string?
+function utf8_iter(str)
+ local byte_start = 1
+ return function()
+ local start = byte_start
+ if #str < start then return nil end
+ local byte_count = utf8_char_bytes(str, start)
+ byte_start = start + byte_count
+ return start, str:sub(start, start + byte_count - 1)
+ end
+end
+
+---Estimating string length based on the number of characters
+---@param char string
+---@return number
+function utf8_length(str)
+ local str_length = 0
+ for _, c in utf8_iter(str) do
+ str_length = str_length + 1
+ end
+ return str_length
+end
+
+---Extract Unicode code point from utf-8 character at index i in str
+---@param str string
+---@param i integer
+---@return integer
+local function utf8_to_unicode(str, i)
+ local byte_count = utf8_char_bytes(str, i)
+ local char_byte = str:byte(i)
+ local unicode = char_byte
+ if byte_count ~= 1 then
+ local shift = 2 ^ (8 - byte_count)
+ char_byte = char_byte - math.floor(0xFF / shift) * shift
+ unicode = char_byte * (2 ^ 6) ^ (byte_count - 1)
+ end
+ for j = 2, byte_count do
+ char_byte = str:byte(i + j - 1) - 0x80
+ unicode = unicode + char_byte * (2 ^ 6) ^ (byte_count - j)
+ end
+ return round(unicode)
+end
+
+---Convert Unicode code point to utf-8 string
+---@param unicode integer
+---@return string?
+local function unicode_to_utf8(unicode)
+ if unicode < 0x80 then
+ return string.char(unicode)
+ else
+ local byte_count
+ if unicode < 0x800 then
+ byte_count = 2
+ elseif unicode < 0x10000 then
+ byte_count = 3
+ elseif unicode < 0x110000 then
+ byte_count = 4
+ else
+ return
+ end -- too big
+
+ local res = {}
+ local shift = 2 ^ 6
+ local after_shift = unicode
+ for _ = byte_count, 2, -1 do
+ local before_shift = after_shift
+ after_shift = math.floor(before_shift / shift)
+ table.insert(res, 1, before_shift - after_shift * shift + 0x80)
+ end
+ shift = 2 ^ (8 - byte_count)
+ table.insert(res, 1, after_shift + math.floor(0xFF / shift) * shift)
+ ---@diagnostic disable-next-line: deprecated
+ return string.char(unpack(res))
+ end
+end
+
+---Update osd resolution if valid
+---@param width integer
+---@param height integer
+local function update_osd_resolution(width, height)
+ if width > 0 and height > 0 then osd_width, osd_height = width, height end
+end
+
+mp.observe_property('osd-dimensions', 'native', function(_, dim)
+ if dim then update_osd_resolution(dim.w, dim.h) end
+end)
+
+local measure_bounds
+do
+ local text_osd = mp.create_osd_overlay('ass-events')
+ text_osd.compute_bounds, text_osd.hidden = true, true
+
+ ---@param ass_text string
+ ---@return integer, integer, integer, integer
+ measure_bounds = function(ass_text)
+ update_osd_resolution(mp.get_osd_size())
+ text_osd.res_x, text_osd.res_y = osd_width, osd_height
+ text_osd.data = ass_text
+ local res = text_osd:update()
+ return res.x0, res.y0, res.x1, res.y1
+ end
+end
+
+local normalized_text_width
+do
+ ---@type {wrap: integer; bold: boolean; italic: boolean, rotate: number; size: number}
+ local bounds_opts = {wrap = 2, bold = false, italic = false, rotate = 0, size = 0}
+
+ ---Measure text width and normalize to a font size of 1
+ ---text has to be ass safe
+ ---@param text string
+ ---@param size number
+ ---@param bold boolean
+ ---@param italic boolean
+ ---@param horizontal boolean
+ ---@return number, integer
+ normalized_text_width = function(text, size, bold, italic, horizontal)
+ bounds_opts.bold, bounds_opts.italic, bounds_opts.rotate = bold, italic, horizontal and 0 or -90
+ local x1, y1 = nil, nil
+ size = size / 0.8
+ -- prevent endless loop
+ local repetitions_left = 5
+ repeat
+ size = size * 0.8
+ bounds_opts.size = size
+ local ass = assdraw.ass_new()
+ ass:txt(0, 0, horizontal and 7 or 1, text, bounds_opts)
+ _, _, x1, y1 = measure_bounds(ass.text)
+ repetitions_left = repetitions_left - 1
+ -- make sure nothing got clipped
+ until (x1 and x1 < osd_width and y1 < osd_height) or repetitions_left == 0
+ local width = (repetitions_left == 0 and not x1) and 0 or (horizontal and x1 or y1)
+ return width / size, horizontal and osd_width or osd_height
+ end
+end
+
+---Estimates character length based on utf8 byte count
+---1 character length is roughly the size of a latin character
+---@param char string
+---@return number
+local function char_length(char)
+ return #char > 2 and 2 or 1
+end
+
+---Estimates string length based on utf8 byte count
+---Note: Making a string in the iterator with the character is a waste here,
+---but as this function is only used when measuring whole string widths it's fine
+---@param text string
+---@return number
+local function text_length(text)
+ if not text or text == '' then return 0 end
+ local text_length = 0
+ for _, char in utf8_iter(tostring(text)) do text_length = text_length + char_length(char) end
+ return text_length
+end
+
+---Finds the best orientation of text on screen and returns the estimated max size
+---and if the text should be drawn horizontally
+---@param text string
+---@return number, boolean
+local function fit_on_screen(text)
+ local estimated_width = text_length(text) * width_length_ratio
+ if osd_width >= osd_height then
+ -- Fill the screen as much as we can, bigger is more accurate.
+ return math.min(osd_width / estimated_width, osd_height), true
+ else
+ return math.min(osd_height / estimated_width, osd_width), false
+ end
+end
+
+---Gets next stage from cache
+---@param cache {[any]: table}
+---@param value any
+local function get_cache_stage(cache, value)
+ local stage = cache[value]
+ if not stage then
+ stage = {}
+ cache[value] = stage
+ end
+ return stage
+end
+
+---Is measured resolution sufficient
+---@param px integer
+---@return boolean
+local function no_remeasure_required(px)
+ return px >= 800 or (px * 1.1 >= osd_width and px * 1.1 >= osd_height)
+end
+
+local character_width
+do
+ ---@type {[boolean]: {[string]: {[1]: number, [2]: integer}}}
+ local char_width_cache = {}
+
+ ---Get measured width of character
+ ---@param char string
+ ---@param bold boolean
+ ---@return number, integer
+ character_width = function(char, bold)
+ ---@type {[string]: {[1]: number, [2]: integer}}
+ local char_widths = get_cache_stage(char_width_cache, bold)
+ local width_px = char_widths[char]
+ if width_px and no_remeasure_required(width_px[2]) then return width_px[1], width_px[2] end
+
+ local unicode = utf8_to_unicode(char, 1)
+ for _, block in ipairs(zero_width_blocks) do
+ if unicode >= block[1] and unicode <= block[2] then
+ char_widths[char] = {0, math.huge}
+ return 0, math.huge
+ end
+ end
+
+ local measured_char = nil
+ for _, block in ipairs(same_width_blocks) do
+ if unicode >= block[1] and unicode <= block[2] then
+ measured_char = unicode_to_utf8(block[1])
+ width_px = char_widths[measured_char]
+ if width_px and no_remeasure_required(width_px[2]) then
+ char_widths[char] = width_px
+ return width_px[1], width_px[2]
+ end
+ break
+ end
+ end
+
+ if not measured_char then measured_char = char end
+ -- half as many repetitions for wide characters
+ local char_count = 10 / char_length(char)
+ local max_size, horizontal = fit_on_screen(measured_char:rep(char_count))
+ local size = math.min(max_size * 0.9, 50)
+ char_count = math.min(math.floor(char_count * max_size / size * 0.8), 100)
+ local enclosing_char, enclosing_width, next_char_count = '|', 0, char_count
+ if measured_char == enclosing_char then
+ enclosing_char = ''
+ else
+ enclosing_width = 2 * character_width(enclosing_char, bold)
+ end
+ local width_ratio, width, px = nil, nil, nil
+ repeat
+ char_count = next_char_count
+ local str = enclosing_char .. measured_char:rep(char_count) .. enclosing_char
+ width, px = normalized_text_width(str, size, bold, false, horizontal)
+ width = width - enclosing_width
+ width_ratio = width * size / (horizontal and osd_width or osd_height)
+ next_char_count = math.min(math.floor(char_count / width_ratio * 0.9), 100)
+ until width_ratio < 0.05 or width_ratio > 0.5 or char_count == next_char_count
+ width = width / char_count
+
+ width_px = {width, px}
+ if char ~= measured_char then char_widths[measured_char] = width_px end
+ char_widths[char] = width_px
+ return width, px
+ end
+end
+
+---Calculate text width from individual measured characters
+---@param text string|number
+---@param bold boolean
+---@return number, integer
+local function character_based_width(text, bold)
+ local max_width = 0
+ local min_px = math.huge
+ for line in tostring(text):gmatch('([^\n]*)\n?') do
+ local total_width = 0
+ for _, char in utf8_iter(line) do
+ local width, px = character_width(char, bold)
+ total_width = total_width + width
+ if px < min_px then min_px = px end
+ end
+ if total_width > max_width then max_width = total_width end
+ end
+ return max_width, min_px
+end
+
+---Measure width of whole text
+---@param text string|number
+---@param bold boolean
+---@param italic boolean
+---@return number, integer
+local function whole_text_width(text, bold, italic)
+ text = tostring(text)
+ local size, horizontal = fit_on_screen(text)
+ return normalized_text_width(ass_escape(text), size * 0.9, bold, italic, horizontal)
+end
+
+---Scale normalized width to real width based on font size and italic
+---@param opts {size: number; italic?: boolean}
+---@return number, number
+local function opts_factor_offset(opts)
+ return opts.size, opts.italic and opts.size * 0.2 or 0
+end
+
+---Scale normalized width to real width based on font size and italic
+---@param opts {size: number; italic?: boolean}
+---@return number
+local function normalized_to_real(width, opts)
+ local factor, offset = opts_factor_offset(opts)
+ return factor * width + offset
+end
+
+do
+ ---@type {[boolean]: {[boolean]: {[string|number]: {[1]: number, [2]: integer}}}} | {[boolean]: {[string|number]: {[1]: number, [2]: integer}}}
+ local width_cache = {}
+
+ ---Calculate width of text with the given opts
+ ---@param text string|number
+ ---@return number
+ ---@param opts {size: number; bold?: boolean; italic?: boolean}
+ function text_width(text, opts)
+ if not text or text == '' then return 0 end
+
+ ---@type boolean, boolean
+ local bold, italic = opts.bold or options.font_bold, opts.italic or false
+
+ if not config.refine.text_width then
+ ---@type {[string|number]: {[1]: number, [2]: integer}}
+ local text_width = get_cache_stage(width_cache, bold)
+ local width_px = text_width[text]
+ if width_px and no_remeasure_required(width_px[2]) then return normalized_to_real(width_px[1], opts) end
+
+ local width, px = character_based_width(text, bold)
+ width_cache[bold][text] = {width, px}
+ return normalized_to_real(width, opts)
+ else
+ ---@type {[string|number]: {[1]: number, [2]: integer}}
+ local text_width = get_cache_stage(get_cache_stage(width_cache, bold), italic)
+ local width_px = text_width[text]
+ if width_px and no_remeasure_required(width_px[2]) then return width_px[1] * opts.size end
+
+ local width, px = whole_text_width(text, bold, italic)
+ width_cache[bold][italic][text] = {width, px}
+ return width * opts.size
+ end
+ end
+end
+
+do
+ ---@type {[string]: string}
+ local cache = {}
+
+ function timestamp_zero_rep_clear_cache()
+ cache = {}
+ end
+
+ ---Replace all timestamp digits with 0
+ ---@param timestamp string
+ function timestamp_zero_rep(timestamp)
+ local substitute = cache[#timestamp]
+ if not substitute then
+ substitute = timestamp:gsub('%d', '0')
+ cache[#timestamp] = substitute
+ end
+ return substitute
+ end
+
+ ---Get width of formatted timestamp as if all the digits were replaced with 0
+ ---@param timestamp string
+ ---@param opts {size: number; bold?: boolean; italic?: boolean}
+ ---@return number
+ function timestamp_width(timestamp, opts)
+ return text_width(timestamp_zero_rep(timestamp), opts)
+ end
+end
+
+do
+ local wrap_at_chars = {' ', ' ', '-', '–'}
+ local remove_when_wrap = {' ', ' '}
+
+ ---Wrap the text at the closest opportunity to target_line_length
+ ---@param text string
+ ---@param opts {size: number; bold?: boolean; italic?: boolean}
+ ---@param target_line_length number
+ ---@return string, integer
+ function wrap_text(text, opts, target_line_length)
+ local target_line_width = target_line_length * width_length_ratio * opts.size
+ local bold, scale_factor, scale_offset = opts.bold or false, opts_factor_offset(opts)
+ local wrap_at_chars, remove_when_wrap = wrap_at_chars, remove_when_wrap
+ local lines = {}
+ for _, text_line in ipairs(split(text, '\n')) do
+ local line_width = scale_offset
+ local line_start = 1
+ local before_end = nil
+ local before_width = scale_offset
+ local before_line_start = 0
+ local before_removed_width = 0
+ for char_start, char in utf8_iter(text_line) do
+ local char_end = char_start + #char - 1
+ local char_width = character_width(char, bold) * scale_factor
+ line_width = line_width + char_width
+ if (char_end == #text_line) or itable_has(wrap_at_chars, char) then
+ local remove = itable_has(remove_when_wrap, char)
+ local line_width_after_remove = line_width - (remove and char_width or 0)
+ if line_width_after_remove < target_line_width then
+ before_end = remove and char_start - 1 or char_end
+ before_width = line_width_after_remove
+ before_line_start = char_end + 1
+ before_removed_width = remove and char_width or 0
+ else
+ if (target_line_width - before_width) <
+ (line_width_after_remove - target_line_width) then
+ lines[#lines + 1] = text_line:sub(line_start, before_end)
+ line_start = before_line_start
+ line_width = line_width - before_width - before_removed_width + scale_offset
+ else
+ lines[#lines + 1] = text_line:sub(line_start, remove and char_start - 1 or char_end)
+ line_start = char_end + 1
+ line_width = scale_offset
+ end
+ before_end = line_start
+ before_width = scale_offset
+ end
+ end
+ end
+ if #text_line >= line_start then
+ lines[#lines + 1] = text_line:sub(line_start)
+ elseif text_line == '' then
+ lines[#lines + 1] = ''
+ end
+ end
+ return table.concat(lines, '\n'), #lines
+ end
+end
+
+do
+ local word_separators = create_set({
+ ' ', ' ', '\t', '-', '–', '_', ',', '.', '+', '&', '(', ')', '[', ']', '{', '}', '<', '>', '/', '\\',
+ '(', ')', '【', '】', ';', ':', '《', '》', '“', '”', '‘', '’', '?', '!',
+ })
+
+ ---Get the first character of each word
+ ---@param str string
+ ---@return string[]
+ function initials(str)
+ local initials, is_word_start, word_separators = {}, true, word_separators
+ for _, char in utf8_iter(str) do
+ if word_separators[char] then
+ is_word_start = true
+ elseif is_word_start then
+ initials[#initials + 1] = char
+ is_word_start = false
+ end
+ end
+ return initials
+ end
+end
diff --git a/dotfiles/.config/mpv/scripts/uosc/lib/utils.lua b/dotfiles/.config/mpv/scripts/uosc/lib/utils.lua
new file mode 100644
index 0000000..82e6272
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/lib/utils.lua
@@ -0,0 +1,973 @@
+--[[ UI specific utilities that might or might not depend on its state or options ]]
+
+---@alias Point {x: number; y: number}
+---@alias Rect {ax: number, ay: number, bx: number, by: number, window_drag?: boolean}
+---@alias Circle {point: Point, r: number, window_drag?: boolean}
+---@alias Hitbox Rect|Circle
+---@alias ComplexBindingInfo {event: 'down' | 'repeat' | 'up' | 'press'; is_mouse: boolean; canceled: boolean; key_name?: string; key_text?: string;}
+
+-- String sorting
+do
+ ----- winapi start -----
+ -- in windows system, we can use the sorting function provided by the win32 API
+ -- see https://learn.microsoft.com/en-us/windows/win32/api/shlwapi/nf-shlwapi-strcmplogicalw
+ -- this function was taken from https://github.com/mpvnet-player/mpv.net/issues/575#issuecomment-1817413401
+ local winapi = nil
+
+ if state.platform == 'windows' and config.refine.sorting then
+ -- is_ffi_loaded is false usually means the mpv builds without luajit
+ local is_ffi_loaded, ffi = pcall(require, 'ffi')
+
+ if is_ffi_loaded then
+ winapi = {
+ ffi = ffi,
+ C = ffi.C,
+ CP_UTF8 = 65001,
+ shlwapi = ffi.load('shlwapi'),
+ }
+
+ -- ffi code from https://github.com/po5/thumbfast, Mozilla Public License Version 2.0
+ ffi.cdef [[
+ int __stdcall MultiByteToWideChar(unsigned int CodePage, unsigned long dwFlags, const char *lpMultiByteStr,
+ int cbMultiByte, wchar_t *lpWideCharStr, int cchWideChar);
+ int __stdcall StrCmpLogicalW(wchar_t *psz1, wchar_t *psz2);
+ ]]
+
+ winapi.utf8_to_wide = function(utf8_str)
+ if utf8_str then
+ local utf16_len = winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, utf8_str, -1, nil, 0)
+
+ if utf16_len > 0 then
+ local utf16_str = winapi.ffi.new('wchar_t[?]', utf16_len)
+
+ if winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, utf8_str, -1, utf16_str, utf16_len) > 0 then
+ return utf16_str
+ end
+ end
+ end
+
+ return ''
+ end
+ end
+ end
+ ----- winapi end -----
+
+ -- alphanum sorting for humans in Lua
+ -- http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua
+ local function padnum(n, d)
+ return #d > 0 and ('%03d%s%.12f'):format(#n, n, tonumber(d) / (10 ^ #d))
+ or ('%03d%s'):format(#n, n)
+ end
+
+ local function sort_lua(strings)
+ local tuples = {}
+ for i, f in ipairs(strings) do
+ tuples[i] = {f:lower():gsub('0*(%d+)%.?(%d*)', padnum), f}
+ end
+ table.sort(tuples, function(a, b)
+ return a[1] == b[1] and #b[2] < #a[2] or a[1] < b[1]
+ end)
+ for i, tuple in ipairs(tuples) do strings[i] = tuple[2] end
+ return strings
+ end
+
+ ---@param strings string[]
+ function sort_strings(strings)
+ if winapi then
+ table.sort(strings, function(a, b)
+ return winapi.shlwapi.StrCmpLogicalW(winapi.utf8_to_wide(a), winapi.utf8_to_wide(b)) == -1
+ end)
+ else
+ sort_lua(strings)
+ end
+ end
+end
+
+-- Creates in-between frames to animate value from `from` to `to` numbers.
+---@param from number
+---@param to number|fun():number
+---@param setter fun(value: number)
+---@param duration_or_callback? number|fun() Duration in milliseconds or a callback function.
+---@param callback? fun() Called either on animation end, or when animation is killed.
+function tween(from, to, setter, duration_or_callback, callback)
+ local duration = duration_or_callback
+ if type(duration_or_callback) == 'function' then callback = duration_or_callback end
+ if type(duration) ~= 'number' then duration = options.animation_duration end
+
+ local current, done, timeout = from, false, nil
+ local get_to = type(to) == 'function' and to or function() return to --[[@as number]] end
+ local distance = math.abs(get_to() - current)
+ local cutoff = distance * 0.01
+ local target_ticks = (math.max(duration, 1) / (state.render_delay * 1000))
+ local decay = 1 - ((cutoff / distance) ^ (1 / target_ticks))
+
+ local function finish()
+ if not done then
+ setter(get_to())
+ done = true
+ timeout:kill()
+ if callback then callback() end
+ request_render()
+ end
+ end
+
+ local function tick()
+ local to = get_to()
+ current = current + ((to - current) * decay)
+ local is_end = math.abs(to - current) <= cutoff
+ if is_end then
+ finish()
+ else
+ setter(current)
+ timeout:resume()
+ request_render()
+ end
+ end
+
+ timeout = mp.add_timeout(state.render_delay, tick)
+ if cutoff > 0 then tick() else finish() end
+
+ return finish
+end
+
+---@param point Point
+---@param rect Rect
+function get_point_to_rectangle_proximity(point, rect)
+ local dx = math.max(rect.ax - point.x, 0, point.x - rect.bx)
+ local dy = math.max(rect.ay - point.y, 0, point.y - rect.by)
+ return math.sqrt(dx * dx + dy * dy)
+end
+
+---@param point_a Point
+---@param point_b Point
+function get_point_to_point_proximity(point_a, point_b)
+ local dx, dy = point_a.x - point_b.x, point_a.y - point_b.y
+ return math.sqrt(dx * dx + dy * dy)
+end
+
+---@param point Point
+---@param hitbox Hitbox
+function point_collides_with(point, hitbox)
+ return (hitbox.r and get_point_to_point_proximity(point, hitbox.point) <= hitbox.r) or
+ (not hitbox.r and get_point_to_rectangle_proximity(point, hitbox --[[@as Rect]]) == 0)
+end
+
+---@param lax number
+---@param lay number
+---@param lbx number
+---@param lby number
+---@param max number
+---@param may number
+---@param mbx number
+---@param mby number
+function get_line_to_line_intersection(lax, lay, lbx, lby, max, may, mbx, mby)
+ -- Calculate the direction of the lines
+ local uA = ((mbx - max) * (lay - may) - (mby - may) * (lax - max)) /
+ ((mby - may) * (lbx - lax) - (mbx - max) * (lby - lay))
+ local uB = ((lbx - lax) * (lay - may) - (lby - lay) * (lax - max)) /
+ ((mby - may) * (lbx - lax) - (mbx - max) * (lby - lay))
+
+ -- If uA and uB are between 0-1, lines are colliding
+ if uA >= 0 and uA <= 1 and uB >= 0 and uB <= 1 then
+ return lax + (uA * (lbx - lax)), lay + (uA * (lby - lay))
+ end
+
+ return nil, nil
+end
+
+-- Returns distance from the start of a finite ray assumed to be at (rax, ray)
+-- coordinates to a line.
+---@param rax number
+---@param ray number
+---@param rbx number
+---@param rby number
+---@param lax number
+---@param lay number
+---@param lbx number
+---@param lby number
+function get_ray_to_line_distance(rax, ray, rbx, rby, lax, lay, lbx, lby)
+ local x, y = get_line_to_line_intersection(rax, ray, rbx, rby, lax, lay, lbx, lby)
+ if x then
+ return math.sqrt((rax - x) ^ 2 + (ray - y) ^ 2)
+ end
+ return nil
+end
+
+-- Returns distance from the start of a finite ray assumed to be at (ax, ay)
+-- coordinates to a rectangle. Returns `0` if ray originates inside rectangle.
+---@param ax number
+---@param ay number
+---@param bx number
+---@param by number
+---@param rect Rect
+---@return number|nil
+function get_ray_to_rectangle_distance(ax, ay, bx, by, rect)
+ -- Is inside
+ if ax >= rect.ax and ax <= rect.bx and ay >= rect.ay and ay <= rect.by then
+ return 0
+ end
+
+ local closest = nil
+
+ local function updateDistance(distance)
+ if distance and (not closest or distance < closest) then closest = distance end
+ end
+
+ updateDistance(get_ray_to_line_distance(ax, ay, bx, by, rect.ax, rect.ay, rect.bx, rect.ay))
+ updateDistance(get_ray_to_line_distance(ax, ay, bx, by, rect.bx, rect.ay, rect.bx, rect.by))
+ updateDistance(get_ray_to_line_distance(ax, ay, bx, by, rect.ax, rect.by, rect.bx, rect.by))
+ updateDistance(get_ray_to_line_distance(ax, ay, bx, by, rect.ax, rect.ay, rect.ax, rect.by))
+
+ return closest
+end
+
+-- Extracts the properties used by property expansion of that string.
+---@param str string
+---@param res { [string] : boolean } | nil
+---@return { [string] : boolean }
+function get_expansion_props(str, res)
+ res = res or {}
+ for str in str:gmatch('%$(%b{})') do
+ local name, str = str:match('^{[?!]?=?([^:]+):?(.*)}$')
+ if name then
+ local s = name:find('==') or nil
+ if s then name = name:sub(0, s - 1) end
+ res[name] = true
+ if str and str ~= '' then get_expansion_props(str, res) end
+ end
+ end
+ return res
+end
+
+-- Escape a string for verbatim display on the OSD.
+---@param str string
+function ass_escape(str)
+ -- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if
+ -- it isn't followed by a recognized character, so add a zero-width
+ -- non-breaking space
+ str = str:gsub('\\', '\\\239\187\191')
+ str = str:gsub('{', '\\{')
+ str = str:gsub('}', '\\}')
+ -- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of
+ -- consecutive newlines
+ str = str:gsub('\n', '\239\187\191\\N')
+ -- Turn leading spaces into hard spaces to prevent ASS from stripping them
+ str = str:gsub('\\N ', '\\N\\h')
+ str = str:gsub('^ ', '\\h')
+ return str
+end
+
+---@param seconds number
+---@param max_seconds number|nil Trims unnecessary `00:` if time is not expected to reach it.
+---@return string
+function format_time(seconds, max_seconds)
+ local human = mp.format_time(seconds)
+ if options.time_precision > 0 then
+ local formatted = string.format('%.' .. options.time_precision .. 'f', math.abs(seconds) % 1)
+ human = human .. '.' .. string.sub(formatted, 3)
+ end
+ if max_seconds then
+ local trim_length = (max_seconds < 60 and 7 or (max_seconds < 3600 and 4 or 0))
+ if trim_length > 0 then
+ local has_minus = seconds < 0
+ human = string.sub(human, trim_length + (has_minus and 1 or 0))
+ if has_minus then human = '-' .. human end
+ end
+ end
+ return human
+end
+
+---@param opacity number 0-1
+function opacity_to_alpha(opacity)
+ return 255 - math.ceil(255 * opacity)
+end
+
+path_separator = (function()
+ local os_separator = state.platform == 'windows' and '\\' or '/'
+
+ -- Get appropriate path separator for the given path.
+ ---@param path string
+ ---@return string
+ return function(path)
+ return path:sub(1, 2) == '\\\\' and '\\' or os_separator
+ end
+end)()
+
+-- Joins paths with the OS aware path separator or UNC separator.
+---@param p1 string
+---@param p2 string
+---@return string
+function join_path(p1, p2)
+ local p1, separator = trim_trailing_separator(p1)
+ -- Prevents joining drive letters with a redundant separator (`C:\\foo`),
+ -- as `trim_trailing_separator()` doesn't trim separators from drive letters.
+ return p1:sub(#p1) == separator and p1 .. p2 or p1 .. separator .. p2
+end
+
+-- Check if path is absolute.
+---@param path string
+---@return boolean
+function is_absolute(path)
+ if path:sub(1, 2) == '\\\\' then
+ return true
+ elseif state.platform == 'windows' then
+ return path:find('^%a+:') ~= nil
+ else
+ return path:sub(1, 1) == '/'
+ end
+end
+
+-- Ensure path is absolute.
+---@param path string
+---@return string
+function ensure_absolute(path)
+ if is_absolute(path) then return path end
+ return join_path(state.cwd, path)
+end
+
+-- Remove trailing slashes/backslashes.
+---@param path string
+---@return string path, string trimmed_separator_type
+function trim_trailing_separator(path)
+ local separator = path_separator(path)
+ path = trim_end(path, separator)
+ if state.platform == 'windows' then
+ -- Drive letters on windows need trailing backslash
+ if path:sub(#path) == ':' then path = path .. '\\' end
+ else
+ if path == '' then path = '/' end
+ end
+ return path, separator
+end
+
+-- Ensures path is absolute, remove trailing slashes/backslashes.
+-- Lightweight version of normalize_path for performance critical parts.
+---@param path string
+---@return string
+function normalize_path_lite(path)
+ if not path or is_protocol(path) then return path end
+ path = trim_trailing_separator(ensure_absolute(path))
+ return path
+end
+
+-- Ensures path is absolute, remove trailing slashes/backslashes, normalization of path separators and deduplication.
+---@param path string
+---@return string
+function normalize_path(path)
+ if not path or is_protocol(path) then return path end
+
+ path = ensure_absolute(path)
+ local is_unc = path:sub(1, 2) == '\\\\'
+ if state.platform == 'windows' or is_unc then path = path:gsub('/', '\\') end
+ path = trim_trailing_separator(path)
+
+ --Deduplication of path separators
+ if is_unc then
+ path = path:gsub('(.\\)\\+', '%1')
+ elseif state.platform == 'windows' then
+ path = path:gsub('\\\\+', '\\')
+ else
+ path = path:gsub('//+', '/')
+ end
+
+ return path
+end
+
+-- Check if path is a protocol, such as `http://...`.
+---@param path string
+function is_protocol(path)
+ return type(path) == 'string' and (path:find('^%a[%w.+-]-://') ~= nil or path:find('^%a[%w.+-]-:%?') ~= nil)
+end
+
+---@param path string
+---@param extensions string[] Lowercase extensions without the dot.
+function has_any_extension(path, extensions)
+ local path_last_dot_index = string_last_index_of(path, '.')
+ if not path_last_dot_index then return false end
+ local path_extension = path:sub(path_last_dot_index + 1):lower()
+ for _, extension in ipairs(extensions) do
+ if path_extension == extension then return true end
+ end
+ return false
+end
+
+-- Executes mp command defined as a string or an itable, or does nothing if command is any other value.
+-- Returns boolean specifying if command was executed or not.
+---@param command string | string[] | nil | any
+---@return boolean executed `true` if command was executed.
+function execute_command(command)
+ local command_type = type(command)
+ if command_type == 'string' then
+ mp.command(command)
+ return true
+ elseif command_type == 'table' and #command > 0 then
+ mp.command_native(command)
+ return true
+ end
+ return false
+end
+
+-- Serializes path into its semantic parts.
+---@param path string
+---@return nil|{path: string; is_root: boolean; dirname?: string; basename: string; filename: string; extension?: string;}
+function serialize_path(path)
+ if not path or is_protocol(path) then return end
+
+ local normal_path = normalize_path_lite(path)
+ local dirname, basename = utils.split_path(normal_path)
+ if basename == '' then basename, dirname = dirname:sub(1, #dirname - 1), nil end
+ local dot_i = string_last_index_of(basename, '.')
+
+ return {
+ path = normal_path,
+ is_root = dirname == nil,
+ dirname = dirname,
+ basename = basename,
+ filename = dot_i and basename:sub(1, dot_i - 1) or basename,
+ extension = dot_i and basename:sub(dot_i + 1) or nil,
+ }
+end
+
+-- Reads items in directory and splits it into directories and files tables.
+---@param path string
+---@param opts? {types?: string[], hidden?: boolean}
+---@return string[] files
+---@return string[] directories
+---@return string|nil error
+function read_directory(path, opts)
+ opts = opts or {}
+ local items, error = utils.readdir(path, 'all')
+ local files, directories = {}, {}
+
+ if not items then
+ return files, directories, 'Reading directory "' .. path .. '" failed. Error: ' .. utils.to_string(error)
+ end
+
+ for _, item in ipairs(items) do
+ if item ~= '.' and item ~= '..' and (opts.hidden or item:sub(1, 1) ~= '.') then
+ local info = utils.file_info(join_path(path, item))
+ if info then
+ if info.is_file then
+ if not opts.types or has_any_extension(item, opts.types) then
+ files[#files + 1] = item
+ end
+ else
+ directories[#directories + 1] = item
+ end
+ end
+ end
+ end
+
+ return files, directories
+end
+
+-- Returns full absolute paths of files in the same directory as `file_path`,
+-- and index of the current file in the table.
+-- Returned table will always contain `file_path`, regardless of `allowed_types`.
+---@param file_path string
+---@param opts? {types?: string[], hidden?: boolean}
+function get_adjacent_files(file_path, opts)
+ opts = opts or {}
+ local current_meta = serialize_path(file_path)
+ if not current_meta then return end
+ local files, _dirs, error = read_directory(current_meta.dirname, {hidden = opts.hidden})
+ if error then
+ msg.error(error)
+ return
+ end
+ sort_strings(files)
+ local current_file_index
+ local paths = {}
+ for _, file in ipairs(files) do
+ local is_current_file = current_meta.basename == file
+ if is_current_file or not opts.types or has_any_extension(file, opts.types) then
+ paths[#paths + 1] = join_path(current_meta.dirname, file)
+ if is_current_file then current_file_index = #paths end
+ end
+ end
+ if not current_file_index then return end
+ return paths, current_file_index
+end
+
+-- Navigates in a list, using delta or, when `state.shuffle` is enabled,
+-- randomness to determine the next item. Loops around if `loop-playlist` is enabled.
+---@param paths table
+---@param current_index number
+---@param delta number 1 or -1 for forward or backward
+function decide_navigation_in_list(paths, current_index, delta)
+ if #paths < 2 then return end
+ delta = delta < 0 and -1 or 1
+
+ -- Shuffle looks at the played files history trimmed to 80% length of the paths
+ -- and removes all paths in it from the potential shuffle pool. This guarantees
+ -- no path repetition until at least 80% of the playlist has been exhausted.
+ if state.shuffle then
+ state.shuffle_history = state.shuffle_history or {
+ pos = #state.history,
+ paths = itable_slice(state.history),
+ }
+ state.shuffle_history.pos = state.shuffle_history.pos + delta
+ local history_path = state.shuffle_history.paths[state.shuffle_history.pos]
+ local next_index = history_path and itable_index_of(paths, history_path)
+ if next_index then
+ return next_index, history_path
+ end
+ if delta < 0 then
+ state.shuffle_history.pos = state.shuffle_history.pos - delta
+ else
+ state.shuffle_history.pos = math.min(state.shuffle_history.pos, #state.shuffle_history.paths + 1)
+ end
+
+ local trimmed_history = itable_slice(state.history, -math.floor(#paths * 0.8))
+ local shuffle_pool = {}
+
+ for index, value in ipairs(paths) do
+ if not itable_has(trimmed_history, value) then
+ shuffle_pool[#shuffle_pool + 1] = index
+ end
+ end
+
+ math.randomseed(os.time())
+ local next_index = shuffle_pool[math.random(#shuffle_pool)]
+ local next_path = paths[next_index]
+ table.insert(state.shuffle_history.paths, state.shuffle_history.pos, next_path)
+ return next_index, next_path
+ end
+
+ local new_index = current_index + delta
+ if mp.get_property_native('loop-playlist') then
+ if new_index > #paths then
+ new_index = new_index % #paths
+ elseif new_index < 1 then
+ new_index = #paths - new_index
+ end
+ elseif new_index < 1 or new_index > #paths then
+ return
+ end
+
+ return new_index, paths[new_index]
+end
+
+---@param delta number
+function navigate_directory(delta)
+ if not state.path or is_protocol(state.path) then return false end
+ local paths, current_index = get_adjacent_files(state.path, {
+ types = config.types.load,
+ hidden = options.show_hidden_files,
+ })
+ if paths and current_index then
+ local _, path = decide_navigation_in_list(paths, current_index, delta)
+ if path then
+ mp.commandv('loadfile', path)
+ return true
+ end
+ end
+ return false
+end
+
+---@param delta number
+function navigate_playlist(delta)
+ local playlist, pos = mp.get_property_native('playlist'), mp.get_property_native('playlist-pos-1')
+ if playlist and #playlist > 1 and pos then
+ local paths = itable_map(playlist, function(item) return normalize_path(item.filename) end)
+ local index = decide_navigation_in_list(paths, pos, delta)
+ if index then
+ mp.commandv('playlist-play-index', index - 1)
+ return true
+ end
+ end
+ return false
+end
+
+---@param delta number
+function navigate_item(delta)
+ if state.has_playlist then return navigate_playlist(delta) else return navigate_directory(delta) end
+end
+
+-- Can't use `os.remove()` as it fails on paths with unicode characters.
+-- Returns `result, error`, result is table of:
+-- `status:number(<0=error), stdout, stderr, error_string, killed_by_us:boolean`
+---@param path string
+function delete_file(path)
+ if state.platform == 'windows' then
+ if options.use_trash then
+ local ps_code = [[
+ Add-Type -AssemblyName Microsoft.VisualBasic
+ [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile('__path__', 'OnlyErrorDialogs', 'SendToRecycleBin')
+ ]]
+
+ local escaped_path = string.gsub(path, "'", "''")
+ escaped_path = string.gsub(escaped_path, '’', '’’')
+ escaped_path = string.gsub(escaped_path, '%%', '%%%%')
+ ps_code = string.gsub(ps_code, '__path__', escaped_path)
+ args = {'powershell', '-NoProfile', '-Command', ps_code}
+ else
+ args = {'cmd', '/C', 'del', path}
+ end
+ else
+ if options.use_trash then
+ --On Linux and Macos the app trash-cli/trash must be installed first.
+ args = {'trash', path}
+ else
+ args = {'rm', path}
+ end
+ end
+ return mp.command_native({
+ name = 'subprocess',
+ args = args,
+ playback_only = false,
+ capture_stdout = true,
+ capture_stderr = true,
+ })
+end
+
+function delete_file_navigate(delta)
+ local path, playlist_pos = state.path, state.playlist_pos
+ local is_local_file = path and not is_protocol(path)
+
+ if navigate_item(delta) then
+ if state.has_playlist then
+ mp.commandv('playlist-remove', playlist_pos - 1)
+ end
+ else
+ mp.command('stop')
+ end
+
+ if is_local_file then
+ if Menu:is_open('open-file') then
+ Elements:maybe('menu', 'delete_value', path)
+ end
+ if path then delete_file(path) end
+ end
+end
+
+function serialize_chapter_ranges(normalized_chapters)
+ local ranges = {}
+ local simple_ranges = {
+ {
+ name = 'openings',
+ patterns = {
+ '^op ', '^op$', ' op$',
+ '^opening$', ' opening$',
+ },
+ requires_next_chapter = true,
+ },
+ {
+ name = 'intros',
+ patterns = {
+ '^intro$', ' intro$',
+ '^avant$', '^prologue$',
+ },
+ requires_next_chapter = true,
+ },
+ {
+ name = 'endings',
+ patterns = {
+ '^ed ', '^ed$', ' ed$',
+ '^ending ', '^ending$', ' ending$',
+ },
+ },
+ {
+ name = 'outros',
+ patterns = {
+ '^outro$', ' outro$',
+ '^closing$', '^closing ',
+ '^preview$', '^pv$',
+ },
+ },
+ }
+ local sponsor_ranges = {}
+
+ -- Extend with alt patterns
+ for _, meta in ipairs(simple_ranges) do
+ local alt_patterns = config.chapter_ranges[meta.name] and config.chapter_ranges[meta.name].patterns
+ if alt_patterns then meta.patterns = itable_join(meta.patterns, alt_patterns) end
+ end
+
+ -- Clone chapters
+ local chapters = {}
+ for i, normalized in ipairs(normalized_chapters) do chapters[i] = table_assign({}, normalized) end
+
+ for i, chapter in ipairs(chapters) do
+ -- Simple ranges
+ for _, meta in ipairs(simple_ranges) do
+ if config.chapter_ranges[meta.name] then
+ local match = itable_find(meta.patterns, function(p) return chapter.lowercase_title:find(p) end)
+ if match then
+ local next_chapter = chapters[i + 1]
+ if next_chapter or not meta.requires_next_chapter then
+ ranges[#ranges + 1] = table_assign({
+ start = chapter.time,
+ ['end'] = next_chapter and next_chapter.time or math.huge,
+ }, config.chapter_ranges[meta.name])
+ end
+ end
+ end
+ end
+
+ -- Sponsor blocks
+ if config.chapter_ranges.ads then
+ local id = chapter.lowercase_title:match('segment start *%(([%w]%w-)%)')
+ if id then -- ad range from sponsorblock
+ for j = i + 1, #chapters, 1 do
+ local end_chapter = chapters[j]
+ local end_match = end_chapter.lowercase_title:match('segment end *%(' .. id .. '%)')
+ if end_match then
+ local range = table_assign({
+ start_chapter = chapter,
+ end_chapter = end_chapter,
+ start = chapter.time,
+ ['end'] = end_chapter.time,
+ }, config.chapter_ranges.ads)
+ ranges[#ranges + 1], sponsor_ranges[#sponsor_ranges + 1] = range, range
+ end_chapter.is_end_only = true
+ break
+ end
+ end -- single chapter for ad
+ elseif not chapter.is_end_only and
+ (chapter.lowercase_title:find('%[sponsorblock%]:') or chapter.lowercase_title:find('^sponsors?')) then
+ local next_chapter = chapters[i + 1]
+ ranges[#ranges + 1] = table_assign({
+ start = chapter.time,
+ ['end'] = next_chapter and next_chapter.time or math.huge,
+ }, config.chapter_ranges.ads)
+ end
+ end
+ end
+
+ -- Fix overlapping sponsor block segments
+ for index, range in ipairs(sponsor_ranges) do
+ local next_range = sponsor_ranges[index + 1]
+ if next_range then
+ local delta = next_range.start - range['end']
+ if delta < 0 then
+ local mid_point = range['end'] + delta / 2
+ range['end'], range.end_chapter.time = mid_point - 0.01, mid_point - 0.01
+ next_range.start, next_range.start_chapter.time = mid_point, mid_point
+ end
+ end
+ end
+ table.sort(chapters, function(a, b) return a.time < b.time end)
+
+ return chapters, ranges
+end
+
+-- Ensures chapters are in chronological order
+function normalize_chapters(chapters)
+ if not chapters then return {} end
+ -- Ensure chronological order
+ table.sort(chapters, function(a, b) return a.time < b.time end)
+ -- Ensure titles
+ for index, chapter in ipairs(chapters) do
+ local chapter_number = chapter.title and string.match(chapter.title, '^Chapter (%d+)$')
+ if chapter_number then
+ chapter.title = t('Chapter %s', tonumber(chapter_number))
+ end
+ chapter.title = chapter.title ~= '(unnamed)' and chapter.title ~= '' and chapter.title or t('Chapter %s', index)
+ chapter.lowercase_title = chapter.title:lower()
+ end
+ return chapters
+end
+
+function serialize_chapters(chapters)
+ chapters = normalize_chapters(chapters)
+ if not chapters then return end
+ --- timeline font size isn't accessible here, so normalize to size 1 and then scale during rendering
+ local opts = {size = 1, bold = true}
+ for index, chapter in ipairs(chapters) do
+ chapter.index = index
+ chapter.title_wrapped, chapter.title_lines = wrap_text(chapter.title, opts, 25)
+ chapter.title_wrapped_width = text_width(chapter.title_wrapped, opts)
+ chapter.title_wrapped = ass_escape(chapter.title_wrapped)
+ end
+ return chapters
+end
+
+---Find all active key bindings or the active key binding for key
+---@param key string|nil
+---@return {[string]: table}|table
+function find_active_keybindings(key)
+ local bindings = mp.get_property_native('input-bindings', {})
+ local active_map = {} -- map: key-name -> bind-info
+ local active_table = {}
+ for _, bind in pairs(bindings) do
+ if bind.owner ~= 'uosc' and bind.priority >= 0 and (not key or bind.key == key) and (
+ not active_map[bind.key]
+ or (active_map[bind.key].is_weak and not bind.is_weak)
+ or (bind.is_weak == active_map[bind.key].is_weak and bind.priority > active_map[bind.key].priority)
+ )
+ then
+ active_table[#active_table + 1] = bind
+ active_map[bind.key] = bind
+ end
+ end
+ return key and active_map[key] or active_table
+end
+
+---@param type 'sub'|'audio'|'video'
+---@param path string
+function load_track(type, path)
+ mp.commandv(type .. '-add', path, 'cached')
+ -- If subtitle track was loaded, assume the user also wants to see it
+ if type == 'sub' then
+ mp.commandv('set', 'sub-visibility', 'yes')
+ end
+end
+
+---@param args (string|number)[]
+---@return string|nil error
+---@return table data
+function call_ziggy(args)
+ local result = mp.command_native({
+ name = 'subprocess',
+ capture_stderr = true,
+ capture_stdout = true,
+ playback_only = false,
+ args = itable_join({config.ziggy_path}, args),
+ })
+
+ if result.status ~= 0 then
+ return 'Calling ziggy failed. Exit code ' .. result.status .. ': ' .. result.stdout .. result.stderr, {}
+ end
+
+ local data = utils.parse_json(result.stdout)
+ if not data then
+ return 'Ziggy response error. Couldn\'t parse json: ' .. result.stdout, {}
+ elseif data.error then
+ return 'Ziggy error: ' .. data.message, {}
+ else
+ return nil, data
+ end
+end
+
+---@param args (string|number)[]
+---@param callback fun(error: string|nil, data: table)
+---@return fun() abort Function to abort the request.
+function call_ziggy_async(args, callback)
+ local abort_signal = mp.command_native_async({
+ name = 'subprocess',
+ capture_stderr = true,
+ capture_stdout = true,
+ playback_only = false,
+ args = itable_join({config.ziggy_path}, args),
+ }, function(success, result, error)
+ if not success or not result or result.status ~= 0 then
+ local exit_code = (result and result.status or 'unknown')
+ local message = error or (result and result.stdout .. result.stderr) or ''
+ callback('Calling ziggy failed. Exit code: ' .. exit_code .. ' Error: ' .. message, {})
+ return
+ end
+
+ local json = result and type(result.stdout) == 'string' and result.stdout or ''
+ local data = utils.parse_json(json)
+ if not data then
+ callback('Ziggy response error. Couldn\'t parse json: ' .. json, {})
+ elseif data.error then
+ callback('Ziggy error: ' .. data.message, {})
+ else
+ return callback(nil, data)
+ end
+ end)
+
+ return function()
+ mp.abort_async_command(abort_signal)
+ end
+end
+
+---@return string|nil
+function get_clipboard()
+ local err, data = call_ziggy({'get-clipboard'})
+ if err then
+ mp.commandv('show-text', 'Get clipboard error. See console for details.')
+ msg.error(err)
+ end
+ return data and data.payload
+end
+
+---@param payload any
+---@return string|nil payload String that was copied to clipboard.
+function set_clipboard(payload)
+ payload = tostring(payload)
+ local err, data = call_ziggy({'set-clipboard', payload})
+ if err then
+ mp.commandv('show-text', 'Set clipboard error. See console for details.')
+ msg.error(err)
+ else
+ mp.commandv('show-text', t('Copied to clipboard') .. ': ' .. payload, 3000)
+ end
+ return data and data.payload
+end
+
+--[[ RENDERING ]]
+
+function render()
+ if not display.initialized then return end
+ state.render_last_time = mp.get_time()
+
+ cursor:clear_zones()
+
+ -- Click on empty area detection
+ if setup_click_detection then setup_click_detection() end
+
+ -- Actual rendering
+ local ass = assdraw.ass_new()
+
+ -- Idle indicator
+ if state.is_idle and not Manager.disabled.idle_indicator then
+ local smaller_side = math.min(display.width, display.height)
+ local center_x, center_y, icon_size = display.width / 2, display.height / 2, math.max(smaller_side / 4, 56)
+ ass:icon(center_x, center_y - icon_size / 4, icon_size, 'not_started', {
+ color = fg, opacity = config.opacity.idle_indicator,
+ })
+ ass:txt(center_x, center_y + icon_size / 2, 8, t('Drop files or URLs to play here'), {
+ size = icon_size / 4, color = fg, opacity = config.opacity.idle_indicator,
+ })
+ end
+
+ -- Audio indicator
+ if state.is_audio and not state.has_image and not Manager.disabled.audio_indicator
+ and not (state.pause and options.pause_indicator == 'static') then
+ local smaller_side = math.min(display.width, display.height)
+ ass:icon(display.width / 2, display.height / 2, smaller_side / 4, 'graphic_eq', {
+ color = fg, opacity = config.opacity.audio_indicator,
+ })
+ end
+
+ -- Elements
+ for _, element in Elements:ipairs() do
+ if element.enabled then
+ local result = element:maybe('render')
+ if result then
+ ass:new_event()
+ ass:merge(result)
+ end
+ end
+ end
+
+ cursor:decide_keybinds()
+
+ -- submit
+ if osd.res_x == display.width and osd.res_y == display.height and osd.data == ass.text then
+ return
+ end
+
+ osd.res_x = display.width
+ osd.res_y = display.height
+ osd.data = ass.text
+ osd.z = 2000
+ osd:update()
+
+ update_margins()
+end
+
+-- Request that render() is called.
+-- The render is then either executed immediately, or rate-limited if it was
+-- called a small time ago.
+state.render_timer = mp.add_timeout(0, render)
+state.render_timer:kill()
+function request_render()
+ if state.render_timer:is_enabled() then return end
+ local timeout = math.max(0, state.render_delay - (mp.get_time() - state.render_last_time))
+ state.render_timer.timeout = timeout
+ state.render_timer:resume()
+end
diff --git a/dotfiles/.config/mpv/scripts/uosc/main.lua b/dotfiles/.config/mpv/scripts/uosc/main.lua
new file mode 100644
index 0000000..4ca7db5
--- /dev/null
+++ b/dotfiles/.config/mpv/scripts/uosc/main.lua
@@ -0,0 +1,1275 @@
+--[[ uosc | https://github.com/tomasklaen/uosc ]]
+local uosc_version = '5.7.0'
+
+mp.commandv('script-message', 'uosc-version', uosc_version)
+
+mp.set_property('osc', 'no')
+
+assdraw = require('mp.assdraw')
+opt = require('mp.options')
+utils = require('mp.utils')
+msg = require('mp.msg')
+osd = mp.create_osd_overlay('ass-events')
+QUARTER_PI_SIN = math.sin(math.pi / 4)
+
+require('lib/std')
+
+--[[ OPTIONS ]]
+
+defaults = {
+ timeline_style = 'line',
+ timeline_line_width = 2,
+ timeline_size = 40,
+ progress = 'windowed',
+ progress_size = 2,
+ progress_line_width = 20,
+ timeline_persistency = '',
+ timeline_border = 1,
+ timeline_step = '5',
+ timeline_cache = true,
+
+ controls =
+ 'menu,gap,subtitles,audio,video,editions,stream-quality,gap,space,speed,space,shuffle,loop-playlist,loop-file,gap,prev,items,next,gap,fullscreen',
+ controls_size = 32,
+ controls_margin = 8,
+ controls_spacing = 2,
+ controls_persistency = '',
+
+ volume = 'right',
+ volume_size = 40,
+ volume_persistency = '',
+ volume_border = 1,
+ volume_step = 1,
+
+ speed_persistency = '',
+ speed_step = 0.1,
+ speed_step_is_factor = false,
+
+ menu_item_height = 36,
+ menu_min_width = 260,
+ menu_padding = 4,
+ menu_type_to_search = true,
+
+ top_bar = 'no-border',
+ top_bar_size = 40,
+ top_bar_persistency = '',
+ top_bar_controls = 'right',
+ top_bar_title = 'yes',
+ top_bar_alt_title = '',
+ top_bar_alt_title_place = 'below',
+ top_bar_flash_on = 'video,audio',
+
+ window_border_size = 1,
+
+ autoload = false,
+ shuffle = false,
+
+ scale = 1,
+ scale_fullscreen = 1.3,
+ font_scale = 1,
+ text_border = 1.2,
+ border_radius = 4,
+ color = '',
+ opacity = '',
+ animation_duration = 100,
+ refine = '',
+ pause_on_click_shorter_than = 0, -- deprecated by below
+ click_threshold = 0,
+ click_command = 'cycle pause; script-binding uosc/flash-pause-indicator',
+ flash_duration = 1000,
+ proximity_in = 40,
+ proximity_out = 120,
+ total_time = false, -- deprecated by below
+ destination_time = 'playtime-remaining',
+ time_precision = 0,
+ font_bold = false,
+ autohide = false,
+ buffered_time_threshold = 60,
+ pause_indicator = 'flash',
+ stream_quality_options = '4320,2160,1440,1080,720,480,360,240,144',
+ video_types =
+ '3g2,3gp,asf,avi,f4v,flv,h264,h265,m2ts,m4v,mkv,mov,mp4,mp4v,mpeg,mpg,ogm,ogv,rm,rmvb,ts,vob,webm,wmv,y4m',
+ audio_types =
+ 'aac,ac3,aiff,ape,au,cue,dsf,dts,flac,m4a,mid,midi,mka,mp3,mp4a,oga,ogg,opus,spx,tak,tta,wav,weba,wma,wv',
+ image_types = 'apng,avif,bmp,gif,j2k,jp2,jfif,jpeg,jpg,jxl,mj2,png,svg,tga,tif,tiff,webp',
+ subtitle_types = 'aqt,ass,gsub,idx,jss,lrc,mks,pgs,pjs,psb,rt,sbv,slt,smi,sub,sup,srt,ssa,ssf,ttxt,txt,usf,vt,vtt',
+ playlist_types = 'm3u,m3u8,pls,url,cue',
+ load_types = 'video,audio,image',
+ default_directory = '~/',
+ show_hidden_files = false,
+ use_trash = false,
+ adjust_osd_margins = true,
+ chapter_ranges = 'openings:30abf964,endings:30abf964,ads:c54e4e80',
+ chapter_range_patterns = 'openings:オープニング;endings:エンディング',
+ languages = 'slang,en',
+ subtitles_directory = '~~/subtitles',
+ disable_elements = '',
+}
+options = table_copy(defaults)
+function handle_options(changed_options)
+ if changed_options.time_precision then
+ timestamp_zero_rep_clear_cache()
+ end
+ update_config()
+ update_human_times()
+ Manager:disable('user', options.disable_elements)
+ Elements:trigger('options')
+ Elements:update_proximities()
+ request_render()
+end
+opt.read_options(options, 'uosc', handle_options)
+-- Normalize values
+options.proximity_out = math.max(options.proximity_out, options.proximity_in + 1)
+if options.chapter_ranges:sub(1, 4) == '^op|' then options.chapter_ranges = defaults.chapter_ranges end
+if options.pause_on_click_shorter_than > 0 and options.click_threshold == 0 then
+ msg.warn('`pause_on_click_shorter_than` is deprecated. Use `click_threshold` and `click_command` instead.')
+ options.click_threshold = options.pause_on_click_shorter_than
+end
+if options.total_time and options.destination_time == 'playtime-remaining' then
+ msg.warn('`total_time` is deprecated. Use `destination_time` instead.')
+ options.destination_time = 'total'
+elseif not itable_index_of({'total', 'playtime-remaining', 'time-remaining'}, options.destination_time) then
+ options.destination_time = 'playtime-remaining'
+end
+if not itable_index_of({'left', 'right'}, options.top_bar_controls) then
+ options.top_bar_controls = options.top_bar_controls == 'yes' and 'right' or nil
+end
+
+--[[ INTERNATIONALIZATION ]]
+local intl = require('lib/intl')
+t = intl.t
+require('lib/char_conv')
+
+--[[ CONFIG ]]
+local config_defaults = {
+ color = {
+ foreground = serialize_rgba('ffffff').color,
+ foreground_text = serialize_rgba('000000').color,
+ background = serialize_rgba('000000').color,
+ background_text = serialize_rgba('ffffff').color,
+ curtain = serialize_rgba('111111').color,
+ success = serialize_rgba('a5e075').color,
+ error = serialize_rgba('ff616e').color,
+ },
+ opacity = {
+ timeline = 0.9,
+ position = 1,
+ chapters = 0.8,
+ slider = 0.9,
+ slider_gauge = 1,
+ controls = 0,
+ speed = 0.6,
+ menu = 1,
+ submenu = 0.4,
+ border = 1,
+ title = 1,
+ tooltip = 1,
+ thumbnail = 1,
+ curtain = 0.8,
+ idle_indicator = 0.8,
+ audio_indicator = 0.5,
+ buffering_indicator = 0.3,
+ playlist_position = 0.8,
+ },
+}
+config = {
+ version = uosc_version,
+ open_subtitles_api_key = 'b0rd16N0bp7DETMpO4pYZwIqmQkZbYQr',
+ open_subtitles_agent = 'uosc v' .. uosc_version,
+ -- sets max rendering frequency in case the
+ -- native rendering frequency could not be detected
+ render_delay = 1 / 60,
+ font = mp.get_property('options/osd-font'),
+ osd_margin_x = mp.get_property('osd-margin-x'),
+ osd_margin_y = mp.get_property('osd-margin-y'),
+ osd_alignment_x = mp.get_property('osd-align-x'),
+ osd_alignment_y = mp.get_property('osd-align-y'),
+ refine = create_set(comma_split(options.refine)),
+ types = {
+ video = comma_split(options.video_types),
+ audio = comma_split(options.audio_types),
+ image = comma_split(options.image_types),
+ subtitle = comma_split(options.subtitle_types),
+ playlist = comma_split(options.playlist_types),
+ media = comma_split(options.video_types
+ .. ',' .. options.audio_types
+ .. ',' .. options.image_types
+ .. ',' .. options.playlist_types),
+ load = {}, -- populated by update_load_types() below
+ },
+ stream_quality_options = comma_split(options.stream_quality_options),
+ top_bar_flash_on = comma_split(options.top_bar_flash_on),
+ chapter_ranges = (function()
+ ---@type table Alternative patterns.
+ local alt_patterns = {}
+ if options.chapter_range_patterns and options.chapter_range_patterns ~= '' then
+ for _, definition in ipairs(split(options.chapter_range_patterns, ';+ *')) do
+ local name_patterns = split(definition, ' *:')
+ local name, patterns = name_patterns[1], name_patterns[2]
+ if name and patterns then alt_patterns[name] = split(patterns, ',') end
+ end
+ end
+
+ ---@type table
+ local ranges = {}
+ if options.chapter_ranges and options.chapter_ranges ~= '' then
+ for _, definition in ipairs(split(options.chapter_ranges, ' *,+ *')) do
+ local name_color = split(definition, ' *:+ *')
+ local name, color = name_color[1], name_color[2]
+ if name and color
+ and name:match('^[a-zA-Z0-9_]+$') and color:match('^[a-fA-F0-9]+$')
+ and (#color == 6 or #color == 8) then
+ local range = serialize_rgba(name_color[2])
+ range.patterns = alt_patterns[name]
+ ranges[name_color[1]] = range
+ end
+ end
+ end
+ return ranges
+ end)(),
+ color = table_copy(config_defaults.color),
+ opacity = table_copy(config_defaults.opacity),
+ cursor_leave_fadeout_elements = {'timeline', 'volume', 'top_bar', 'controls'},
+ timeline_step = 5,
+ timeline_step_flag = '',
+}
+
+function update_load_types()
+ local extensions = {}
+ local types = create_set(comma_split(options.load_types:lower()))
+
+ if types.same then
+ types.same = nil
+ if state and state.type then types[state.type] = true end
+ end
+
+ for _, name in ipairs(table_keys(types)) do
+ local type_extensions = config.types[name]
+ if type(type_extensions) == 'table' then
+ itable_append(extensions, type_extensions)
+ else
+ msg.warn('Unknown load type: ' .. name)
+ end
+ end
+
+ config.types.load = extensions
+end
+
+-- Updates config with values dependent on options
+function update_config()
+ -- Required environment config
+ if options.autoload then
+ mp.commandv('set', 'keep-open', 'yes')
+ mp.commandv('set', 'keep-open-pause', 'no')
+ end
+
+ -- Adds `{element}_persistency` config properties with forced visibility states (e.g.: `{paused = true}`)
+ for _, name in ipairs({'timeline', 'controls', 'volume', 'top_bar', 'speed'}) do
+ local option_name = name .. '_persistency'
+ local value, flags = options[option_name], {}
+ if type(value) == 'string' then
+ for _, state in ipairs(comma_split(value)) do flags[state] = true end
+ end
+ config[option_name] = flags
+ end
+
+ -- Opacity
+ config.opacity = table_assign({}, config_defaults.opacity, serialize_key_value_list(options.opacity,
+ function(value, key)
+ return clamp(0, tonumber(value) or config.opacity[key], 1)
+ end
+ ))
+
+ -- Color
+ config.color = table_assign({}, config_defaults.color, serialize_key_value_list(options.color, function(value)
+ return serialize_rgba(value).color
+ end))
+
+ -- Global color shorthands
+ fg, bg = config.color.foreground, config.color.background
+ fgt, bgt = config.color.foreground_text, config.color.background_text
+
+ -- Timeline step
+ do
+ local is_exact = options.timeline_step:sub(-1) == '!'
+ config.timeline_step = tonumber(is_exact and options.timeline_step:sub(1, -2) or options.timeline_step)
+ config.timeline_step_flag = is_exact and 'exact' or ''
+ end
+
+ -- Other
+ update_load_types()
+end
+update_config()
+
+-- Default menu items
+function create_default_menu_items()
+ return {
+ {title = t('Subtitles'), value = 'script-binding uosc/subtitles'},
+ {title = t('Audio tracks'), value = 'script-binding uosc/audio'},
+ {title = t('Stream quality'), value = 'script-binding uosc/stream-quality'},
+ {title = t('Playlist'), value = 'script-binding uosc/items'},
+ {title = t('Chapters'), value = 'script-binding uosc/chapters'},
+ {
+ title = t('Navigation'),
+ items = {
+ {
+ title = t('Next'),
+ hint = t('playlist or file'),
+ value =
+ 'script-binding uosc/next',
+ },
+ {
+ title = t('Prev'),
+ hint = t('playlist or file'),
+ value =
+ 'script-binding uosc/prev',
+ },
+ {title = t('Delete file & Next'), value = 'script-binding uosc/delete-file-next'},
+ {title = t('Delete file & Prev'), value = 'script-binding uosc/delete-file-prev'},
+ {title = t('Delete file & Quit'), value = 'script-binding uosc/delete-file-quit'},
+ {title = t('Open file'), value = 'script-binding uosc/open-file'},
+ },
+ },
+ {
+ title = t('Utils'),
+ items = {
+ {
+ title = t('Aspect ratio'),
+ items = {
+ {title = t('Default'), value = 'set video-aspect-override "-1"'},
+ {title = '16:9', value = 'set video-aspect-override "16:9"'},
+ {title = '4:3', value = 'set video-aspect-override "4:3"'},
+ {title = '2.35:1', value = 'set video-aspect-override "2.35:1"'},
+ },
+ },
+ {title = t('Audio devices'), value = 'script-binding uosc/audio-device'},
+ {title = t('Editions'), value = 'script-binding uosc/editions'},
+ {title = t('Screenshot'), value = 'async screenshot'},
+ {title = t('Key bindings'), value = 'script-binding uosc/keybinds'},
+ {title = t('Show in directory'), value = 'script-binding uosc/show-in-directory'},
+ {title = t('Open config folder'), value = 'script-binding uosc/open-config-directory'},
+ },
+ },
+ {title = t('Quit'), value = 'quit'},
+ }
+end
+
+--[[ STATE ]]
+
+display = {width = 1280, height = 720, initialized = false}
+cursor = require('lib/cursor')
+state = {
+ platform = (function()
+ local platform = mp.get_property_native('platform')
+ if platform then
+ if itable_index_of({'windows', 'darwin'}, platform) then return platform end
+ else
+ if os.getenv('windir') ~= nil then return 'windows' end
+ local homedir = os.getenv('HOME')
+ if homedir ~= nil and string.sub(homedir, 1, 6) == '/Users' then return 'darwin' end
+ end
+ return 'linux'
+ end)(),
+ cwd = mp.get_property('working-directory'),
+ path = nil, -- current file path or URL
+ history = {}, -- history of last played files stored as full paths
+ title = nil,
+ alt_title = nil,
+ time = nil, -- current media playback time
+ speed = 1,
+ ---@type number|nil
+ duration = nil, -- current media duration
+ time_human = nil, -- current playback time in human format
+ destination_time_human = nil, -- depends on options.destination_time
+ pause = mp.get_property_native('pause'),
+ chapters = {},
+ ---@type {index: number; title: string}|nil
+ current_chapter = nil,
+ chapter_ranges = {},
+ border = mp.get_property_native('border'),
+ title_bar = mp.get_property_native('title-bar'),
+ fullscreen = mp.get_property_native('fullscreen'),
+ maximized = mp.get_property_native('window-maximized'),
+ fullormaxed = mp.get_property_native('fullscreen') or mp.get_property_native('window-maximized'),
+ render_timer = nil,
+ render_last_time = 0,
+ volume = nil,
+ volume_max = nil,
+ mute = nil,
+ type = nil, -- video,image,audio
+ is_idle = false,
+ is_video = false,
+ is_audio = false, -- true if file is audio only (mp3, etc)
+ is_image = false,
+ is_stream = false,
+ has_image = false,
+ has_audio = false,
+ has_sub = false,
+ has_chapter = false,
+ has_playlist = false,
+ shuffle = options.shuffle,
+ ---@type nil|{pos: number; paths: string[]}
+ shuffle_history = nil,
+ on_shuffle = function() state.shuffle_history = nil end,
+ mouse_bindings_enabled = false,
+ uncached_ranges = nil,
+ cache = nil,
+ cache_buffering = 100,
+ cache_underrun = false,
+ cache_duration = nil,
+ core_idle = false,
+ eof_reached = false,
+ render_delay = config.render_delay,
+ playlist_count = 0,
+ playlist_pos = 0,
+ margin_top = 0,
+ margin_bottom = 0,
+ margin_left = 0,
+ margin_right = 0,
+ hidpi_scale = 1,
+ scale = 1,
+ radius = 0,
+}
+buttons = require('lib/buttons')
+thumbnail = {width = 0, height = 0, disabled = false}
+external = {} -- Properties set by external scripts
+key_binding_overwrites = {} -- Table of key_binding:mpv_command
+Elements = require('elements/Elements')
+Menu = require('elements/Menu')
+
+-- State dependent utilities
+require('lib/utils')
+require('lib/text')
+require('lib/ass')
+require('lib/menus')
+
+-- Determine path to ziggy
+do
+ local bin = 'ziggy-' .. (state.platform == 'windows' and 'windows.exe' or state.platform)
+ config.ziggy_path = os.getenv('MPV_UOSC_ZIGGY') or join_path(mp.get_script_directory(), join_path('bin', bin))
+end
+
+--[[ STATE UPDATERS ]]
+
+function update_display_dimensions()
+ state.scale = (state.hidpi_scale or 1) * (state.fullormaxed and options.scale_fullscreen or options.scale)
+ state.radius = round(options.border_radius * state.scale)
+ local real_width, real_height = mp.get_osd_size()
+ if real_width <= 0 then return end
+ display.width, display.height = real_width, real_height
+ display.initialized = true
+
+ -- Tell elements about this
+ Elements:trigger('display')
+
+ -- Some elements probably changed their rectangles as a reaction to `display`
+ Elements:update_proximities()
+ request_render()
+end
+
+function update_fullormaxed()
+ state.fullormaxed = state.fullscreen or state.maximized
+ update_display_dimensions()
+ Elements:trigger('prop_fullormaxed', state.fullormaxed)
+ cursor:leave()
+end
+
+function update_duration()
+ local duration = state._duration and ((state.rebase_start_time == false and state.start_time)
+ and (state._duration + state.start_time) or state._duration)
+ set_state('duration', duration)
+ update_human_times()
+end
+
+function update_human_times()
+ state.speed = state.speed or 1
+ if state.time then
+ local max_seconds = state.duration
+ if state.duration then
+ if options.destination_time == 'playtime-remaining' then
+ max_seconds = state.speed >= 1 and state.duration or state.duration / state.speed
+ state.destination_time_human = format_time((state.time - state.duration) / state.speed, max_seconds)
+ elseif options.destination_time == 'total' then
+ state.destination_time_human = format_time(state.duration, max_seconds)
+ else
+ state.destination_time_human = format_time(state.time - state.duration, max_seconds)
+ end
+ else
+ state.destination_time_human = nil
+ end
+ state.time_human = format_time(state.time, max_seconds)
+ else
+ state.time_human, state.destination_time_human = nil, nil
+ end
+end
+
+-- Notifies other scripts such as console about where the unoccupied parts of the screen are.
+function update_margins()
+ if display.height == 0 then return end
+
+ local function causes_margin(element)
+ return element and element.enabled and (element:is_persistent() or element.min_visibility > 0.5)
+ end
+ local timeline, top_bar, controls, volume = Elements.timeline, Elements.top_bar, Elements.controls, Elements.volume
+ -- margins are normalized to window size
+ local left, right, top, bottom = 0, 0, 0, 0
+
+ if causes_margin(controls) then
+ bottom = (display.height - controls.ay) / display.height
+ elseif causes_margin(timeline) then
+ bottom = (display.height - timeline.ay) / display.height
+ end
+
+ if causes_margin(top_bar) then top = top_bar.title_by / display.height end
+
+ if causes_margin(volume) then
+ if options.volume == 'left' then
+ left = volume.bx / display.width
+ elseif options.volume == 'right' then
+ right = volume.ax / display.width
+ end
+ end
+
+ if top == state.margin_top and bottom == state.margin_bottom and
+ left == state.margin_left and right == state.margin_right then
+ return
+ end
+
+ state.margin_top = top
+ state.margin_bottom = bottom
+ state.margin_left = left
+ state.margin_right = right
+
+ if utils.shared_script_property_set then
+ utils.shared_script_property_set('osc-margins', string.format('%f,%f,%f,%f', 0, 0, top, bottom))
+ end
+ mp.set_property_native('user-data/osc/margins', {l = left, r = right, t = top, b = bottom})
+
+ if not options.adjust_osd_margins then return end
+ local osd_margin_y, osd_margin_x, osd_factor_x = 0, 0, display.width / display.height * 720
+ if config.osd_alignment_y == 'bottom' then
+ osd_margin_y = round(bottom * 720)
+ elseif config.osd_alignment_y == 'top' then
+ osd_margin_y = round(top * 720)
+ end
+ if config.osd_alignment_x == 'left' then
+ osd_margin_x = round(left * osd_factor_x)
+ elseif config.osd_alignment_x == 'right' then
+ osd_margin_x = round(right * osd_factor_x)
+ end
+ mp.set_property_native('osd-margin-y', osd_margin_y + config.osd_margin_y)
+ mp.set_property_native('osd-margin-x', osd_margin_x + config.osd_margin_x)
+end
+function create_state_setter(name, callback)
+ return function(_, value)
+ set_state(name, value)
+ if callback then callback() end
+ request_render()
+ end
+end
+
+function set_state(name, value)
+ state[name] = value
+ local state_event = state['on_' .. name]
+ if state_event then state_event(value) end
+ Elements:trigger('prop_' .. name, value)
+end
+
+function handle_file_end()
+ local resume = false
+ if not state.loop_file then
+ if state.has_playlist then
+ resume = state.shuffle and navigate_playlist(1)
+ else
+ resume = options.autoload and navigate_directory(1)
+ end
+ end
+ -- Resume only when navigation happened
+ if resume then mp.command('set pause no') end
+end
+local file_end_timer = mp.add_timeout(1, handle_file_end)
+file_end_timer:kill()
+
+function load_file_index_in_current_directory(index)
+ if not state.path or is_protocol(state.path) then return end
+
+ local serialized = serialize_path(state.path)
+ if serialized and serialized.dirname then
+ local files, _dirs, error = read_directory(serialized.dirname, {
+ types = config.types.load,
+ hidden = options.show_hidden_files,
+ })
+
+ if error then
+ msg.error(error)
+ return
+ end
+
+ sort_strings(files)
+ if index < 0 then index = #files + index + 1 end
+
+ if files[index] then
+ mp.commandv('loadfile', join_path(serialized.dirname, files[index]))
+ end
+ end
+end
+
+function update_render_delay(name, fps)
+ if fps then state.render_delay = 1 / fps end
+end
+
+function observe_display_fps(name, fps)
+ if fps then
+ mp.unobserve_property(update_render_delay)
+ mp.unobserve_property(observe_display_fps)
+ mp.observe_property('display-fps', 'native', update_render_delay)
+ end
+end
+
+function select_current_chapter()
+ local current_chapter_index = state.current_chapter and state.current_chapter.index
+ local current_chapter
+ if state.time and state.chapters then
+ _, current_chapter = itable_find(state.chapters, function(c) return state.time >= c.time end, #state.chapters, 1)
+ end
+ local new_chapter_index = current_chapter and current_chapter.index
+ if current_chapter_index ~= new_chapter_index then
+ set_state('current_chapter', current_chapter)
+ if itable_has(config.top_bar_flash_on, 'chapter') then
+ Elements:flash({'top_bar'})
+ end
+ end
+end
+
+--[[ STATE HOOKS ]]
+
+-- Click detection
+if options.click_threshold > 0 then
+ -- Executes custom command for clicks shorter than `options.click_threshold`
+ -- while filtering out double clicks.
+ local click_time = options.click_threshold / 1000
+ local doubleclick_time = mp.get_property_native('input-doubleclick-time') / 1000
+ local last_down, last_up = 0, 0
+ local click_timer = mp.add_timeout(math.max(click_time, doubleclick_time), function()
+ local delta = last_up - last_down
+ if delta > 0 and delta < click_time and delta > 0.02 then mp.command(options.click_command) end
+ end)
+ click_timer:kill()
+ local function handle_up() last_up = mp.get_time() end
+ local function handle_down()
+ last_down = mp.get_time()
+ if click_timer:is_enabled() then click_timer:kill() else click_timer:resume() end
+ end
+ -- If this function exists, it'll be called at the beginning of render().
+ function setup_click_detection()
+ local hitbox = {ax = 0, ay = 0, bx = display.width, by = display.height, window_drag = true}
+ cursor:zone('primary_down', hitbox, handle_down)
+ cursor:zone('primary_up', hitbox, handle_up)
+ end
+end
+
+mp.register_event('file-loaded', function()
+ local path = normalize_path(mp.get_property_native('path'))
+ itable_delete_value(state.history, path)
+ state.history[#state.history + 1] = path
+ set_state('path', path)
+
+ -- Flash top bar on requested file types
+ for _, type in ipairs(config.top_bar_flash_on) do
+ if state['is_' .. type] then
+ Elements:flash({'top_bar'})
+ break
+ end
+ end
+end)
+mp.register_event('end-file', function(event)
+ set_state('path', nil)
+ if event.reason == 'eof' then
+ file_end_timer:kill()
+ handle_file_end()
+ end
+end)
+-- Top bar titles
+do
+ local function update_state_with_template(prop, template)
+ -- escape ASS, and strip newlines and trailing slashes and trim whitespace
+ local tmp = mp.command_native({'expand-text', template}):gsub('\\n', ' '):gsub('[\\%s]+$', ''):gsub('^%s+', '')
+ set_state(prop, ass_escape(tmp))
+ end
+
+ local function add_template_listener(template, callback)
+ local props = get_expansion_props(template)
+ for prop, _ in pairs(props) do
+ mp.observe_property(prop, 'native', callback)
+ end
+ if not next(props) then callback() end
+ end
+
+ local function remove_template_listener(callback) mp.unobserve_property(callback) end
+
+ -- Main title
+ if #options.top_bar_title > 0 and options.top_bar_title ~= 'no' then
+ if options.top_bar_title == 'yes' then
+ local template = nil
+ local function update_title() update_state_with_template('title', template) end
+ mp.observe_property('title', 'string', function(_, title)
+ remove_template_listener(update_title)
+ template = title
+ if template then
+ if template:sub(-6) == ' - mpv' then template = template:sub(1, -7) end
+ add_template_listener(template, update_title)
+ end
+ end)
+ elseif type(options.top_bar_title) == 'string' then
+ add_template_listener(options.top_bar_title, function()
+ update_state_with_template('title', options.top_bar_title)
+ end)
+ end
+ end
+
+ -- Alt title
+ if #options.top_bar_alt_title > 0 and options.top_bar_alt_title ~= 'no' then
+ add_template_listener(options.top_bar_alt_title, function()
+ update_state_with_template('alt_title', options.top_bar_alt_title)
+ end)
+ end
+end
+mp.observe_property('playback-time', 'number', create_state_setter('time', function()
+ -- Create a file-end event that triggers right before file ends
+ file_end_timer:kill()
+ if state.duration and state.time and not state.pause then
+ local remaining = (state.duration - state.time) / state.speed
+ if remaining < 5 then
+ local timeout = remaining - 0.02
+ if timeout > 0 then
+ file_end_timer.timeout = timeout
+ file_end_timer:resume()
+ else
+ handle_file_end()
+ end
+ end
+ end
+
+ update_human_times()
+ select_current_chapter()
+end))
+mp.observe_property('rebase-start-time', 'bool', create_state_setter('rebase_start_time', update_duration))
+mp.observe_property('demuxer-start-time', 'number', create_state_setter('start_time', update_duration))
+mp.observe_property('duration', 'number', create_state_setter('_duration', update_duration))
+mp.observe_property('speed', 'number', create_state_setter('speed', update_human_times))
+mp.observe_property('track-list', 'native', function(name, value)
+ -- checks the file dispositions
+ local types = {sub = 0, image = 0, audio = 0, video = 0}
+ for _, track in ipairs(value) do
+ if track.type == 'video' then
+ if track.image or track.albumart then
+ types.image = types.image + 1
+ else
+ types.video = types.video + 1
+ end
+ elseif types[track.type] then
+ types[track.type] = types[track.type] + 1
+ end
+ end
+ set_state('is_audio', types.video == 0 and types.audio > 0)
+ set_state('is_image', types.image > 0 and types.video == 0 and types.audio == 0)
+ set_state('has_image', types.image > 0)
+ set_state('has_audio', types.audio > 0)
+ set_state('has_many_audio', types.audio > 1)
+ set_state('has_sub', types.sub > 0)
+ set_state('has_many_sub', types.sub > 1)
+ set_state('is_video', types.video > 0)
+ set_state('has_many_video', types.video > 1)
+ set_state('type', state.is_video and 'video' or state.is_audio and 'audio' or state.is_image and 'image' or nil)
+ update_load_types()
+ Elements:trigger('dispositions')
+end)
+mp.observe_property('editions', 'number', function(_, editions)
+ if editions then set_state('has_many_edition', editions > 1) end
+ Elements:trigger('dispositions')
+end)
+mp.observe_property('chapter-list', 'native', function(_, chapters)
+ local chapters, chapter_ranges = serialize_chapters(chapters), {}
+ if chapters then chapters, chapter_ranges = serialize_chapter_ranges(chapters) end
+ set_state('chapters', chapters)
+ set_state('chapter_ranges', chapter_ranges)
+ set_state('has_chapter', #chapters > 0)
+ select_current_chapter()
+ Elements:trigger('dispositions')
+end)
+mp.observe_property('border', 'bool', create_state_setter('border'))
+mp.observe_property('title-bar', 'bool', create_state_setter('title_bar'))
+mp.observe_property('loop-file', 'native', create_state_setter('loop_file'))
+mp.observe_property('ab-loop-a', 'number', create_state_setter('ab_loop_a'))
+mp.observe_property('ab-loop-b', 'number', create_state_setter('ab_loop_b'))
+mp.observe_property('playlist-pos-1', 'number', create_state_setter('playlist_pos'))
+mp.observe_property('playlist-count', 'number', function(_, value)
+ set_state('playlist_count', value)
+ set_state('has_playlist', value > 1)
+ Elements:trigger('dispositions')
+end)
+mp.observe_property('fullscreen', 'bool', create_state_setter('fullscreen', update_fullormaxed))
+mp.observe_property('window-maximized', 'bool', create_state_setter('maximized', update_fullormaxed))
+mp.observe_property('idle-active', 'bool', function(_, idle)
+ set_state('is_idle', idle)
+ Elements:trigger('dispositions')
+end)
+mp.observe_property('pause', 'bool', create_state_setter('pause', function() file_end_timer:kill() end))
+mp.observe_property('volume', 'number', create_state_setter('volume'))
+mp.observe_property('volume-max', 'number', create_state_setter('volume_max'))
+mp.observe_property('mute', 'bool', create_state_setter('mute'))
+mp.observe_property('osd-dimensions', 'native', function(name, val)
+ update_display_dimensions()
+ request_render()
+end)
+mp.observe_property('display-hidpi-scale', 'native', create_state_setter('hidpi_scale', update_display_dimensions))
+mp.observe_property('cache', 'string', create_state_setter('cache'))
+mp.observe_property('cache-buffering-state', 'number', create_state_setter('cache_buffering'))
+mp.observe_property('demuxer-via-network', 'native', create_state_setter('is_stream', function()
+ Elements:trigger('dispositions')
+end))
+mp.observe_property('demuxer-cache-state', 'native', function(prop, cache_state)
+ local cached_ranges, bof, eof, uncached_ranges = nil, nil, nil, nil
+ if cache_state then
+ cached_ranges, bof, eof = cache_state['seekable-ranges'], cache_state['bof-cached'], cache_state['eof-cached']
+ set_state('cache_underrun', cache_state['underrun'])
+ set_state('cache_duration', not cache_state.eof and cache_state['cache-duration'] or nil)
+ else
+ cached_ranges = {}
+ end
+
+ if not (state.duration and (#cached_ranges > 0 or state.cache == 'yes' or
+ (state.cache == 'auto' and state.is_stream))) then
+ if state.uncached_ranges then set_state('uncached_ranges', nil) end
+ set_state('cache_duration', nil)
+ return
+ end
+
+ -- Normalize
+ local ranges = {}
+ for _, range in ipairs(cached_ranges) do
+ ranges[#ranges + 1] = {
+ math.max(range['start'] or 0, 0),
+ math.min(range['end'] or state.duration --[[@as number]], state.duration),
+ }
+ end
+ table.sort(ranges, function(a, b) return a[1] < b[1] end)
+ if bof then ranges[1][1] = 0 end
+ if eof then ranges[#ranges][2] = state.duration end
+ -- Invert cached ranges into uncached ranges, as that's what we're rendering
+ local inverted_ranges = {{0, state.duration}}
+ for _, cached in pairs(ranges) do
+ inverted_ranges[#inverted_ranges][2] = cached[1]
+ inverted_ranges[#inverted_ranges + 1] = {cached[2], state.duration}
+ end
+ uncached_ranges = {}
+ local last_range = nil
+ for _, range in ipairs(inverted_ranges) do
+ if last_range and last_range[2] + 0.5 > range[1] then -- fuse ranges
+ last_range[2] = range[2]
+ else
+ if range[2] - range[1] > 0.5 then -- skip short ranges
+ uncached_ranges[#uncached_ranges + 1] = range
+ last_range = range
+ end
+ end
+ end
+
+ set_state('uncached_ranges', uncached_ranges)
+end)
+mp.observe_property('display-fps', 'native', observe_display_fps)
+mp.observe_property('estimated-display-fps', 'native', update_render_delay)
+mp.observe_property('eof-reached', 'native', create_state_setter('eof_reached'))
+mp.observe_property('core-idle', 'native', create_state_setter('core_idle'))
+
+--[[ KEY BINDS ]]
+
+-- Adds a key binding that respects rerouting set by `key_binding_overwrites` table.
+---@param name string
+---@param callback fun(event: table)
+---@param flags nil|string
+function bind_command(name, callback, flags)
+ mp.add_key_binding(nil, name, function(...)
+ if key_binding_overwrites[name] then
+ mp.command(key_binding_overwrites[name])
+ else
+ callback(...)
+ end
+ end, flags)
+end
+
+bind_command('toggle-ui', function() Elements:toggle({'timeline', 'controls', 'volume', 'top_bar'}) end)
+bind_command('flash-ui', function() Elements:flash({'timeline', 'controls', 'volume', 'top_bar'}) end)
+bind_command('flash-timeline', function() Elements:flash({'timeline'}) end)
+bind_command('flash-top-bar', function() Elements:flash({'top_bar'}) end)
+bind_command('flash-volume', function() Elements:flash({'volume'}) end)
+bind_command('flash-speed', function() Elements:flash({'speed'}) end)
+bind_command('flash-pause-indicator', function() Elements:flash({'pause_indicator'}) end)
+bind_command('flash-progress', function() Elements:flash({'progress'}) end)
+bind_command('toggle-progress', function() Elements:maybe('timeline', 'toggle_progress') end)
+bind_command('toggle-title', function() Elements:maybe('top_bar', 'toggle_title') end)
+bind_command('decide-pause-indicator', function() Elements:maybe('pause_indicator', 'decide') end)
+bind_command('menu', function() toggle_menu_with_items() end)
+bind_command('menu-blurred', function() toggle_menu_with_items({mouse_nav = true}) end)
+bind_command('keybinds', function()
+ if Menu:is_open('keybinds') then
+ Menu:close()
+ else
+ open_command_menu({type = 'keybinds', items = get_keybinds_items(), search_style = 'palette'})
+ end
+end)
+bind_command('download-subtitles', open_subtitle_downloader)
+bind_command('load-subtitles', create_track_loader_menu_opener({
+ prop = 'sub',
+ title = t('Load subtitles'),
+ loaded_message = t('Loaded subtitles'),
+ allowed_types = itable_join(config.types.video, config.types.subtitle),
+}))
+bind_command('load-audio', create_track_loader_menu_opener({
+ prop = 'audio',
+ title = t('Load audio'),
+ loaded_message = t('Loaded audio'),
+ allowed_types = itable_join(config.types.video, config.types.audio),
+}))
+bind_command('load-video', create_track_loader_menu_opener({
+ prop = 'video',
+ title = t('Load video'),
+ loaded_message = t('Loaded video'),
+ allowed_types = config.types.video,
+}))
+bind_command('subtitles', create_select_tracklist_type_menu_opener({
+ title = t('Subtitles'),
+ type = 'sub',
+ prop = 'sid',
+ enable_prop = 'sub-visibility',
+ secondary = {prop = 'secondary-sid', icon = 'vertical_align_top', enable_prop = 'secondary-sub-visibility'},
+ load_command = 'script-binding uosc/load-subtitles',
+ download_command = 'script-binding uosc/download-subtitles',
+}))
+bind_command('audio', create_select_tracklist_type_menu_opener({
+ title = t('Audio'), type = 'audio', prop = 'aid', load_command = 'script-binding uosc/load-audio',
+}))
+bind_command('video', create_select_tracklist_type_menu_opener({
+ title = t('Video'), type = 'video', prop = 'vid', load_command = 'script-binding uosc/load-video',
+}))
+bind_command('playlist', create_self_updating_menu_opener({
+ title = t('Playlist'),
+ type = 'playlist',
+ list_prop = 'playlist',
+ footnote = t('Paste path or url to add.') .. ' ' .. t('%s to reorder.', 'ctrl+up/down/pgup/pgdn/home/end'),
+ serializer = function(playlist)
+ local items = {}
+ local force_filename = mp.get_property_native('osd-playlist-entry') == 'filename'
+ for index, item in ipairs(playlist) do
+ local title = type(item.title) == 'string' and #item.title > 0 and item.title or false
+ items[index] = {
+ title = (not force_filename and title) and title
+ or (is_protocol(item.filename) and item.filename or serialize_path(item.filename).basename),
+ hint = tostring(index),
+ active = item.current,
+ value = index,
+ }
+ end
+ return items
+ end,
+ on_activate = function(event) mp.commandv('set', 'playlist-pos-1', tostring(event.value)) end,
+ on_paste = function(event) mp.commandv('loadfile', tostring(event.value), 'append') end,
+ on_key = function(event)
+ if event.id == 'ctrl+c' and event.selected_item then
+ local payload = mp.get_property_native('playlist/' .. (event.selected_item.value - 1) .. '/filename')
+ set_clipboard(payload)
+ end
+ end,
+ on_move = function(event)
+ local from, to = event.from_index, event.to_index
+ mp.commandv('playlist-move', tostring(from - 1), tostring(to - (to > from and 0 or 1)))
+ end,
+ on_remove = function(event) mp.commandv('playlist-remove', tostring(event.value - 1)) end,
+}))
+bind_command('chapters', create_self_updating_menu_opener({
+ title = t('Chapters'),
+ type = 'chapters',
+ list_prop = 'chapter-list',
+ active_prop = 'chapter',
+ serializer = function(chapters, current_chapter)
+ local items = {}
+ chapters = normalize_chapters(chapters)
+ for index, chapter in ipairs(chapters) do
+ items[index] = {
+ title = chapter.title or '',
+ hint = format_time(chapter.time, state.duration),
+ value = index,
+ active = index - 1 == current_chapter,
+ }
+ end
+ return items
+ end,
+ on_activate = function(event) mp.commandv('set', 'chapter', tostring(event.value - 1)) end,
+}))
+bind_command('editions', create_self_updating_menu_opener({
+ title = t('Editions'),
+ type = 'editions',
+ list_prop = 'edition-list',
+ active_prop = 'current-edition',
+ serializer = function(editions, current_id)
+ local items = {}
+ for _, edition in ipairs(editions or {}) do
+ local edition_id_1 = tostring(edition.id + 1)
+ items[#items + 1] = {
+ title = edition.title or t('Edition %s', edition_id_1),
+ hint = edition_id_1,
+ value = edition.id,
+ active = edition.id == current_id,
+ }
+ end
+ return items
+ end,
+ on_activate = function(event) mp.commandv('set', 'edition', event.value) end,
+}))
+bind_command('show-in-directory', function()
+ -- Ignore URLs
+ if not state.path or is_protocol(state.path) then return end
+
+ if state.platform == 'windows' then
+ utils.subprocess_detached({args = {'explorer', '/select,', state.path .. ' '}, cancellable = false})
+ elseif state.platform == 'darwin' then
+ utils.subprocess_detached({args = {'open', '-R', state.path}, cancellable = false})
+ elseif state.platform == 'linux' then
+ local result = utils.subprocess({args = {'nautilus', state.path}, cancellable = false})
+
+ -- Fallback opens the folder with xdg-open instead
+ if result.status ~= 0 then
+ utils.subprocess({args = {'xdg-open', serialize_path(state.path).dirname}, cancellable = false})
+ end
+ end
+end)
+bind_command('stream-quality', open_stream_quality_menu)
+bind_command('open-file', open_open_file_menu)
+bind_command('shuffle', function() set_state('shuffle', not state.shuffle) end)
+bind_command('items', function()
+ if state.has_playlist then
+ mp.command('script-binding uosc/playlist')
+ else
+ mp.command('script-binding uosc/open-file')
+ end
+end)
+bind_command('next', function() navigate_item(1) end)
+bind_command('prev', function() navigate_item(-1) end)
+bind_command('next-file', function() navigate_directory(1) end)
+bind_command('prev-file', function() navigate_directory(-1) end)
+bind_command('first', function()
+ if state.has_playlist then
+ mp.commandv('set', 'playlist-pos-1', '1')
+ else
+ load_file_index_in_current_directory(1)
+ end
+end)
+bind_command('last', function()
+ if state.has_playlist then
+ mp.commandv('set', 'playlist-pos-1', tostring(state.playlist_count))
+ else
+ load_file_index_in_current_directory(-1)
+ end
+end)
+bind_command('first-file', function() load_file_index_in_current_directory(1) end)
+bind_command('last-file', function() load_file_index_in_current_directory(-1) end)
+bind_command('delete-file-prev', function() delete_file_navigate(-1) end)
+bind_command('delete-file-next', function() delete_file_navigate(1) end)
+bind_command('delete-file-quit', function()
+ mp.command('stop')
+ if state.path and not is_protocol(state.path) then delete_file(state.path) end
+ mp.command('quit')
+end)
+bind_command('audio-device', create_self_updating_menu_opener({
+ title = t('Audio devices'),
+ type = 'audio-device-list',
+ list_prop = 'audio-device-list',
+ active_prop = 'audio-device',
+ serializer = function(audio_device_list, current_device)
+ current_device = current_device or 'auto'
+ local ao = mp.get_property('current-ao') or ''
+ local items = {}
+ for _, device in ipairs(audio_device_list) do
+ if device.name == 'auto' or string.match(device.name, '^' .. ao) then
+ local hint = string.match(device.name, ao .. '/(.+)')
+ if not hint then hint = device.name end
+ items[#items + 1] = {
+ title = device.description:sub(1, 7) == 'Default'
+ and t('Default %s', device.description:sub(9))
+ or device.description,
+ hint = hint,
+ active = device.name == current_device,
+ value = device.name,
+ }
+ end
+ end
+ return items
+ end,
+ on_activate = function(event) mp.commandv('set', 'audio-device', event.value) end,
+}))
+bind_command('paste', function()
+ local has_playlist = mp.get_property_native('playlist-count') > 1
+ mp.commandv('script-binding', 'uosc/paste-to-' .. (has_playlist and 'playlist' or 'open'))
+end)
+bind_command('paste-to-open', function()
+ local payload = get_clipboard()
+ if payload then mp.commandv('loadfile', payload) end
+end)
+bind_command('paste-to-playlist', function()
+ -- If there's no file loaded, we use `paste-to-open`, which both opens and adds to playlist
+ if state.is_idle then
+ mp.commandv('script-binding', 'uosc/paste-to-open')
+ else
+ local payload = get_clipboard()
+ if payload then
+ mp.commandv('loadfile', payload, 'append')
+ mp.commandv('show-text', t('Added to playlist') .. ': ' .. payload, 3000)
+ end
+ end
+end)
+bind_command('copy-to-clipboard', function()
+ if state.path then
+ set_clipboard(state.path)
+ else
+ mp.commandv('show-text', t('Nothing to copy'), 3000)
+ end
+end)
+bind_command('open-config-directory', function()
+ local config_path = mp.command_native({'expand-path', '~~/mpv.conf'})
+ local config = serialize_path(normalize_path(config_path))
+
+ if config then
+ local args
+
+ if state.platform == 'windows' then
+ args = {'explorer', '/select,', config.path}
+ elseif state.platform == 'darwin' then
+ args = {'open', '-R', config.path}
+ elseif state.platform == 'linux' then
+ args = {'xdg-open', config.dirname}
+ end
+
+ utils.subprocess_detached({args = args, cancellable = false})
+ else
+ msg.error('Couldn\'t serialize config path "' .. config_path .. '".')
+ end
+end)
+bind_command('update', function()
+ if not Elements:has('updater') then require('elements/Updater'):new() end
+end)
+
+--[[ MESSAGE HANDLERS ]]
+
+mp.register_script_message('show-submenu', function(id) toggle_menu_with_items({submenu = id}) end)
+mp.register_script_message('show-submenu-blurred', function(id)
+ toggle_menu_with_items({submenu = id, mouse_nav = true})
+end)
+mp.register_script_message('open-menu', function(json, submenu_id)
+ local data = utils.parse_json(json)
+ if type(data) ~= 'table' or type(data.items) ~= 'table' then
+ msg.error('open-menu: received json didn\'t produce a table with menu configuration')
+ else
+ open_command_menu(data, {submenu = submenu_id, on_close = data.on_close})
+ end
+end)
+mp.register_script_message('update-menu', function(json)
+ local data = utils.parse_json(json)
+ if type(data) ~= 'table' or type(data.items) ~= 'table' then
+ msg.error('update-menu: received json didn\'t produce a table with menu configuration')
+ else
+ local menu = data.type and Menu:is_open(data.type)
+ if menu then menu:update(data) end
+ end
+end)
+mp.register_script_message('select-menu-item', function(type, item_index, menu_id)
+ local menu = Menu:is_open(type)
+ local index = tonumber(item_index)
+ if menu and index and not menu.mouse_nav then
+ index = round(index)
+ if index > 0 and index <= #menu.current.items then
+ menu:select_index(index, menu_id)
+ menu:scroll_to_index(index, menu_id, true)
+ end
+ end
+end)
+mp.register_script_message('close-menu', function(type)
+ if Menu:is_open(type) then Menu:close() end
+end)
+mp.register_script_message('thumbfast-info', function(json)
+ local data = utils.parse_json(json)
+ if type(data) ~= 'table' or not data.width or not data.height then
+ thumbnail.disabled = true
+ msg.error('thumbfast-info: received json didn\'t produce a table with thumbnail information')
+ else
+ thumbnail = data
+ request_render()
+ end
+end)
+mp.register_script_message('set', function(name, value)
+ external[name] = value
+ Elements:trigger('external_prop_' .. name, value)
+end)
+mp.register_script_message('toggle-elements', function(elements) Elements:toggle(comma_split(elements)) end)
+mp.register_script_message('set-min-visibility', function(visibility, elements)
+ local fraction = tonumber(visibility)
+ local ids = comma_split(elements and elements ~= '' and elements or 'timeline,controls,volume,top_bar')
+ if fraction then Elements:set_min_visibility(clamp(0, fraction, 1), ids) end
+end)
+mp.register_script_message('flash-elements', function(elements) Elements:flash(comma_split(elements)) end)
+mp.register_script_message('overwrite-binding', function(name, command) key_binding_overwrites[name] = command end)
+mp.register_script_message('disable-elements', function(id, elements) Manager:disable(id, elements) end)
+
+--[[ ELEMENTS ]]
+
+-- Dynamic elements
+local constructors = {
+ window_border = require('elements/WindowBorder'),
+ buffering_indicator = require('elements/BufferingIndicator'),
+ pause_indicator = require('elements/PauseIndicator'),
+ top_bar = require('elements/TopBar'),
+ timeline = require('elements/Timeline'),
+ controls = options.controls and options.controls ~= 'never' and require('elements/Controls'),
+ volume = itable_index_of({'left', 'right'}, options.volume) and require('elements/Volume'),
+}
+
+-- Required elements
+require('elements/Curtain'):new()
+
+-- Element manager
+-- Handles creating and destroying elements based on disabled_elements user+script config.
+Manager = {
+ -- Managed disable-able element IDs
+ _ids = itable_join(table_keys(constructors), {'idle_indicator', 'audio_indicator'}),
+ ---@type table A map of clients and a list of element ids they disable
+ _disabled_by = {},
+ ---@type table
+ disabled = {},
+}
+
+-- Set client and which elements it wishes disabled. To undo just pass an empty `element_ids` for the same `client`.
+---@param client string
+---@param element_ids string|string[]|nil `foo,bar` or `{'foo', 'bar'}`.
+function Manager:disable(client, element_ids)
+ self._disabled_by[client] = comma_split(element_ids)
+ ---@diagnostic disable-next-line: deprecated
+ self.disabled = create_set(itable_join(unpack(table_values(self._disabled_by))))
+ self:_commit()
+end
+
+function Manager:_commit()
+ -- Create and destroy elements as needed
+ for _, id in ipairs(self._ids) do
+ local constructor = constructors[id]
+ if not self.disabled[id] then
+ if not Elements:has(id) and constructor then constructor:new() end
+ else
+ Elements:maybe(id, 'destroy')
+ end
+ end
+
+ -- We use `on_display` event to tell elements to update their dimensions
+ Elements:trigger('display')
+end
+
+-- Initial commit
+Manager:disable('user', options.disable_elements)
diff --git a/home/gui/media-player/mpv.nix b/home/gui/media-player/mpv.nix
new file mode 100644
index 0000000..700eb46
--- /dev/null
+++ b/home/gui/media-player/mpv.nix
@@ -0,0 +1,5 @@
+_: {
+ programs.mpv = {
+ enable = true;
+ };
+}
diff --git a/home/gui/media-player/vlc.nix b/home/gui/media-player/vlc.nix
deleted file mode 100644
index 4ccdb40..0000000
--- a/home/gui/media-player/vlc.nix
+++ /dev/null
@@ -1,5 +0,0 @@
-{pkgs, ...}: {
- home.packages = with pkgs; [
- vlc
- ];
-}
diff --git a/home/wayland/xdg.nix b/home/wayland/xdg.nix
index 3e11aa0..162002f 100644
--- a/home/wayland/xdg.nix
+++ b/home/wayland/xdg.nix
@@ -14,7 +14,7 @@
editor = ["neovim"];
filemanager = ["nemo.desktop"];
image-viewer = ["oculante.desktop"];
- media-player = ["vlc.desktop"];
+ media-player = ["mpv.desktop"];
in {
associations = {
added = {