From: Ian Kelling Date: Tue, 8 Apr 2025 07:56:33 +0000 (-0400) Subject: misc improvements X-Git-Url: https://iankelling.org/git/?a=commitdiff_plain;h=7f8cfb85f9d3f5fc5177226cb0746f73441fab5d;p=distro-setup misc improvements --- diff --git a/brc b/brc index 59175d6..da736c4 100644 --- a/brc +++ b/brc @@ -2507,8 +2507,8 @@ grep ps and output in a nice format" fi } -pubip() { curl -4s https://icanhazip.com; } -pubip6() { curl -6s https://icanhazip.com; } +pubip() { wget -4 -q -O- https://icanhazip.com; } +pubip6() { wget -6 -q -O- https://icanhazip.com; } whatismyip() { pubip; } diff --git a/brc2 b/brc2 index 43df9e5..c750016 100644 --- a/brc2 +++ b/brc2 @@ -2832,6 +2832,9 @@ mpvd() { mpva() { mpv --profile=a "$@"; } +mpvl() { + mpv --profile=l "$@"; +} # mpv for testing video quality, dont scale. mpvt() { mpv --video-unscaled "$@"; @@ -4744,12 +4747,64 @@ linediff() { meld <(printf "%s\n" "$l1") <(printf "%s\n" "$l2") & } -# list count of files in directories -wc-l-dirs() { +# List count of files in directories. +# +# Note: you may want to add on | sort -n. +ls-wc-files() { + local dir + for dir in "$@"; do + e "$(find -L $dir -maxdepth 1 -type f -printf x | wc -c)" "$dir" + done +} + +ls-wc-dirs() { local dir for dir in "$@"; do - e "$(find $dir -maxdepth 1 -type f -printf x | wc -c)" "$dir" - done | sort -n + e "$(find -L $dir -maxdepth 1 -type d -not -path . -printf x | wc -c)" "$dir" + done +} + + +# usage: $0 [--debug] WEBP_FILES... +# +# output: array webp_frames = count of animation frames in each of +# WEBP_FILES. 0 means the webp has no animation and is just a still +# image. +# +# Example use: +# +#webp-anim-array *webp; fs=(*webp); mv -t ../stil2 $(for (( i=0; i < ${#webp_frames[@]}; i++ )); do if [[ ${webp_frames[$i]} == 0 ]]; then echo ${fs[$i]}; fi; done) +# +webp-anim-array() { + local debug=false tmps + local -a args + local -i i + if [[ $1 == --debug ]]; then + debug=true + shift + fi + args=( "$@" ) + if (( $# <= 2 )); then + echo "error: something went wrong, \$#=$#. Only 2+ is useful." + return 1 + fi + # note, we could save the file name in the webinfo output if our first 2 seds were s/^.* /0 / + tmps=$(webpinfo -summary "${args[@]}" | sed -rn '1{s/.*/0/;x};/^File:/{s/.*/0/;x;p};${x;p}; /^[[:space:]]*Number of frames:[[:space:]]+([2-9]|[1-9][0-9]+)$/{s/^.*[[:space:]]([0-9]+)$/\1/;G;s/\n0//;h}') + unset webp_frames + mapfile -t webp_frames <<<"$tmps" + if (( ${#webp_frames[@]} != $# )); then + echo "error: \${#webp_frames[@]}:${#webp_frames[@]} != \$#:$#" + return 1 + fi + if $debug; then + for (( i=0; i < ${#webp_frames[@]}; i++ )); do + echo "${webp_frames[$i]} ${args[$i]}" + done | sort -n + for (( i=0; i < ${#webp_frames[@]}; i++ )); do + echo "${webp_frames[$i]}" + done | sort -n | uniq -c + + fi } export BASEFILE_DIR=/a/bin/fai-basefiles diff --git a/distro-begin b/distro-begin index 08abcd0..9e7e01f 100755 --- a/distro-begin +++ b/distro-begin @@ -652,6 +652,11 @@ if has_monitor; then ###### install X pi i3 python3-i3ipc + f=/etc/i3/config + if [[ -e $f && ! -w $f ]]; then + sudo chown iank:iank $f + fi + ##### install xinput case $(distro-name) in diff --git a/filesystem/etc/i3/config b/filesystem/etc/i3/config deleted file mode 100644 index 55ebe22..0000000 --- a/filesystem/etc/i3/config +++ /dev/null @@ -1,303 +0,0 @@ -####### DO NOT EDIT LIVE CONFIG. generated from /a/bin/distro-setup/i3-sway/gen ####### - -# random thoughts: what to do with a window I don't have room for? -# * I could tabify it -# * I could split an existing window with it -# * I could send it away to another workspace, -# * I could resize it to be very small. - - -# todo: think whether this is useful: https://github.com/tmfink/i3-wk-switch -# todo: see comment by Jakstern551 here for tip about jumping to windows -# https://old.reddit.com/r/i3wm/comments/k8m4k4/share_your_i3_tips_and_tricks_that_you_have/ - -# https://i3wm.org/docs/userguide.html#keybindings -#To get the current mapping of your keys, use xmodmap -pke. To -#interactively enter a key and see what keysym it is configured to, use -#xev. -set $mod Mod4 - -# for non-gui apps, use this. -set $ex exec --no-startup-id - -bindsym $mod+2 $ex "i3-split-maybe"; exec "pavucontrol" -# calling without --new-instance makes this to be the instance that links -# will open in from other applications. -# unused. todo: consider binding this to some key on the right side of keyboard. -#bindsym $mod+3 $ex "i3-split-maybe"; exec "abrowser" -# calling just abrowser mysteriously stopped working, -# so I figured out this is how to get output, but then -# it suddenly started working again. -#bindsym $mod+3 exec "abrowser 2>&1 >/tmp/l" -#bindsym $mod+3 exec "abrowser --new-instance -P sfw" -bindsym $mod+4 $ex "i3-abrowser --new-instance -P firefox-main-profile" -bindsym $mod+5 $ex "/a/bin/ds/stream-interlude" -bindsym $mod+6 $ex "i3-split-maybe"; exec "/usr/local/bin/start-tor-browser" -bindsym $mod+7 $ex "/a/bin/ds/myx" -#bindsym $mod+6 $ex "/a/bin/redshift.sh" -# bindsym $mod+equal $ex "t s w; t in" -# bindsym $mod+Home $ex "t out" -# #bindsym $mod+End $ex "t s x; t in" -# bindsym $mod+grave $ex "t s lunch; t in; t out -a '45 minutes from now'" - - -bindsym $mod+1 focus parent -bindsym $mod+shift+1 focus child - -# note, i used to have a key: "floating toggle; floating toggle" to -# as undo split, as suggested here https://github.com/i3/i3/issues/3808 -# but something -# -bindsym $mod+grave floating toggle -bindsym $mod+equal $ex "i3-set-layout splith" -# move firefox to current workspace. -# https://i3wm.org/docs/userguide.html#keybindings -# get class with xprop, example output -# WM_CLASS(STRING) = "irssi", "URxvt" -# xprop |& grep WM_CLASS -bindsym $mod+w $ex "i3-abrowser" -bindsym $mod+shift+w fullscreen toggle - -bindsym $mod+e $ex "i3-emacs" -# unused -#bindsym $mod+shift+e -bindsym $mod+r $ex "/a/bin/ds/xl" - -bindsym $mod+backslash $ex "scrot" - -bindsym $mod+t $ex "i3-set-layout splitv" - -bindsym $mod+g $ex "i3-set-layout tabbed" - - -# Use Mouse+$mod to drag floating windows to their wanted position -floating_modifier $mod - -bindsym $mod+u focus left; $ex "i3-mouse-warp" -# i dont expect to use this much -bindsym $mod+shift+u $ex "i3-auto-layout-toggle" -bindsym $mod+i focus right; $ex "i3-mouse-warp" -bindsym $mod+o focus up; $ex "i3-mouse-warp" -bindsym $mod+p focus down; $ex "i3-mouse-warp" - -bindsym $mod+Left $ex "i3-split-push left" -bindsym $mod+Right $ex "i3-split-push right" -bindsym $mod+Up $ex "i3-split-push up" -bindsym $mod+Down $ex "i3-split-push down" - -# for testing in case there is a problem with above. -# these could be rebound to other things. -bindsym $mod+shift+Left move left -bindsym $mod+shift+Right move right -bindsym $mod+shift+Up move up -bindsym $mod+shift+Down move down - -bindsym $mod+Shift+a move container to workspace 1 -bindsym $mod+a workspace 1 - - -bindsym $mod+Shift+s move container to workspace 4 -bindsym $mod+s workspace 4 - -bindsym $mod+Shift+d move container to workspace 3 -bindsym $mod+d workspace 3 - -bindsym $mod+Shift+f move container to workspace 2 -bindsym $mod+f workspace 2 - -bindsym $mod+Shift+z move container to workspace 5 -bindsym $mod+z workspace 5 - -bindsym $mod+Shift+x move container to workspace 6 -bindsym $mod+x workspace 6 - -bindsym $mod+v split vertical -bindsym $mod+Shift+v move workspace to output MON-LEFT - -# 122 = XF86AudioLowerVolume, keyboardio function + t -bindcode 122 $ex "toggle-mute unmute"; mode "ptt" -mode "ptt" { -# normally, if we hold down a button, it will start automatically -# repeating itself, up and down events. But this stops that from -# happening. Based on testing, making mode be 1st or 2nd doesn't matter. -bindcode --release 122 $ex "toggle-mute mute"; mode "default" -} -# 171 = XF86AudioNext, keyboardio function + g -bindcode 171 $ex "toggle-mute unmute" - -## temp for testing, add antying here -#bindsym $mod+shift+5 $ex "/a/a.sh" - - -bindsym $mod+b $ex "i3-konsole" -bindsym $mod+shift+b unmark term; mark term - - -# for use to cleanup extra emacs windows -# https://faq.i3wm.org/question/7662/reverse-perl-matches-in-criteria-in-i3-config.1.html -# I found their regex slightly wrong. This is a hacky way to -# ignore my irc emacs instances, their window titles -# are irc room names. Another way would be to hack on the -# window title, or xprop stuff, but I figure I'm switching -# to wayland soon, lets wait and see how things work there. -bindsym $mod+shift+6 [class="Emacs" title="^(?!#[a-zA-Z][a-zA-Z-]*$)"] move workspace current - -bindsym $mod+c kill - -bindsym $mod+Home split horizontal -bindsym $mod+Shift+End move container to workspace 7 -bindsym $mod+End workspace 7 -bindsym $mod+Shift+q move container to workspace 8 -bindsym $mod+q workspace 8 - -bindsym $mod+Shift+8 move container to workspace 9 -bindsym $mod+8 workspace 9 -bindsym $mod+Shift+9 move container to workspace 10 -bindsym $mod+9 workspace 10 - -bindsym $mod+m $ex "dunstctl close-all" -bindsym $mod+Shift+m border toggle - -# 65 = space. -# toggle tiling / floating. -# -# The idea here is: when floating a window, make it sticky and 1080p, -# because the only reason we want to do this is to keep it on screen -# when doing an obs broacast. When unfloating a window, just act as a -# normal unfloat. There is a quirk with this: in a layout with 3 windows, -# 2 stacked, 1 tall, floating and ufloating the tall one will make it -# another stacked one, but still 1920x1080, you need to move it to the -# right to get it back into its tall spot. I could automate this, -# but I'm not bothering right now -bindcode $mod+65 $ex obs-auto-scene-switch-toggle; floating toggle; sticky enable; resize set 1920 1080; move position 100 ppt 0 ppt - -# change focus between tiling / floating windows -bindcode $mod+shift+65 focus mode_toggle - -bindsym $mod+shift+h $ex /b/ds/stream-clip hc -bindsym $mod+j $ex "i3-split-maybe"; exec emacsclient -c -bindsym $mod+shift+j $ex /b/ds/stream-clip up -bindsym $mod+k $ex "i3-split-maybe"; exec konsole -bindsym $mod+shift+k $ex /b/ds/stream-clip intro -bindsym $mod+l $ex dmenu_run -bindsym $mod+shift+l $ex /b/ds/stream-clip steady -bindsym $mod+shift+semicolon $ex /b/ds/stream-clip sad -# note default is 27% on my system76. not sure if these -# keybinds will screw up other laptop brightness keys. -bindsym XF86MonBrightnessUp $ex brightnessctl s +5% -bindsym XF86MonBrightnessDown $ex brightnessctl s 5%- -for_window [class="copyq" instance="copyq" window_type="normal"] floating enable -# eh, dont really like web page titles + a long browser name string. -for_window [class="firefox" instance="Navigator" window_role="browser"] title_format "b" -bindsym $mod+y $ex copyq-restart -bindsym $mod+shift+y $ex "i3-chat" - -# unused -#bindsym $mod+shift+F1 - - -# Font for window titles. Will also be used by the bar unless a different font -# is used in the bar {} block below. -font pango:monospace 7 - -# not helpful when i have split screen from myx -#hide_edge_borders vertical - -#exec --no-startup-id /usr/lib/x86_64-linux-gnu/libexec/kdeconnectd - -# Start clipster daemon -#exec --no-startup-id /a/opt/clipster/clipster -d - -# title bars but no borders. i tried this out a bit -#default_border normal 0 - -# default border is like 2 pixels -default_border pixel 2 -# for debugging -#default_border normal 10 - -# I dont see a way to make processing windows act like normal windows, -# this does it. -# https://unix.stackexchange.com/questions/450700/opening-a-programme-in-a-floating-window-in-i3 -# -# This is the info for a processing window launched from the ide. -# I'm not sure I want it like this, so commenting it out for now. -#for_window [class="processing-core-PApplet" instance="processing-core-PApplet"] floating disable - -# this is the processing window for my app named focus. -for_window [class="focus" instance="focus"] floating disable - -client.focused #4c7899 #285577 #ffffff #2e9ef4 #ff4400 -client.focused_inactive #333333 #5f676a #ffffff #484e50 #B8C8CD -client.unfocused #333333 #222222 #888888 #292d2e #B8C8CD -# exit i3 (logs you out of your X session) -bindsym $mod+Shift+o exec "i3-nagbar -t warning -m 'You pressed the exit shortcut. Do you really want to exit i3? This will end your X session.' -b 'Yes, exit i3' 'i3-msg exit'" - -bindsym $mod+Shift+i $ex /b/ds/i3-sway/gen -bindsym $mod+Shift+p restart - - -$ex copyq -$ex dunst -# haven't been using it enough to justify automatically running it.] -#$ex /usr/lib/x86_64-linux-gnu/libexec/kdeconnectd -$ex /usr/local/bin/awatch -# this dies when we restart i3. -exec_always --no-startup-id i3-event-hook -workspace 1 output -workspace 2 output -workspace 3 output -workspace 4 output -workspace 5 output -workspace 6 output -workspace 7 output -workspace 8 output -workspace 9 output -workspace 10 output -workspace 11 output -# by default, new workspaces are created on whatever screen doesn't have -# one active or else the current one. That is annoying, I have one -# primary monitor, I don't want a new workspace created on secondary -# monitor just because I happen be focused on it. This fixes that. -workspace 1 output primary -workspace 2 output MON-LEFT -workspace 3 output MON-RIGHT -workspace 4 output MON-RIGHT -workspace 5 output MON-RIGHT -workspace 6 output MON-RIGHT -workspace 7 output MON-RIGHT -workspace 8 output MON-RIGHT -workspace 9 output MON-RIGHT -workspace 10 output MON-RIGHT - -default_orientation vertical - -# bar is needed for kde connect -bar { - -# keep it only on secondary monitor to save space and make for less -# missing pixes in obs live stream. For docs on this, search "output -# primary" in the i3 guide. -output primary - -# the builtin prog -#status_command i3status - -#for faster testing -#status_command /a/bin/ds/filesystem/usr/local/bin/myi3status -status_command /usr/local/bin/myi3status -# note: old wip command: /p/c/myi3life - - -font pango:monospace 18 - -tray_output primary - -# I found I didn't need these. -# workspace_buttons no - -# note /a/c/myx adds the last bit to this file conditionally. -mode hide -hidden_state hide -} -bindsym $mod+Shift+t move workspace to output diff --git a/filesystem/usr/local/bin/ikclip b/filesystem/usr/local/bin/ikclip index 2868e61..50d428d 100755 --- a/filesystem/usr/local/bin/ikclip +++ b/filesystem/usr/local/bin/ikclip @@ -20,6 +20,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +printf "%s" "$*" >> /tmp/ikclip.log # both! printf "%s" "$*" | xclip printf "%s" "$*" | xclip -selection clipboard diff --git a/fsf-script-lib b/fsf-script-lib index b4767d4..7fa8318 100644 --- a/fsf-script-lib +++ b/fsf-script-lib @@ -123,6 +123,17 @@ slog() { # echo COMMAND then run it. m() { printf "+ %s\n" "$*" >&2; "$@"; } +# usage: mq COMMAND... +# +# echo COMMAND if verbose=true. +# Then run it. Like m, but more quietly. +mq() { + # shellcheck disable=SC2154 + if [[ $verbose == true ]]; then + printf "+ %s\n" "$*" >&2 + fi + "$@" +} # mb, maybe. echo args if they fail. mb() { diff --git a/i3-sway/gen b/i3-sway/gen index 67004fa..a3e509a 100755 --- a/i3-sway/gen +++ b/i3-sway/gen @@ -41,13 +41,12 @@ cat common.conf sway.conf > $dir/config # instead. rm -f ~/.config/i3/config -dir=/a/bin/distro-setup/filesystem/etc/i3 -mkdir -p $dir -cat common.conf i3.conf > $dir/config -if [[ -s ~/i3-myx.conf ]]; then - cat ~/i3-myx.conf >>$dir/config +f=/etc/i3/config +if [[ -e /etc/i3 && ! -w /etc/i3 ]]; then + sudo chown -R iank:iank /etc/i3 fi -conflink -f +cat common.conf i3.conf $(if [[ -s ~/i3-myx.conf ]]; then echo ~/i3-myx.conf; fi) >$f + if [[ $I3SOCK ]]; then echo $0: i3-msg $i3_cmd i3-msg $i3_cmd diff --git a/pkgs b/pkgs index d088afe..9e00cb4 100644 --- a/pkgs +++ b/pkgs @@ -253,6 +253,7 @@ p3=( oathtool opendkim-tools p7zip-full + parallel parted parted-doc pass @@ -319,6 +320,7 @@ p3=( # vlc stdout complains that it doesn't find a file from this package. libvdpau-va-gl1 wamerican-huge + webp wireless-tools w3m whois diff --git a/subdir_files/.config/mpv/input.conf b/subdir_files/.config/mpv/input.conf index 0bee5f0..5559759 100644 --- a/subdir_files/.config/mpv/input.conf +++ b/subdir_files/.config/mpv/input.conf @@ -1,3 +1,9 @@ a cycle_values video-rotate "90" "180" "270" "0" Alt+h add video-zoom 0.25 Alt+g add video-zoom -0.25 + +# Skip to the next file +b playlist-next + +# Skip to the previous file +c playlist-prev diff --git a/subdir_files/.config/mpv/mpv.conf b/subdir_files/.config/mpv/mpv.conf index c84a332..f91c63d 100644 --- a/subdir_files/.config/mpv/mpv.conf +++ b/subdir_files/.config/mpv/mpv.conf @@ -14,6 +14,19 @@ replaygain=track # the /etc one. hwdec=vdpau +# loop. saves playlist position. good for looking at gifs +[l] +loop-file=inf +save-position-on-quit +#no-resume-playback +#no-save-position-on-quit + +[m] +loop-file=inf +save-position-on-quit +shuffle + + # use --profile d [d] loop-file=inf diff --git a/subdir_files/.config/mpv/scripts/iank-like.lua b/subdir_files/.config/mpv/scripts/iank-like.lua new file mode 100644 index 0000000..2796de8 --- /dev/null +++ b/subdir_files/.config/mpv/scripts/iank-like.lua @@ -0,0 +1,16 @@ +local log_file = "/t/mpvlike.log" + +mp.add_key_binding("v", "write_filename", function() + local filepath = mp.get_property("path") + -- Convert to absolute path if it's not already + if filepath and not filepath:match("^/") and not filepath:match("^%a:") then + local working_dir = mp.get_property("working-directory") + if working_dir then + filepath = working_dir .. "/" .. filepath + end + + local file = io.open(log_file, "a") -- Open the file in append mode + file:write(filepath .. "\n") -- Write the filename with a newline + file:close() + end +end) diff --git a/subdir_files/.config/mpv/scripts/playlistmanager.lua b/subdir_files/.config/mpv/scripts/playlistmanager.lua new file mode 100644 index 0000000..8908986 --- /dev/null +++ b/subdir_files/.config/mpv/scripts/playlistmanager.lua @@ -0,0 +1,1706 @@ +-- iank wget https://raw.githubusercontent.com/jonniek/mpv-playlistmanager/16e18949e3d604c2ffe43e95391f420227881139/playlistmanager.lua +local settings = { + --navigation keybindings force override only while playlist is visible + --if "no" then you can display the playlist by any of the navigation keys + dynamic_binds = true, + + -- to bind multiple keys separate them by a space + + -- main key to show playlist + key_showplaylist = "SHIFT+ENTER", + + -- display playlist while key is held down + key_peek_at_playlist = "", + + -- dynamic keys + key_moveup = "UP", + key_movedown = "DOWN", + key_movepageup = "PGUP", + key_movepagedown = "PGDWN", + key_movebegin = "HOME", + key_moveend = "END", + key_selectfile = "RIGHT LEFT", + key_unselectfile = "", + key_playfile = "ENTER", + key_removefile = "BS", + key_closeplaylist = "ESC SHIFT+ENTER", + + -- extra functionality keys + key_sortplaylist = "", + key_shuffleplaylist = "", + key_reverseplaylist = "", + key_loadfiles = "", + key_saveplaylist = "", + + --replaces matches on filenames based on extension, put as empty string to not replace anything + --replace rules are executed in provided order + --replace rule key is the pattern and value is the replace value + --uses :gsub('pattern', 'replace'), read more http://lua-users.org/wiki/StringLibraryTutorial + --'all' will match any extension or protocol if it has one + --uses json and parses it into a lua table to be able to support .conf file + + filename_replace = [[ + [ + { + "protocol": { "all": true }, + "rules": [ + { "%%(%x%x)": "hex_to_char" } + ] + } + ] + ]], + +--[=====[ START OF SAMPLE REPLACE - Remove this line to use it + --Sample replace: replaces underscore to space on all files + --for mp4 and webm; remove extension, remove brackets and surrounding whitespace, change dot between alphanumeric to space + filename_replace = [[ + [ + { + "ext": { "all": true}, + "rules": [ + { "_" : " " } + ] + },{ + "ext": { "mp4": true, "mkv": true }, + "rules": [ + { "^(.+)%..+$": "%1" }, + { "%s*[%[%(].-[%]%)]%s*": "" }, + { "(%w)%.(%w)": "%1 %2" } + ] + },{ + "protocol": { "http": true, "https": true }, + "rules": [ + { "^%a+://w*%.?": "" } + ] + } + ] + ]], +--END OF SAMPLE REPLACE ]=====] + + --json array of filetypes to search from directory + loadfiles_filetypes = [[ + [ + "jpg", "jpeg", "png", "tif", "tiff", "gif", "webp", "svg", "bmp", + "mp3", "wav", "ogm", "flac", "m4a", "wma", "ogg", "opus", + "mkv", "avi", "mp4", "ogv", "webm", "rmvb", "flv", "wmv", "mpeg", "mpg", "m4v", "3gp" + ] + ]], + + --loadfiles at startup if 1 or more items in playlist + loadfiles_on_start = false, + -- loadfiles from working directory on idle startup + loadfiles_on_idle_start = false, + --always put loaded files after currently playing file + loadfiles_always_append = false, + + --sort playlist when files are added to playlist + sortplaylist_on_file_add = false, + + --default sorting method, must be one of: "name-asc", "name-desc", "date-asc", "date-desc", "size-asc", "size-desc". + default_sort = "name-asc", + + --"linux | windows | auto" + system = "auto", + + --Use ~ for home directory. Leave as empty to use mpv/playlists + playlist_savepath = "", + + -- constant filename to save playlist as. Note that it will override existing playlist. Leave empty for generated name. + playlist_save_filename = "", + + --save playlist automatically after current file was unloaded + save_playlist_on_file_end = false, + + + --show file title every time a new file is loaded + show_title_on_file_load = false, + --show playlist every time a new file is loaded + show_playlist_on_file_load = false, + --close playlist when selecting file to play + close_playlist_on_playfile = false, + + --sync cursor when file is loaded from outside reasons(file-ending, playlist-next shortcut etc.) + --has the sideeffect of moving cursor if file happens to change when navigating + --good side is cursor always following current file when going back and forth files with playlist-next/prev + sync_cursor_on_load = true, + + --allow the playlist cursor to loop from end to start and vice versa + loop_cursor = true, + + + -- allow playlistmanager to write watch later config when navigating between files + allow_write_watch_later_config = true, + + -- reset cursor navigation when closing or opening playlist + reset_cursor_on_close = true, + reset_cursor_on_open = true, + + --prefer to display titles for following files: "all", "url", "none". Sorting still uses filename. + prefer_titles = "url", + + --youtube-dl executable for title resolving if enabled, probably "youtube-dl" or "yt-dlp", can be absolute path + youtube_dl_executable = "yt-dlp", + + --call youtube-dl to resolve the titles of urls in the playlist + resolve_url_titles = false, + + --call ffprobe to resolve the titles of local files in the playlist (if they exist in the metadata) + resolve_local_titles = false, + + -- timeout in seconds for url title resolving + resolve_title_timeout = 15, + + -- how many url titles can be resolved at a time. Higher number might lead to stutters. + concurrent_title_resolve_limit = 10, + + --osd timeout on inactivity in seconds, use 0 for no timeout + playlist_display_timeout = 0, + + -- when peeking at playlist, show playlist at the very least for display timeout + peek_respect_display_timeout = false, + + -- the maximum amount of lines playlist will render. -1 will automatically calculate lines. + showamount = -1, + + --playlist ass style overrides inside curly brackets, \keyvalue is one field, extra \ for escape in lua + --example {\\q2\\an7\\fnUbuntu\\fs10\\b0\\bord1} equals: line-wrap=no, align=top left, font=Ubuntu, size=10, bold=no, border=1 + --read http://docs.aegisub.org/3.2/ASS_Tags/ for reference of tags + --undeclared tags will use default osd settings + --these styles will be used for the whole playlist + --\\q2 style is recommended since filename wrapping may lead to unexpected rendering + --\\an7 style is recommended to align to top left otherwise, osd-align-x/y is respected + style_ass_tags = "{\\q2\\an7}", + --paddings for left right and top bottom + text_padding_x = 30, + text_padding_y = 60, + + --screen dim when menu is open 0.0 - 1.0 (0 is no dim, 1 is black) + curtain_opacity=0.0, + + --set title of window with stripped name + set_title_stripped = false, + title_prefix = "", + title_suffix = " - mpv", + + --slice long filenames, and how many chars to show + slice_longfilenames = false, + slice_longfilenames_amount = 70, + + --Playlist header template + --%mediatitle or %filename = title or name of playing file + --%pos = position of playing file + --%cursor = position of navigation + --%plen = playlist length + --%N = newline + playlist_header = "[%cursor/%plen]", + + --Playlist file templates + --%pos = position of file with leading zeros + --%name = title or name of file + --%N = newline + --you can also use the ass tags mentioned above. For example: + -- selected_file="{\\c&HFF00FF&}➔ %name" | to add a color for selected file. However, if you + -- use ass tags you need to reset them for every line (see https://github.com/jonniek/mpv-playlistmanager/issues/20) + normal_file = "○ %name", + hovered_file = "● %name", + selected_file = "➔ %name", + playing_file = "▷ %name", + playing_hovered_file = "▶ %name", + playing_selected_file = "➤ %name", + + + -- what to show when playlist is truncated + playlist_sliced_prefix = "...", + playlist_sliced_suffix = "...", + + --output visual feedback to OSD for tasks + display_osd_feedback = true, +} +local opts = require("mp.options") +opts.read_options(settings, "playlistmanager", function(list) update_opts(list) end) + +local utils = require("mp.utils") +local msg = require("mp.msg") +local assdraw = require("mp.assdraw") + +local alignment_table = { + [1] = { ["x"] = "left", ["y"] = "bottom" }, + [2] = { ["x"] = "center", ["y"] = "bottom" }, + [3] = { ["x"] = "right", ["y"] = "bottom" }, + [4] = { ["x"] = "left", ["y"] = "center" }, + [5] = { ["x"] = "center", ["y"] = "center" }, + [6] = { ["x"] = "right", ["y"] = "center" }, + [7] = { ["x"] = "left", ["y"] = "top" }, + [8] = { ["x"] = "center", ["y"] = "top" }, + [9] = { ["x"] = "right", ["y"] = "top" }, +} + +--check os +if settings.system=="auto" then + local o = {} + if mp.get_property_native('options/vo-mmcss-profile', o) ~= o then + settings.system = "windows" + else + settings.system = "linux" + end +end + +-- auto calculate showamount +if settings.showamount == -1 then + -- same as draw_playlist() height + local h = 720 + + local playlist_h = h + -- both top and bottom with same padding + playlist_h = playlist_h - settings.text_padding_y * 2 + + -- osd-font-size is based on 720p height + -- see https://mpv.io/manual/stable/#options-osd-font-size + -- details in https://mpv.io/manual/stable/#options-sub-font-size + -- draw_playlist() is based on 720p, need some conversion + local fs = mp.get_property_native('osd-font-size') * h / 720 + -- get the ass font size + if settings.style_ass_tags ~= nil then + local ass_fs_tag = settings.style_ass_tags:match('\\fs%d+') + if ass_fs_tag ~= nil then + fs = tonumber(ass_fs_tag:match('%d+')) + end + end + + settings.showamount = math.floor(playlist_h / fs) + + -- exclude the header line + if settings.playlist_header ~= "" then + settings.showamount = settings.showamount - 1 + -- probably some newlines (%N or \N) in the header + for _ in settings.playlist_header:gmatch('%%N') do + settings.showamount = settings.showamount - 1 + end + for _ in settings.playlist_header:gmatch('\\N') do + settings.showamount = settings.showamount - 1 + end + end + + msg.info('auto showamount: ' .. settings.showamount) +end + +--global variables +local playlist_overlay = mp.create_osd_overlay("ass-events") +local playlist_visible = false +local strippedname = nil +local path = nil +local directory = nil +local filename = nil +local pos = 0 +local plen = 0 +local cursor = 0 +--table for saved media titles for later if we prefer them +local title_table = {} +-- table for urls and local file paths that we have requested to be resolved to titles +local requested_titles = {} + +local filetype_lookup = {} + +function refresh_UI() + if not playlist_visible then return end + refresh_globals() + if plen == 0 then return end + draw_playlist() +end + +function update_opts(changelog) + msg.verbose('updating options') + + --parse filename json + if changelog.filename_replace then + if(settings.filename_replace~="") then + settings.filename_replace = utils.parse_json(settings.filename_replace) + else + settings.filename_replace = false + end + end + + --parse loadfiles json + if changelog.loadfiles_filetypes then + settings.loadfiles_filetypes = utils.parse_json(settings.loadfiles_filetypes) + + filetype_lookup = {} + --create loadfiles set + for _, ext in ipairs(settings.loadfiles_filetypes) do + filetype_lookup[ext] = true + end + end + + if changelog.resolve_url_titles then + resolve_titles() + end + + if changelog.resolve_local_titles then + resolve_titles() + end + + if changelog.playlist_display_timeout then + keybindstimer = mp.add_periodic_timer(settings.playlist_display_timeout, remove_keybinds) + keybindstimer:kill() + end + + refresh_UI() +end + +update_opts({filename_replace = true, loadfiles_filetypes = true}) + +----- 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 +local winapisort = nil +if settings.system == "windows" then + -- ffiok is false usually means the mpv builds without luajit + local ffiok, ffi = pcall(require, "ffi") + if ffiok then + ffi.cdef[[ + int MultiByteToWideChar(unsigned int CodePage, unsigned long dwFlags, const char *lpMultiByteStr, int cbMultiByte, wchar_t *lpWideCharStr, int cchWideChar); + int StrCmpLogicalW(const wchar_t * psz1, const wchar_t * psz2); + ]] + + local shlwapi = ffi.load("shlwapi.dll") + + function MultiByteToWideChar(MultiByteStr) + local UTF8_CODEPAGE = 65001 + if MultiByteStr then + local utf16_len = ffi.C.MultiByteToWideChar(UTF8_CODEPAGE, 0, MultiByteStr, -1, nil, 0) + if utf16_len > 0 then + local utf16_str = ffi.new("wchar_t[?]", utf16_len) + if ffi.C.MultiByteToWideChar(UTF8_CODEPAGE, 0, MultiByteStr, -1, utf16_str, utf16_len) > 0 then + return utf16_str + end + end + end + return "" + end + + winapisort = function (a, b) + return shlwapi.StrCmpLogicalW(MultiByteToWideChar(a), MultiByteToWideChar(b)) < 0 + end + + end +end +----- winapi end ----- + +local sort_modes = { + { + id="name-asc", + title="name ascending", + sort_fn=function (a, b, playlist) + if winapisort ~= nil then + return winapisort(playlist[a].string, playlist[b].string) + end + return alphanumsort(playlist[a].string, playlist[b].string) + end, + }, + { + id="name-desc", + title="name descending", + sort_fn=function (a, b, playlist) + if winapisort ~= nil then + return winapisort(playlist[b].string, playlist[a].string) + end + return alphanumsort(playlist[b].string, playlist[a].string) + end, + }, + { + id="date-asc", + title="date ascending", + sort_fn=function (a, b) + return (get_file_info(a).mtime or 0) < (get_file_info(b).mtime or 0) + end, + }, + { + id="date-desc", + title="date descending", + sort_fn=function (a, b) + return (get_file_info(a).mtime or 0) > (get_file_info(b).mtime or 0) + end, + }, + { + id="size-asc", + title="size ascending", + sort_fn=function (a, b) + return (get_file_info(a).size or 0) < (get_file_info(b).size or 0) + end, + }, + { + id="size-desc", + title="size descending", + sort_fn=function (a, b) + return (get_file_info(a).size or 0) > (get_file_info(b).size or 0) + end, + }, +} + +local sort_mode = 1 +for mode, sort_data in pairs(sort_modes) do + if sort_data.id == settings.default_sort then + sort_mode = mode + end +end + +function is_protocol(path) + return type(path) == 'string' and path:match('^%a[%a%d-_]+://') ~= nil +end + +function on_file_loaded() + refresh_globals() + if settings.sync_cursor_on_load then cursor=pos end + refresh_UI() -- refresh only after moving cursor + + filename = mp.get_property("filename") + path = mp.get_property('path') + local media_title = mp.get_property("media-title") + if is_protocol(path) and not title_table[path] and path ~= media_title then + title_table[path] = media_title + end + + strippedname = stripfilename(mp.get_property('media-title')) + if settings.show_title_on_file_load then + mp.commandv('show-text', strippedname) + end + if settings.show_playlist_on_file_load then + showplaylist() + end + if settings.set_title_stripped then + mp.set_property("title", settings.title_prefix..strippedname..settings.title_suffix) + end +end + +function on_start_file() + refresh_globals() + filename = mp.get_property("filename") + path = mp.get_property('path') + --if not a url then join path with working directory + if not is_protocol(path) then + path = utils.join_path(mp.get_property('working-directory'), path) + directory = utils.split_path(path) + else + directory = nil + end + + if settings.loadfiles_on_start and plen == 1 then + local ext = filename:match("%.([^%.]+)$") + -- a directory or playlist has been loaded, let's not do anything as mpv will expand it into files + if ext and filetype_lookup[ext:lower()] then + msg.info("Loading files from playing files directory") + playlist() + end + end +end + +function on_end_file() + if settings.save_playlist_on_file_end then save_playlist() end + strippedname = nil + path = nil + directory = nil + filename = nil +end + +function refresh_globals() + pos = mp.get_property_number('playlist-pos', 0) + plen = mp.get_property_number('playlist-count', 0) +end + +function escapepath(dir, escapechar) + return string.gsub(dir, escapechar, '\\'..escapechar) +end + +function replace_table_has_value(value, valid_values) + if value == nil or valid_values == nil then + return false + end + return valid_values['all'] or valid_values[value] +end + +local filename_replace_functions = { + --decode special characters in url + hex_to_char = function(x) return string.char(tonumber(x, 16)) end +} + +-- from http://lua-users.org/wiki/LuaUnicode +local UTF8_PATTERN = '[%z\1-\127\194-\244][\128-\191]*' + +-- return a substring based on utf8 characters +-- like string.sub, but negative index is not supported +local function utf8_sub(s, i, j) + if i > j then + return s + end + + local t = {} + local idx = 1 + for char in s:gmatch(UTF8_PATTERN) do + if i <= idx and idx <= j then + local width = #char > 2 and 2 or 1 + idx = idx + width + t[#t + 1] = char + end + end + return table.concat(t) +end + +--strip a filename based on its extension or protocol according to rules in settings +function stripfilename(pathfile, media_title) + if pathfile == nil then return '' end + local ext = pathfile:match("%.([^%.]+)$") + local protocol = pathfile:match("^(%a%a+)://") + if not ext then ext = "" end + local tmp = pathfile + if settings.filename_replace and not media_title then + for k,v in ipairs(settings.filename_replace) do + if replace_table_has_value(ext, v['ext']) or replace_table_has_value(protocol, v['protocol']) then + for ruleindex, indexrules in ipairs(v['rules']) do + for rule, override in pairs(indexrules) do + override = filename_replace_functions[override] or override + tmp = tmp:gsub(rule, override) + end + end + end + end + end + local tmp_clip = utf8_sub(tmp, 1, settings.slice_longfilenames_amount) + if settings.slice_longfilenames and tmp ~= tmp_clip then + tmp = tmp_clip .. "..." + end + return tmp +end + +--gets the file info of an item +function get_file_info(item) + local path = mp.get_property('playlist/' .. item - 1 .. '/filename') + if is_protocol(path) then return {} end + local file_info = utils.file_info(path) + if not file_info then + msg.warn('failed to read file info for', path) + return {} + end + + return file_info +end + +--gets a nicename of playlist entry at 0-based position i +function get_name_from_index(i, notitle) + refresh_globals() + if plen <= i then msg.error("no index in playlist", i, "length", plen); return nil end + local _, name = nil + local title = mp.get_property('playlist/'..i..'/title') + local name = mp.get_property('playlist/'..i..'/filename') + + local should_use_title = settings.prefer_titles == 'all' or is_protocol(name) and settings.prefer_titles == 'url' + + --check if file has a media title stored + if not title and should_use_title and title_table[name] then + title = title_table[name] + end + + --if we have media title use a more conservative strip + if title and not notitle and should_use_title then + -- Escape a string for verbatim display on the OSD + -- Ref: https://github.com/mpv-player/mpv/blob/94677723624fb84756e65c8f1377956667244bc9/player/lua/stats.lua#L145 + return stripfilename(title, true):gsub("\\", '\\\239\187\191'):gsub("{", "\\{"):gsub("^ ", "\\h") + end + + --remove paths if they exist, keeping protocols for stripping + if string.sub(name, 1, 1) == '/' or name:match("^%a:[/\\]") then + _, name = utils.split_path(name) + end + return stripfilename(name):gsub("\\", '\\\239\187\191'):gsub("{", "\\{"):gsub("^ ", "\\h") +end + +function parse_header(string) + local esc_title = stripfilename(mp.get_property("media-title"), true):gsub("%%", "%%%%") + local esc_file = stripfilename(mp.get_property("filename")):gsub("%%", "%%%%") + return string:gsub("%%N", "\\N") + -- add a blank character at the end of each '\N' to ensure that the height of the empty line is the same as the non empty line + :gsub("\\N", "\\N ") + :gsub("%%pos", mp.get_property_number("playlist-pos",0)+1) + :gsub("%%plen", mp.get_property("playlist-count")) + :gsub("%%cursor", cursor+1) + :gsub("%%mediatitle", esc_title) + :gsub("%%filename", esc_file) + -- undo name escape + :gsub("%%%%", "%%") +end + +function parse_filename(string, name, index) + local base = tostring(plen):len() + local esc_name = stripfilename(name):gsub("%%", "%%%%") + return string:gsub("%%N", "\\N") + :gsub("%%pos", string.format("%0"..base.."d", index+1)) + :gsub("%%name", esc_name) + -- undo name escape + :gsub("%%%%", "%%") +end + +function parse_filename_by_index(index) + local template = settings.normal_file + + local is_idle = mp.get_property_native('idle-active') + local position = is_idle and -1 or pos + + if index == position then + if index == cursor then + if selection then + template = settings.playing_selected_file + else + template = settings.playing_hovered_file + end + else + template = settings.playing_file + end + elseif index == cursor then + if selection then + template = settings.selected_file + else + template = settings.hovered_file + end + end + + return parse_filename(template, get_name_from_index(index), index) +end + +function is_terminal_mode() + local width, height, aspect_ratio = mp.get_osd_size() + return width == 0 and height == 0 and aspect_ratio == 0 +end + +function draw_playlist() + refresh_globals() + + -- if there is no playing file, then cursor can be -1. That would break rendering of playlist. + if cursor == -1 then + cursor = 0 + end + + local ass = assdraw.ass_new() + local terminaloutput = "" + + local _, _, a = mp.get_osd_size() + local h = 720 + local w = math.ceil(h * a) + + if settings.curtain_opacity ~= nil and settings.curtain_opacity ~= 0 and settings.curtain_opacity <= 1.0 then + -- curtain dim from https://github.com/christoph-heinrich/mpv-quality-menu/blob/501794bfbef468ee6a61e54fc8821fe5cd72c4ed/quality-menu.lua#L699-L707 + local alpha = 255 - math.ceil(255 * settings.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, w, h) + ass:draw_stop() + ass:new_event() + end + + ass:append(settings.style_ass_tags) + + -- add \clip style + -- make both left and right follow text_padding_x + -- both top and bottom follow text_padding_y + local border_size = mp.get_property_number('osd-border-size') + if settings.style_ass_tags ~= nil then + local bord = tonumber(settings.style_ass_tags:match('\\bord(%d+%.?%d*)')) + if bord ~= nil then border_size = bord end + end + ass:append(string.format('{\\clip(%f,%f,%f,%f)}', + settings.text_padding_x - border_size, settings.text_padding_y - border_size, + w - 1 - settings.text_padding_x + border_size, h - 1 - settings.text_padding_y + border_size)) + + -- align from mpv.conf + local align_x = mp.get_property("osd-align-x") + local align_y = mp.get_property("osd-align-y") + -- align from style_ass_tags + if settings.style_ass_tags ~= nil then + local an = tonumber(settings.style_ass_tags:match('\\an(%d)')) + if an ~= nil and alignment_table[an] ~= nil then + align_x = alignment_table[an]["x"] + align_y = alignment_table[an]["y"] + end + end + -- range of x [0, w-1] + local pos_x + if align_x == 'left' then + pos_x = settings.text_padding_x + elseif align_x == 'right' then + pos_x = w - 1 - settings.text_padding_x + else + pos_x = math.floor((w - 1) / 2) + end + -- range of y [0, h-1] + local pos_y + if align_y == 'top' then + pos_y = settings.text_padding_y + elseif align_y == 'bottom' then + pos_y = h - 1 - settings.text_padding_y + else + pos_y = math.floor((h - 1) / 2) + end + ass:pos(pos_x, pos_y) + + if settings.playlist_header ~= "" then + local header = parse_header(settings.playlist_header) + ass:append(header.."\\N") + terminaloutput = terminaloutput..header.."\n" + end + + -- (visible index, playlist index) pairs of playlist entries that should be rendered + local visible_indices = {} + + local one_based_cursor = cursor + 1 + table.insert(visible_indices, one_based_cursor) + + local offset = 1; + local visible_indices_length = 1; + while visible_indices_length < settings.showamount and visible_indices_length < plen do + -- add entry for offset steps below the cursor + local below = one_based_cursor + offset + if below <= plen then + table.insert(visible_indices, below) + visible_indices_length = visible_indices_length + 1; + end + + -- add entry for offset steps above the cursor + -- also need to double check that there is still space, this happens if we have even numbered limit + local above = one_based_cursor - offset + if above >= 1 and visible_indices_length < settings.showamount and visible_indices_length < plen then + table.insert(visible_indices, 1, above) + visible_indices_length = visible_indices_length + 1; + end + + offset = offset + 1 + end + + -- both indices are 1 based + for display_index, playlist_index in pairs(visible_indices) do + if display_index == 1 and playlist_index ~= 1 then + ass:append(settings.playlist_sliced_prefix.."\\N") + terminaloutput = terminaloutput..settings.playlist_sliced_prefix.."\n" + elseif display_index == settings.showamount and playlist_index ~= plen then + ass:append(settings.playlist_sliced_suffix) + terminaloutput = terminaloutput..settings.playlist_sliced_suffix.."\n" + else + -- parse_filename_by_index expects 0 based index + local fname = parse_filename_by_index(playlist_index - 1) + ass:append(fname.."\\N") + terminaloutput = terminaloutput..fname.."\n" + end + end + + if is_terminal_mode() then + local timeout_setting = settings.playlist_display_timeout + local timeout = timeout_setting == 0 and 2147483 or timeout_setting + -- TODO: probably have to strip ass tags from terminal output + -- would maybe be possible to use terminal color output instead + mp.osd_message(terminaloutput, timeout) + else + playlist_overlay.data = ass.text + playlist_overlay:update() + end +end + +local peek_display_timer = nil +local peek_button_pressed = false + +function peek_timeout() + peek_display_timer:kill() + if not peek_button_pressed and not playlist_visible then + remove_keybinds() + end +end + +function handle_complex_playlist_toggle(table) + local event = table["event"] + if event == "press" then + msg.error("Complex key event not supported. Falling back to normal playlist display.") + showplaylist() + elseif event == "down" then + showplaylist(1000000) + if settings.peek_respect_display_timeout then + peek_button_pressed = true + peek_display_timer = mp.add_periodic_timer(settings.playlist_display_timeout, peek_timeout) + end + elseif event == "up" then + -- set playlist state to not visible, doesn't actually hide playlist yet + -- this will allow us to check if other functionality has rendered playlist before removing binds + playlist_visible = false + + function remove_keybinds_after_timeout() + -- if playlist is still not visible then lets actually hide it + -- this lets other keys that interupt the peek to render playlist without peek up event closing it + if not playlist_visible then + remove_keybinds() + end + end + + if settings.peek_respect_display_timeout then + peek_button_pressed = false + if not peek_display_timer:is_enabled() then + mp.add_timeout(0.01, remove_keybinds_after_timeout) + end + else + -- use small delay to let dynamic binds run before keys are potentially unbound + mp.add_timeout(0.01, remove_keybinds_after_timeout) + end + end +end + +function toggle_playlist(show_function) + local show = show_function or showplaylist + if playlist_visible then + remove_keybinds() + else + -- toggle always shows without timeout + show(0) + end +end + +function showplaylist(duration) + refresh_globals() + if plen == 0 then return end + if not playlist_visible and settings.reset_cursor_on_open then + resetcursor() + end + + playlist_visible = true + add_keybinds() + + draw_playlist() + keybindstimer:kill() + + local dur = tonumber(duration) or settings.playlist_display_timeout + if dur > 0 then + keybindstimer = mp.add_periodic_timer(dur, remove_keybinds) + end +end + +function showplaylist_non_interactive(duration) + refresh_globals() + if plen == 0 then return end + if not playlist_visible and settings.reset_cursor_on_open then + resetcursor() + end + playlist_visible = true + draw_playlist() + keybindstimer:kill() + + local dur = tonumber(duration) or settings.playlist_display_timeout + if dur > 0 then + keybindstimer = mp.add_periodic_timer(dur, remove_keybinds) + end +end + +selection=nil +function selectfile() + refresh_globals() + if plen == 0 then return end + if not selection then + selection=cursor + else + selection=nil + end + showplaylist() +end + +function unselectfile() + selection=nil + showplaylist() +end + +function resetcursor() + selection = nil + cursor = mp.get_property_number('playlist-pos', 1) +end + +function removefile() + refresh_globals() + if plen == 0 then return end + selection = nil + if cursor==pos then mp.command("script-message unseenplaylist mark true \"playlistmanager avoid conflict when removing file\"") end + mp.commandv("playlist-remove", cursor) + if cursor==plen-1 then cursor = cursor - 1 end + if plen == 1 then + remove_keybinds() + else + showplaylist() + end +end + +function moveup() + refresh_globals() + if plen == 0 then return end + if cursor~=0 then + if selection then mp.commandv("playlist-move", cursor,cursor-1) end + cursor = cursor-1 + elseif settings.loop_cursor then + if selection then mp.commandv("playlist-move", cursor,plen) end + cursor = plen-1 + end + showplaylist() +end + +function movedown() + refresh_globals() + if plen == 0 then return end + if cursor ~= plen-1 then + if selection then mp.commandv("playlist-move", cursor,cursor+2) end + cursor = cursor + 1 + elseif settings.loop_cursor then + if selection then mp.commandv("playlist-move", cursor,0) end + cursor = 0 + end + showplaylist() +end + + +function movepageup() + refresh_globals() + if plen == 0 or cursor == 0 then return end + local offset = settings.showamount % 2 == 0 and 1 or 0 + local last_file_that_doesnt_scroll = math.ceil(settings.showamount / 2) + local reverse_cursor = plen - cursor + local files_to_jump = math.max(last_file_that_doesnt_scroll + offset - reverse_cursor, 0) + settings.showamount - 2 + local prev_cursor = cursor + cursor = cursor - files_to_jump + if cursor < last_file_that_doesnt_scroll then + cursor = 0 + end + if selection then + mp.commandv("playlist-move", prev_cursor, cursor) + end + showplaylist() +end + +function movepagedown() + refresh_globals() + if plen == 0 or cursor == plen - 1 then return end + local last_file_that_doesnt_scroll = math.ceil(settings.showamount / 2) - 1 + local files_to_jump = math.max(last_file_that_doesnt_scroll - cursor, 0) + settings.showamount - 2 + local prev_cursor = cursor + cursor = cursor + files_to_jump + + local cursor_on_last_page = plen - (settings.showamount - 3) + if cursor > cursor_on_last_page then + cursor = plen - 1 + end + if selection then + mp.commandv("playlist-move", prev_cursor, cursor + 1) + end + showplaylist() +end + + +function movebegin() + refresh_globals() + if plen == 0 or cursor == 0 then return end + local prev_cursor = cursor + cursor = 0 + if selection then mp.commandv("playlist-move", prev_cursor, cursor) end + showplaylist() +end + +function moveend() + refresh_globals() + if plen == 0 or cursor == plen-1 then return end + local prev_cursor = cursor + cursor = plen-1 + if selection then mp.commandv("playlist-move", prev_cursor, cursor+1) end + showplaylist() +end + +function write_watch_later(force_write) + if settings.allow_write_watch_later_config then + if mp.get_property_bool("save-position-on-quit") or force_write then + mp.command("write-watch-later-config") + end + end +end + +function playlist_next() + write_watch_later(true) + mp.commandv("playlist-next", "weak") + if settings.close_playlist_on_playfile then + remove_keybinds() + end + refresh_UI() +end + +function playlist_prev() + write_watch_later(true) + mp.commandv("playlist-prev", "weak") + if settings.close_playlist_on_playfile then + remove_keybinds() + end + refresh_UI() +end + +function playlist_random() + write_watch_later() + refresh_globals() + if plen < 2 then return end + math.randomseed(os.time()) + local random = pos + while random == pos do + random = math.random(0, plen-1) + end + mp.set_property("playlist-pos", random) + if settings.close_playlist_on_playfile then + remove_keybinds() + end +end + +function playfile() + refresh_globals() + if plen == 0 then return end + selection = nil + local is_idle = mp.get_property_native('idle-active') + if cursor ~= pos or is_idle then + write_watch_later() + mp.set_property("playlist-pos", cursor) + else + if cursor~=plen-1 then + cursor = cursor + 1 + end + write_watch_later() + mp.commandv("playlist-next", "weak") + end + if settings.close_playlist_on_playfile then + remove_keybinds() + elseif playlist_visible then + showplaylist() + end +end + +function file_filter(filenames) + local files = {} + for i = 1, #filenames do + local file = filenames[i] + local ext = file:match('%.([^%.]+)$') + if ext and filetype_lookup[ext:lower()] then + table.insert(files, file) + end + end + return files +end + +function get_playlist_filenames_set() + local filenames = {} + for n=0,plen-1,1 do + local filename = mp.get_property('playlist/'..n..'/filename') + local _, file = utils.split_path(filename) + filenames[file] = true + end + return filenames +end + +--Creates a playlist of all files in directory, will keep the order and position +--For exaple, Folder has 12 files, you open the 5th file and run this, the remaining 7 are added behind the 5th file and prior 4 files before it +function playlist(force_dir) + refresh_globals() + if not directory and plen > 0 then return end + local hasfile = true + if plen == 0 then + hasfile = false + dir = mp.get_property('working-directory') + else + dir = directory + end + + if dir == "." then dir = "" end + if force_dir then dir = force_dir end + + local files = file_filter(utils.readdir(dir, "files")) + if winapisort ~= nil then + table.sort(files, winapisort) + else + table.sort(files, alphanumsort) + end + + + if files == nil then + msg.verbose("no files in directory") + return + end + + local filenames = get_playlist_filenames_set() + local c, c2 = 0,0 + if files then + local cur = false + local filename = mp.get_property("filename") + for _, file in ipairs(files) do + if file == nil or file[1] == "." then + break + end + local appendstr = "append" + if not hasfile then + cur = true + appendstr = "append-play" + hasfile = true + end + if filename == file then + cur = true + elseif filenames[file] then + -- skip files already in playlist + elseif cur == true or settings.loadfiles_always_append then + mp.commandv("loadfile", utils.join_path(dir, file), appendstr) + msg.info("Appended to playlist: " .. file) + c2 = c2 + 1 + else + mp.commandv("loadfile", utils.join_path(dir, file), appendstr) + msg.info("Prepended to playlist: " .. file) + mp.commandv("playlist-move", mp.get_property_number("playlist-count", 1)-1, c) + c = c + 1 + end + end + if c2 > 0 or c>0 then + msg.info("Added "..c + c2.." files to playlist") + else + msg.info("No additional files found") + end + cursor = mp.get_property_number('playlist-pos', 1) + else + msg.error("Could not scan for files: "..(error or "")) + end + refresh_globals() + if playlist_visible then + showplaylist() + end + if settings.display_osd_feedback then + if c2 > 0 or c>0 then + mp.osd_message("Added "..c + c2.." files to playlist") + else + mp.osd_message("No additional files found") + end + end + return c + c2 +end + +function parse_home(path) + if not path:find("^~") then + return path + end + local home_dir = os.getenv("HOME") or os.getenv("USERPROFILE") + if not home_dir then + local drive = os.getenv("HOMEDRIVE") + local path = os.getenv("HOMEPATH") + if drive and path then + home_dir = utils.join_path(drive, path) + else + msg.error("Couldn't find home dir.") + return nil + end + end + local result = path:gsub("^~", home_dir) + return result +end + +local interactive_save = false +function activate_playlist_save() + if interactive_save then + remove_keybinds() + mp.command("script-message playlistmanager-save-interactive \"start interactive filenaming process\"") + else + save_playlist() + end +end + +--saves the current playlist into a m3u file +function save_playlist(filename) + local length = mp.get_property_number('playlist-count', 0) + if length == 0 then return end + + --get playlist save path + local savepath + if settings.playlist_savepath == nil or settings.playlist_savepath == "" then + savepath = mp.command_native({"expand-path", "~~home/"}).."/playlists" + else + savepath = parse_home(settings.playlist_savepath) + if savepath == nil then return end + end + + --create savepath if it doesn't exist + if utils.readdir(savepath) == nil then + local windows_args = {'powershell', '-NoProfile', '-Command', 'mkdir', savepath} + local unix_args = { 'mkdir', savepath } + local args = settings.system == 'windows' and windows_args or unix_args + local res = utils.subprocess({ args = args, cancellable = false }) + if res.status ~= 0 then + msg.error("Failed to create playlist save directory "..savepath..". Error: "..(res.error or "unknown")) + return + end + end + + local name = filename + if name == nil then + if settings.playlist_save_filename == nil or settings.playlist_save_filename == "" then + local date = os.date("*t") + local datestring = ("%02d-%02d-%02d_%02d-%02d-%02d"):format(date.year, date.month, date.day, date.hour, date.min, date.sec) + + name = datestring.."_playlist-size_"..length..".m3u" + else + name = settings.playlist_save_filename + end + end + + local savepath = utils.join_path(savepath, name) + local file, err = io.open(savepath, "w") + if not file then + msg.error("Error in creating playlist file, check permissions. Error: "..(err or "unknown")) + else + file:write("#EXTM3U\n") + local i=0 + while i < length do + local pwd = mp.get_property("working-directory") + local filename = mp.get_property('playlist/'..i..'/filename') + local fullpath = filename + if not is_protocol(filename) then + fullpath = utils.join_path(pwd, filename) + end + local title = mp.get_property('playlist/'..i..'/title') or title_table[filename] + if title then + file:write("#EXTINF:,"..title.."\n") + end + file:write(fullpath, "\n") + i=i+1 + end + local saved_msg = "Playlist written to: "..savepath + if settings.display_osd_feedback then mp.osd_message(saved_msg) end + msg.info(saved_msg) + file:close() + end +end + +function alphanumsort(a, b) + local function padnum(d) + local dec, n = string.match(d, "(%.?)0*(.+)") + return #dec > 0 and ("%.12f"):format(d) or ("%s%03d%s"):format(dec, #n, n) + end + return tostring(a):lower():gsub("%.?%d+",padnum)..("%3d"):format(#b) + < tostring(b):lower():gsub("%.?%d+",padnum)..("%3d"):format(#a) +end + +-- fast sort algo from https://github.com/zsugabubus/dotfiles/blob/master/.config/mpv/scripts/playlist-filtersort.lua +function sortplaylist(startover) + local playlist = mp.get_property_native('playlist') + if #playlist < 2 then return end + + local order = {} + for i=1, #playlist do + order[i] = i + playlist[i].string = get_name_from_index(i - 1, true) + end + + table.sort(order, function(a, b) + return sort_modes[sort_mode].sort_fn(a, b, playlist) + end) + + for i=1, #playlist do + playlist[order[i]].new_pos = i + end + + for i=1, #playlist do + while true do + local j = playlist[i].new_pos + if i == j then + break + end + mp.commandv('playlist-move', (i) - 1, (j + 1) - 1) + mp.commandv('playlist-move', (j - 1) - 1, (i) - 1) + playlist[j], playlist[i] = playlist[i], playlist[j] + end + end + + for i = 1, #playlist do + local filename = mp.get_property('playlist/' .. i - 1 .. '/filename') + local ext = filename:match("%.([^%.]+)$") + if not ext or not filetype_lookup[ext:lower()] then + --move the directory to the end of the playlist + mp.commandv('playlist-move', i - 1, #playlist) + end + end + + cursor = mp.get_property_number('playlist-pos', 0) + if startover then + mp.set_property('playlist-pos', 0) + end + if playlist_visible then + showplaylist() + end + if settings.display_osd_feedback then + mp.osd_message("Playlist sorted with "..sort_modes[sort_mode].title) + end +end + +function reverseplaylist() + local length = mp.get_property_number('playlist-count', 0) + if length < 2 then return end + for outer=1, length-1, 1 do + mp.commandv('playlist-move', outer, 0) + end + if playlist_visible then + showplaylist() + end + if settings.display_osd_feedback then + mp.osd_message("Playlist reversed") + end +end + +function shuffleplaylist() + refresh_globals() + if plen < 2 then return end + mp.command("playlist-shuffle") + math.randomseed(os.time()) + mp.commandv("playlist-move", pos, math.random(0, plen-1)) + + local playlist = mp.get_property_native('playlist') + for i = 1, #playlist do + local filename = mp.get_property('playlist/' .. i - 1 .. '/filename') + local ext = filename:match("%.([^%.]+)$") + if not ext or not filetype_lookup[ext:lower()] then + --move the directory to the end of the playlist + mp.commandv('playlist-move', i - 1, #playlist) + end + end + + mp.set_property('playlist-pos', 0) + refresh_globals() + if playlist_visible then + showplaylist() + end + if settings.display_osd_feedback then + mp.osd_message("Playlist shuffled") + end +end + +function bind_keys(keys, name, func, opts) + if keys == nil or keys == "" then + mp.add_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_key_binding(key, name..prefix, func, opts) + i = i + 1 + end +end + +function bind_keys_forced(keys, name, func, opts) + if keys == nil or 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 keys == nil or 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 add_keybinds() + bind_keys_forced(settings.key_moveup, 'moveup', moveup, "repeatable") + bind_keys_forced(settings.key_movedown, 'movedown', movedown, "repeatable") + bind_keys_forced(settings.key_movepageup, 'movepageup', movepageup, "repeatable") + bind_keys_forced(settings.key_movepagedown, 'movepagedown', movepagedown, "repeatable") + bind_keys_forced(settings.key_movebegin, 'movebegin', movebegin, "repeatable") + bind_keys_forced(settings.key_moveend, 'moveend', moveend, "repeatable") + bind_keys_forced(settings.key_selectfile, 'selectfile', selectfile) + bind_keys_forced(settings.key_unselectfile, 'unselectfile', unselectfile) + bind_keys_forced(settings.key_playfile, 'playfile', playfile) + bind_keys_forced(settings.key_removefile, 'removefile', removefile, "repeatable") + bind_keys_forced(settings.key_closeplaylist, 'closeplaylist', remove_keybinds) +end + +function remove_keybinds() + keybindstimer:kill() + keybindstimer = mp.add_periodic_timer(settings.playlist_display_timeout, remove_keybinds) + keybindstimer:kill() + playlist_overlay.data = "" + playlist_overlay:remove() + if is_terminal_mode() then + mp.osd_message("") + end + playlist_visible = false + if settings.reset_cursor_on_close then + resetcursor() + end + if settings.dynamic_binds then + unbind_keys(settings.key_moveup, 'moveup') + unbind_keys(settings.key_movedown, 'movedown') + unbind_keys(settings.key_movepageup, 'movepageup') + unbind_keys(settings.key_movepagedown, 'movepagedown') + unbind_keys(settings.key_movebegin, 'movebegin') + unbind_keys(settings.key_moveend, 'moveend') + unbind_keys(settings.key_selectfile, 'selectfile') + unbind_keys(settings.key_unselectfile, 'unselectfile') + unbind_keys(settings.key_playfile, 'playfile') + unbind_keys(settings.key_removefile, 'removefile') + unbind_keys(settings.key_closeplaylist, 'closeplaylist') + end +end + +keybindstimer = mp.add_periodic_timer(settings.playlist_display_timeout, remove_keybinds) +keybindstimer:kill() + +if not settings.dynamic_binds then + add_keybinds() +end + +if settings.loadfiles_on_idle_start and mp.get_property_number('playlist-count', 0) == 0 then + playlist() +end + +mp.observe_property('playlist-count', "number", function(_, plcount) + --if we promised to listen and sort on playlist size increase do it + if settings.sortplaylist_on_file_add and (plcount > plen) then + msg.info("Added files will be automatically sorted") + refresh_globals() + sortplaylist() + end + refresh_UI() + resolve_titles() +end) +mp.observe_property('osd-dimensions', 'native', refresh_UI) + + +url_request_queue = {} +function url_request_queue.push(item) table.insert(url_request_queue, item) end +function url_request_queue.pop() return table.remove(url_request_queue, 1) end +local url_titles_to_fetch = url_request_queue +local ongoing_url_requests = {} + +function url_fetching_throttler() + if #url_titles_to_fetch == 0 then + url_title_fetch_timer:kill() + end + + local ongoing_url_requests_count = 0 + for _, ongoing in pairs(ongoing_url_requests) do + if ongoing then + ongoing_url_requests_count = ongoing_url_requests_count + 1 + end + end + + -- start resolving some url titles if there is available slots + local amount_to_fetch = math.max(0, settings.concurrent_title_resolve_limit - ongoing_url_requests_count) + for index=1,amount_to_fetch,1 do + local file = url_titles_to_fetch.pop() + if file then + ongoing_url_requests[file] = true + resolve_ytdl_title(file) + end + end +end + +url_title_fetch_timer = mp.add_periodic_timer(0.1, url_fetching_throttler) +url_title_fetch_timer:kill() + +local_request_queue = {} +function local_request_queue.push(item) table.insert(local_request_queue, item) end +function local_request_queue.pop() return table.remove(local_request_queue, 1) end +local local_titles_to_fetch = local_request_queue +local ongoing_local_request = false + +-- this will only allow 1 concurrent local title resolve process +function local_fetching_throttler() + if not ongoing_local_request then + local file = local_titles_to_fetch.pop() + if file then + ongoing_local_request = true + resolve_ffprobe_title(file) + end + end +end + +function resolve_titles() + if settings.prefer_titles == 'none' then return end + if not settings.resolve_url_titles and not settings.resolve_local_titles then return end + + local length = mp.get_property_number('playlist-count', 0) + if length < 2 then return end + -- loop all items in playlist because we can't predict how it has changed + local added_urls = false + local added_local = false + for i=0,length - 1,1 do + local filename = mp.get_property('playlist/'..i..'/filename') + local title = mp.get_property('playlist/'..i..'/title') + if i ~= pos + and filename + and not title + and not title_table[filename] + and not requested_titles[filename] + then + requested_titles[filename] = true + if filename:match('^https?://') and settings.resolve_url_titles then + url_titles_to_fetch.push(filename) + added_urls = true + elseif settings.prefer_titles == "all" and settings.resolve_local_titles then + local_titles_to_fetch.push(filename) + added_local = true + end + end + end + if added_urls then + url_title_fetch_timer:resume() + end + if added_local then + local_fetching_throttler() + end +end + +function resolve_ytdl_title(filename) + local args = { + settings.youtube_dl_executable, + '--no-playlist', + '--flat-playlist', + '-sJ', + '--no-config', + filename, + } + local req = mp.command_native_async( + { + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true + }, + function (success, res) + ongoing_url_requests[filename] = false + if res.killed_by_us then + msg.verbose('Request to resolve url title ' .. filename .. ' timed out') + return + end + if res.status == 0 then + local json, err = utils.parse_json(res.stdout) + if not err then + local is_playlist = json['_type'] and json['_type'] == 'playlist' + local title = (is_playlist and '[playlist]: ' or '') .. json['title'] + msg.verbose(filename .. " resolved to '" .. title .. "'") + title_table[filename] = title + mp.set_property_native('user-data/playlistmanager/titles', title_table) + refresh_UI() + else + msg.error("Failed parsing json, reason: "..(err or "unknown")) + end + else + msg.error("Failed to resolve url title "..filename.." Error: "..(res.error or "unknown")) + end + end + ) + + mp.add_timeout( + settings.resolve_title_timeout, + function() + mp.abort_async_command(req) + ongoing_url_requests[filename] = false + end + ) +end + +function resolve_ffprobe_title(filename) + local args = { "ffprobe", "-show_format", "-show_entries", "format=tags", "-loglevel", "quiet", filename } + local req = mp.command_native_async( + { + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true + }, + function (success, res) + ongoing_local_request = false + local_fetching_throttler() + if res.killed_by_us then + msg.verbose('Request to resolve local title ' .. filename .. ' timed out') + return + end + if res.status == 0 then + local title = string.match(res.stdout, "title=([^\n\r]+)") + if title then + msg.verbose(filename .. " resolved to '" .. title .. "'") + title_table[filename] = title + mp.set_property_native('user-data/playlistmanager/titles', title_table) + refresh_UI() + end + else + msg.error("Failed to resolve local title "..filename.." Error: "..(res.error or "unknown")) + end + end + ) +end + +--script message handler +function handlemessage(msg, value, value2) + if msg == "show" and value == "playlist" then + if value2 ~= "toggle" then + showplaylist(value2) + return + else + toggle_playlist(showplaylist) + return + end + end + if msg == "show" and value == "playlist-nokeys" then + if value2 ~= "toggle" then + showplaylist_non_interactive(value2) + return + else + toggle_playlist(showplaylist_non_interactive) + return + end + end + if msg == "show" and value == "filename" and strippedname and value2 then + mp.commandv('show-text', strippedname, tonumber(value2)*1000 ) ; return + end + if msg == "show" and value == "filename" and strippedname then + mp.commandv('show-text', strippedname ) ; return + end + if msg == "sort" then sortplaylist(value) ; return end + if msg == "shuffle" then shuffleplaylist() ; return end + if msg == "reverse" then reverseplaylist() ; return end + if msg == "loadfiles" then playlist(value) ; return end + if msg == "save" then save_playlist(value) ; return end + if msg == "playlist-next" then playlist_next() ; return end + if msg == "playlist-prev" then playlist_prev() ; return end + if msg == "playlist-next-random" then playlist_random() ; return end + if msg == "enable-interactive-save" then interactive_save = true end + if msg == "close" then remove_keybinds() end +end + +mp.register_script_message("playlistmanager", handlemessage) + +bind_keys(settings.key_sortplaylist, "sortplaylist", function() + sortplaylist() + sort_mode = sort_mode + 1 + if sort_mode > #sort_modes then sort_mode = 1 end +end) +bind_keys(settings.key_shuffleplaylist, "shuffleplaylist", shuffleplaylist) +bind_keys(settings.key_reverseplaylist, "reverseplaylist", reverseplaylist) +bind_keys(settings.key_loadfiles, "loadfiles", playlist) +bind_keys(settings.key_saveplaylist, "saveplaylist", activate_playlist_save) +bind_keys(settings.key_showplaylist, "showplaylist", showplaylist) +bind_keys( + settings.key_peek_at_playlist, + "peek_at_playlist", + handle_complex_playlist_toggle, + { complex=true } +) + +mp.register_event("start-file", on_start_file) +mp.register_event("file-loaded", on_file_loaded) +mp.register_event("end-file", on_end_file)