dotfiles

personal dotfiles
git clone anongit@rnpnr.xyz:dotfiles.git
Log | Files | Refs | Feed | Submodules

gallery.lua (33679B)


      1 local utils = require 'mp.utils'
      2 local msg = require 'mp.msg'
      3 local assdraw = require 'mp.assdraw'
      4 
      5 local on_windows = (package.config:sub(1,1) ~= "/")
      6 
      7 local opts = {
      8     thumbs_dir = "/tmp/thumb",
      9     auto_generate_thumbnails = true,
     10     generate_thumbnails_with_mpv = false,
     11 
     12     thumbnail_width = 192,
     13     thumbnail_height = 108,
     14     dynamic_thumbnail_size = "",
     15 
     16     take_thumbnail_at = "20%",
     17 
     18     resume_when_picking = true,
     19     start_gallery_on_startup = false,
     20     start_gallery_on_file_end = false,
     21     toggle_behaves_as_accept = true,
     22 
     23     margin_x = 15,
     24     margin_y = 15,
     25     max_thumbnails = 64,
     26 
     27     show_scrollbar = true,
     28     scrollbar_side = "left",
     29     scrollbar_min_size = 10,
     30 
     31     show_placeholders = true,
     32     placeholder_color = "222222",
     33     always_show_placeholders = false,
     34     background = "0.1",
     35 
     36     show_filename = true,
     37     show_title = true,
     38     strip_directory = true,
     39     strip_extension = true,
     40     text_size = 28,
     41 
     42     selected_frame_color = "DDDDDD",
     43     frame_roundness = 5,
     44     flagged_frame_color = "5B9769",
     45     selected_flagged_frame_color = "BAFFCA",
     46     flagged_file_path = "/tmp/mpv.list",
     47 
     48     max_generators = 8,
     49 
     50     mouse_support = true,
     51     UP        = "k",
     52     DOWN      = "j",
     53     LEFT      = "h",
     54     RIGHT     = "l",
     55     PAGE_UP   = "PGUP",
     56     PAGE_DOWN = "PGDWN",
     57     FIRST     = "HOME",
     58     LAST      = "END",
     59     RANDOM    = "r",
     60     ACCEPT    = "ENTER",
     61     CANCEL    = "ESC",
     62     REMOVE    = "DEL",
     63     FLAG      = "SPACE",
     64 }
     65 (require 'mp.options').read_options(opts)
     66 
     67 function split(input, char, tonum)
     68     local ret = {}
     69     for str in string.gmatch(input, "([^" .. char .. "]+)") do
     70         ret[#ret + 1] = (not tonum and str) or tonumber(str)
     71     end
     72     return ret
     73 end
     74 opts.dynamic_thumbnail_size = split(opts.dynamic_thumbnail_size, ";", false)
     75 for i = 1, #opts.dynamic_thumbnail_size do
     76     local preset = split(opts.dynamic_thumbnail_size[i], ",", true)
     77     if (#preset ~= 3) or not (preset[1] and preset[2] and preset[3]) then
     78         msg.error(opts.dynamic_thumbnail_size[i] .. " is not a valid preset")
     79         return
     80     end
     81     opts.dynamic_thumbnail_size[i] = preset
     82 end
     83 
     84 if on_windows then
     85     opts.thumbs_dir = string.gsub(opts.thumbs_dir, "^%%APPDATA%%", os.getenv("APPDATA") or "%APPDATA%")
     86 else
     87     opts.thumbs_dir = string.gsub(opts.thumbs_dir, "^~", os.getenv("HOME") or "~")
     88 end
     89 opts.max_thumbnails = math.min(opts.max_thumbnails, 64)
     90 
     91 local sha256
     92 --[[
     93 minified code below is a combination of:
     94 -sha256 implementation from
     95 http://lua-users.org/wiki/SecureHashAlgorithm
     96 -lua implementation of bit32 (used as fallback on lua5.1) from
     97 https://www.snpedia.com/extensions/Scribunto/engines/LuaCommon/lualib/bit32.lua
     98 both are licensed under the MIT below:
     99 
    100 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
    101 The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
    102 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    103 --]]
    104 do local b,c,d,e,f;if bit32 then b,c,d,e,f=bit32.band,bit32.rrotate,bit32.bxor,bit32.rshift,bit32.bnot else f=function(g)g=math.floor(tonumber(g))%0x100000000;return(-g-1)%0x100000000 end;local h={[0]={[0]=0,0,0,0},[1]={[0]=0,1,0,1},[2]={[0]=0,0,2,2},[3]={[0]=0,1,2,3}}local i={[0]={[0]=0,1,2,3},[1]={[0]=1,0,3,2},[2]={[0]=2,3,0,1},[3]={[0]=3,2,1,0}}local function j(k,l,m,n,o)for p=1,m do l[p]=math.floor(tonumber(l[p]))%0x100000000 end;local q=1;local r=0;for s=0,31,2 do local t=n;for p=1,m do t=o[t][l[p]%4]l[p]=math.floor(l[p]/4)end;r=r+t*q;q=q*4 end;return r end;b=function(...)return j('band',{...},select('#',...),3,h)end;d=function(...)return j('bxor',{...},select('#',...),0,i)end;e=function(g,u)g=math.floor(tonumber(g))%0x100000000;u=math.floor(tonumber(u))u=math.min(math.max(-32,u),32)return math.floor(g/2^u)%0x100000000 end;c=function(g,u)g=math.floor(tonumber(g))%0x100000000;u=-math.floor(tonumber(u))%32;local g=g*2^u;return g%0x100000000+math.floor(g/0x100000000)end end;local v={0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2}local function w(n)return string.gsub(n,".",function(t)return string.format("%02x",string.byte(t))end)end;local function x(y,z)local n=""for p=1,z do local A=y%256;n=string.char(A)..n;y=(y-A)/256 end;return n end;local function B(n,p)local z=0;for p=p,p+3 do z=z*256+string.byte(n,p)end;return z end;local function C(D,E)local F=-(E+1+8)%64;E=x(8*E,8)D=D.."\128"..string.rep("\0",F)..E;return D end;local function G(H)H[1]=0x6a09e667;H[2]=0xbb67ae85;H[3]=0x3c6ef372;H[4]=0xa54ff53a;H[5]=0x510e527f;H[6]=0x9b05688c;H[7]=0x1f83d9ab;H[8]=0x5be0cd19;return H end;local function I(D,p,H)local J={}for K=1,16 do J[K]=B(D,p+(K-1)*4)end;for K=17,64 do local L=J[K-15]local M=d(c(L,7),c(L,18),e(L,3))L=J[K-2]local N=d(c(L,17),c(L,19),e(L,10))J[K]=J[K-16]+M+J[K-7]+N end;local O,s,t,P,Q,R,S,T=H[1],H[2],H[3],H[4],H[5],H[6],H[7],H[8]for p=1,64 do local M=d(c(O,2),c(O,13),c(O,22))local U=d(b(O,s),b(O,t),b(s,t))local V=M+U;local N=d(c(Q,6),c(Q,11),c(Q,25))local W=d(b(Q,R),b(f(Q),S))local X=T+N+W+v[p]+J[p]T=S;S=R;R=Q;Q=P+X;P=t;t=s;s=O;O=X+V end;H[1]=b(H[1]+O)H[2]=b(H[2]+s)H[3]=b(H[3]+t)H[4]=b(H[4]+P)H[5]=b(H[5]+Q)H[6]=b(H[6]+R)H[7]=b(H[7]+S)H[8]=b(H[8]+T)end;local function Y(H)return w(x(H[1],4)..x(H[2],4)..x(H[3],4)..x(H[4],4)..x(H[5],4)..x(H[6],4)..x(H[7],4)..x(H[8],4))end;local Z={}sha256=function(D)D=C(D,#D)local H=G(Z)for p=1,#D,64 do I(D,p,H)end;return Y(H)end end
    105 -- end of sha code
    106 
    107 active = false
    108 playlist = {} -- copy of the current "playlist" property
    109 geometry = {
    110     window_w = 0,
    111     window_h = 0,
    112     rows = 0,
    113     columns = 0,
    114     size_x = 0,
    115     size_y = 0,
    116     margin_x = 0,
    117     margin_y = 0,
    118 }
    119 view = { -- 1-based indices into the "playlist" array
    120     first = 0, -- must be equal to N*columns
    121     last = 0, -- must be > first and <= first + rows*columns
    122 }
    123 hash_cache = {}
    124 overlays = {
    125     active = {}, -- array of 64 strings indicating the file associated to the current thumbnail (empty if no file)
    126     missing = {}, -- maps hashes of missing thumbnails to the index they should be shown at
    127 }
    128 selection = {
    129     old = 0, -- the playlist element selected when entering the gallery
    130     now = 0, -- the currently selected element
    131 }
    132 pending = {
    133     selection = -1,
    134     window_size_changed = false,
    135     deletion = false,
    136 }
    137 ass = {
    138     selection = "",
    139     scrollbar = "",
    140     placeholders = "",
    141 }
    142 flags = {}
    143 resume = {} -- maps filenames to a {time=,vid=,aid=,sid=} tuple
    144 misc = {
    145     old_force_window = "",
    146     old_geometry = "",
    147     old_osd_level = "",
    148     old_background = "",
    149     old_idle = "",
    150 }
    151 generators = {} -- list of generator scripts that have registered themselves
    152 
    153 do
    154     local inited = false
    155     function init()
    156         if not inited then
    157             inited = true
    158             if utils.file_info then -- 0.28+
    159                 local res = utils.file_info(opts.thumbs_dir)
    160                 if not res or not res.is_dir then
    161                     msg.error(string.format("Thumbnail directory \"%s\" does not exist", opts.thumbs_dir))
    162 		    os.execute("mkdir " .. opts.thumbs_dir)
    163                 end
    164             end
    165         end
    166     end
    167 end
    168 
    169 function file_exists(path)
    170     if utils.file_info then -- 0.28+
    171         local info = utils.file_info(path)
    172         return info ~= nil and info.is_file
    173     else
    174         local f = io.open(path, "r")
    175         if f ~= nil then
    176             io.close(f)
    177             return true
    178         end
    179         return false
    180     end
    181 end
    182 
    183 function thumbnail_size_from_presets(window_w, window_h)
    184     local size = window_w * window_h
    185     local picked = nil
    186     for _, preset in ipairs(opts.dynamic_thumbnail_size) do
    187         picked = { preset[2], preset[3] }
    188         if size <= preset[1] then
    189             break
    190         end
    191     end
    192     return picked
    193 end
    194 
    195 function select_under_cursor()
    196     local g = geometry
    197     local mx, my = mp.get_mouse_pos()
    198     if mx < 0 or my < 0 or mx > g.window_w or my > g.window_h then return end
    199     local mx, my = mx - g.margin_x, my - g.margin_y
    200     local on_column = (mx % (g.size_x + g.margin_x)) < g.size_x
    201     local on_row = (my % (g.size_y + g.margin_y)) < g.size_y
    202     if on_column and on_row then
    203         local column = math.floor(mx / (g.size_x + g.margin_x))
    204         local row = math.floor(my / (g.size_y + g.margin_y))
    205         local new_sel = view.first + row * g.columns + column
    206         if new_sel > view.last then return end
    207         if selection.now == new_sel then
    208             quit_gallery_view(selection.now)
    209         else
    210             selection.now = new_sel
    211             pending.selection = new_sel
    212             ass_show(true, false, false)
    213         end
    214     end
    215 end
    216 
    217 function toggle_selection_flag()
    218     local name = playlist[selection.now].filename
    219     if flags[name] == nil then
    220         flags[name] = true
    221     else
    222         flags[name] = nil
    223     end
    224     ass_show(true, false, false)
    225 end
    226 
    227 do
    228     local function increment_func(increment, clamp)
    229         local new = pending.selection == -1 and selection.now or pending.selection
    230         new = new + increment
    231         if new <= 0 or new > #playlist then
    232             if not clamp then return end
    233             new = math.max(1, math.min(new, #playlist))
    234         end
    235         pending.selection = new
    236     end
    237 
    238     local bindings_repeat = {}
    239         bindings_repeat[opts.UP]        = function() increment_func(- geometry.columns, false) end
    240         bindings_repeat[opts.DOWN]      = function() increment_func(  geometry.columns, false) end
    241         bindings_repeat[opts.LEFT]      = function() increment_func(- 1, false) end
    242         bindings_repeat[opts.RIGHT]     = function() increment_func(  1, false) end
    243         bindings_repeat[opts.PAGE_UP]   = function() increment_func(- geometry.columns * geometry.rows, true) end
    244         bindings_repeat[opts.PAGE_DOWN] = function() increment_func(  geometry.columns * geometry.rows, true) end
    245         bindings_repeat[opts.RANDOM]    = function() pending.selection = math.random(1, #playlist) end
    246         bindings_repeat[opts.REMOVE]    = function() pending.deletion = true end
    247 
    248     local bindings = {}
    249         bindings[opts.FIRST]  = function() pending.selection = 1 end
    250         bindings[opts.LAST]   = function() pending.selection = #playlist end
    251         bindings[opts.ACCEPT] = function() quit_gallery_view(selection.now) end
    252         bindings[opts.CANCEL] = function() quit_gallery_view(selection.old) end
    253         bindings[opts.FLAG]   = toggle_selection_flag
    254     if opts.mouse_support then
    255         bindings["MBTN_LEFT"]  = select_under_cursor
    256         bindings["WHEEL_UP"]   = function() increment_func(- geometry.columns, true) end
    257         bindings["WHEEL_DOWN"] = function() increment_func(  geometry.columns, true) end
    258     end
    259 
    260     local function window_size_changed()
    261         pending.window_size_changed = true
    262     end
    263 
    264     local function idle_handler()
    265         if pending.selection ~= -1 then
    266             selection.now = pending.selection
    267             pending.selection = -1
    268             ensure_view_valid()
    269             refresh_overlays(false)
    270             ass_show(true, true, true)
    271         end
    272         if pending.window_size_changed then
    273             pending.window_size_changed = false
    274             local window_w, window_h = mp.get_osd_size()
    275             if window_w ~= geometry.window_w or window_h ~= geometry.window_h then
    276                 compute_geometry(window_w, window_h)
    277                 if geometry.rows <= 0 or geometry.columns <= 0 then
    278                     quit_gallery_view(selection.old)
    279                     return
    280                 end
    281                 ensure_view_valid()
    282                 refresh_overlays(true)
    283                 ass_show(true, true, true)
    284             end
    285         end
    286         if pending.deletion then
    287             pending.deletion = false
    288             mp.commandv("playlist-remove", selection.now - 1)
    289             selection.now = selection.now + (selection.now == #playlist and -1 or 1)
    290         end
    291     end
    292 
    293     function setup_ui_handlers()
    294         for key, func in pairs(bindings_repeat) do
    295             mp.add_forced_key_binding(key, "gallery-view-"..key, func, {repeatable = true})
    296         end
    297         for key, func in pairs(bindings) do
    298             mp.add_forced_key_binding(key, "gallery-view-"..key, func)
    299         end
    300         for _, prop in ipairs({ "osd-width", "osd-height" }) do
    301             mp.observe_property(prop, "native", window_size_changed)
    302         end
    303         mp.register_idle(idle_handler)
    304     end
    305 
    306     function teardown_ui_handlers()
    307         for key, _ in pairs(bindings_repeat) do
    308             mp.remove_key_binding("gallery-view-"..key)
    309         end
    310         for key, _ in pairs(bindings) do
    311             mp.remove_key_binding("gallery-view-"..key)
    312         end
    313         mp.unobserve_property(window_size_changed)
    314         mp.unregister_idle(idle_handler)
    315     end
    316 end
    317 
    318 function resume_playback(select)
    319     -- what a mess
    320     local s = resume[playlist[select].filename]
    321     local pos = mp.get_property_number("playlist-pos-1")
    322     if pos == select then
    323         if s and opts.resume_when_picking then
    324             mp.commandv("seek", s.time, "absolute")
    325         end
    326         mp.set_property("vid", s and s.vid or "1")
    327         mp.set_property("aid", s and s.aid or "1")
    328         mp.set_property("sid", s and s.sid or "1")
    329         mp.set_property_bool("pause", false)
    330     else
    331         if s then
    332             local func
    333             func = function()
    334                 local change_maybe = function(prop, val)
    335                     if val ~= mp.get_property(prop) then
    336                         mp.set_property(prop,val)
    337                     end
    338                 end
    339                 change_maybe("vid", s.vid)
    340                 change_maybe("aid", s.aid)
    341                 change_maybe("sid", s.sid)
    342                 if opts.resume_when_picking then
    343                     mp.commandv("seek", s.time, "absolute")
    344                 end
    345                 mp.unregister_event(func)
    346             end
    347             mp.register_event("file-loaded", func)
    348         end
    349         mp.set_property("playlist-pos-1", select)
    350         mp.set_property("vid", "1")
    351         mp.set_property("aid", "1")
    352         mp.set_property("sid", "1")
    353         mp.set_property_bool("pause", false)
    354     end
    355 end
    356 
    357 function restore_properties()
    358     mp.set_property("force-window", misc.old_force_window)
    359     mp.set_property("track-auto-selection", misc.old_track_auto_selection)
    360     mp.set_property("geometry", misc.old_geometry)
    361     mp.set_property("osd-level", misc.old_osd_level)
    362     mp.set_property("background", misc.old_background)
    363     mp.set_property("idle", misc.old_idle)
    364     mp.commandv("script-message", "osc-visibility", "auto", "true")
    365 end
    366 
    367 function save_properties()
    368     misc.old_force_window = mp.get_property("force-window")
    369     misc.old_track_auto_selection = mp.get_property("track-auto-selection")
    370     misc.old_geometry = mp.get_property("geometry")
    371     misc.old_osd_level = mp.get_property("osd-level")
    372     misc.old_background = mp.get_property("background")
    373     misc.old_idle = mp.get_property("idle")
    374     mp.set_property_bool("force-window", true)
    375     mp.set_property_bool("track-auto-selection", false)
    376     mp.set_property_number("osd-level", 0)
    377     mp.set_property("background", opts.background)
    378     mp.set_property_bool("idle", true)
    379     mp.commandv("no-osd", "script-message", "osc-visibility", "never", "true")
    380     mp.set_property("geometry", geometry.window_w .. "x" .. geometry.window_h)
    381 end
    382 
    383 function compute_geometry(ww, wh)
    384     geometry.window_w, geometry.window_h = ww, wh
    385 
    386     local dyn_thumb_size = thumbnail_size_from_presets(ww, wh)
    387     if dyn_thumb_size then
    388         geometry.size_x = dyn_thumb_size[1]
    389         geometry.size_y = dyn_thumb_size[2]
    390     else
    391         geometry.size_x = opts.thumbnail_width
    392         geometry.size_y = opts.thumbnail_height
    393     end
    394 
    395     local margin_y = opts.show_filename and math.max(opts.text_size, opts.margin_y) or opts.margin_y
    396     geometry.rows = math.floor((wh - margin_y) / (geometry.size_y + margin_y))
    397     geometry.columns = math.floor((ww - opts.margin_x) / (geometry.size_x + opts.margin_x))
    398     if (geometry.rows * geometry.columns > opts.max_thumbnails) then
    399         local r = math.sqrt(geometry.rows * geometry.columns / opts.max_thumbnails)
    400         geometry.rows = math.floor(geometry.rows / r)
    401         geometry.columns = math.floor(geometry.columns / r)
    402     end
    403     geometry.margin_x = (ww - geometry.columns * geometry.size_x) / (geometry.columns + 1)
    404     geometry.margin_y = (wh - geometry.rows * geometry.size_y) / (geometry.rows + 1)
    405 end
    406 
    407 -- makes sure that view.first and view.last are valid with regards to the playlist
    408 -- and that selection.now is within the view
    409 -- to be called after the playlist, view or selection was modified somehow
    410 function ensure_view_valid()
    411     local selection_row = math.floor((selection.now - 1) / geometry.columns)
    412     local max_thumbs = geometry.rows * geometry.columns
    413 
    414     if view.last >= #playlist then
    415         view.last = #playlist
    416         last_row = math.floor((view.last - 1) / geometry.columns)
    417         first_row = math.max(0, last_row - geometry.rows + 1)
    418         view.first = 1 + first_row * geometry.columns
    419     elseif view.first == 0 or view.last == 0 or view.last - view.first + 1 ~= max_thumbs then
    420         -- special case: the number of possible thumbnails was changed
    421         -- just recreate the view such that the selection is in the middle row
    422         local max_row = (#playlist - 1) / geometry.columns + 1
    423         local row_first = selection_row - math.floor((geometry.rows - 1) / 2)
    424         local row_last = selection_row + math.floor((geometry.rows - 1) / 2) + geometry.rows % 2
    425         if row_first < 0 then
    426             row_first = 0
    427         elseif row_last > max_row then
    428             row_first = max_row - geometry.rows + 1
    429         end
    430         view.first = 1 + row_first * geometry.columns
    431         view.last = math.min(#playlist, view.first - 1 + max_thumbs)
    432         return
    433     end
    434 
    435     if selection.now < view.first then
    436         -- the selection is now on the first line
    437         view.first = selection_row * geometry.columns + 1
    438         view.last = math.min(#playlist, view.first + max_thumbs - 1)
    439     elseif selection.now > view.last then
    440         -- the selection is now on the last line
    441         view.last = (selection_row + 1) * geometry.columns
    442         view.first = math.max(1, view.last - max_thumbs + 1)
    443         view.last = math.min(#playlist, view.last)
    444     end
    445 end
    446 
    447 -- ass related stuff
    448 do
    449     local function refresh_placeholders()
    450         if not opts.show_placeholders then return end
    451         local a = assdraw.ass_new()
    452         a:new_event()
    453         a:append('{\\bord0}')
    454         a:append('{\\shad0}')
    455         a:append('{\\1c&' .. opts.placeholder_color .. '}')
    456         a:pos(0, 0)
    457         a:draw_start()
    458         for i = 0, view.last - view.first do
    459             if opts.always_show_placeholders or overlays.active[i + 1] == "" then
    460                 local x = geometry.margin_x + (geometry.margin_x + geometry.size_x) * (i % geometry.columns)
    461                 local y = geometry.margin_y + (geometry.margin_y + geometry.size_y) * math.floor(i / geometry.columns)
    462                 a:rect_cw(x, y, x + geometry.size_x, y + geometry.size_y)
    463             end
    464         end
    465         a:draw_stop()
    466         ass.placeholders = a.text
    467     end
    468 
    469     local function refresh_scrollbar()
    470         if not opts.show_scrollbar then return end
    471         ass.scrollbar = ""
    472         local before = (view.first - 1) / #playlist
    473         local after = (#playlist - view.last) / #playlist
    474         -- don't show the scrollbar if everything is visible
    475         if before + after == 0 then return end
    476         local p = opts.scrollbar_min_size / 100
    477         if before + after > 1 - p then
    478             if before == 0 then
    479                 after = (1 - p)
    480             elseif after == 0 then
    481                 before = (1 - p)
    482             else
    483                 before, after =
    484                     before / after * (1 - p) / (1 + before / after),
    485                     after / before * (1 - p) / (1 + after / before)
    486             end
    487         end
    488         local y1 = geometry.margin_y + before * (geometry.window_h - 2 * geometry.margin_y)
    489         local y2 = geometry.window_h - (geometry.margin_y + after * (geometry.window_h - 2 * geometry.margin_y))
    490         local x1, x2
    491         if opts.scrollbar_side == "left" then
    492             x1, x2 = 4, 8
    493         else
    494             x1, x2 = geometry.window_w - 8, geometry.window_w - 4
    495         end
    496         local scrollbar = assdraw.ass_new()
    497         scrollbar:new_event()
    498         scrollbar:append('{\\bord0}')
    499         scrollbar:append('{\\shad0}')
    500         scrollbar:append('{\\1c&AAAAAA&}')
    501         scrollbar:pos(0, 0)
    502         scrollbar:draw_start()
    503         scrollbar:round_rect_cw(x1, y1, x2, y2, opts.frame_roundness)
    504         scrollbar:draw_stop()
    505         ass.scrollbar = scrollbar.text
    506     end
    507 
    508     local function refresh_selection()
    509         local selection_ass = assdraw.ass_new()
    510         local draw_frame = function(index, color)
    511             if index < view.first or index > view.last then return end
    512             local i = index - view.first
    513             local x = geometry.margin_x + (geometry.margin_x + geometry.size_x) * (i % geometry.columns)
    514             local y = geometry.margin_y + (geometry.margin_y + geometry.size_y) * math.floor(i / geometry.columns)
    515             selection_ass:new_event()
    516             selection_ass:append('{\\bord5}')
    517             selection_ass:append('{\\3c&'.. color ..'&}')
    518             selection_ass:append('{\\1a&FF&}')
    519             selection_ass:pos(0, 0)
    520             selection_ass:draw_start()
    521             selection_ass:round_rect_cw(x, y, x + geometry.size_x, y + geometry.size_y, opts.frame_roundness)
    522             selection_ass:draw_stop()
    523         end
    524         for i = view.first, view.last do
    525             local name = playlist[i].filename
    526             if flags[name] then
    527                 if i == selection.now then
    528                     draw_frame(i, opts.selected_flagged_frame_color)
    529                 else
    530                     draw_frame(i, opts.flagged_frame_color)
    531                 end
    532             elseif i == selection.now then
    533                 draw_frame(i, opts.selected_frame_color)
    534             end
    535         end
    536 
    537         if opts.show_filename or opts.show_title then
    538             selection_ass:new_event()
    539             local i = (selection.now - view.first)
    540             local an = 5
    541             local x = geometry.margin_x + (geometry.margin_x + geometry.size_x) * (i % geometry.columns) + geometry.size_x / 2
    542             local y = geometry.margin_y + (geometry.margin_y + geometry.size_y) * math.floor(i / geometry.columns) + geometry.size_y + geometry.margin_y / 2
    543             local col = i % geometry.columns
    544             if geometry.columns > 1 then
    545                 if col == 0 then
    546                     x = x - geometry.size_x / 2
    547                     an = 4
    548                 elseif col == geometry.columns - 1 then
    549                     x = x + geometry.size_x / 2
    550                     an = 6
    551                 end
    552             end
    553             selection_ass:an(an)
    554             selection_ass:pos(x, y)
    555             selection_ass:append(string.format("{\\fs%d}", opts.text_size))
    556             selection_ass:append("{\\bord0}")
    557             local f
    558             local element = playlist[selection.now]
    559             if opts.show_title and element.title then
    560                 f = element.title
    561             else
    562                 f = element.filename
    563                 if opts.strip_directory then
    564                     if on_windows then
    565                         f = string.match(f, "([^\\/]+)$") or f
    566                     else
    567                         f = string.match(f, "([^/]+)$") or f
    568                     end
    569                 end
    570                 if opts.strip_extension then
    571                     f = string.match(f, "(.+)%.[^.]+$") or f
    572                 end
    573             end
    574             selection_ass:append(f)
    575         end
    576         ass.selection = selection_ass.text
    577     end
    578 
    579     function ass_show(selection, scrollbar, placeholders)
    580         if selection then refresh_selection() end
    581         if scrollbar then refresh_scrollbar() end
    582         if placeholders then refresh_placeholders() end
    583         local merge = function(a, b)
    584             return b ~= "" and (a .. "\n" .. b) or a
    585         end
    586         mp.set_osd_ass(geometry.window_w, geometry.window_h,
    587             merge(merge(ass.selection, ass.scrollbar), ass.placeholders)
    588         )
    589     end
    590 
    591     function ass_hide()
    592         mp.set_osd_ass(1280, 720, "")
    593     end
    594 end
    595 
    596 function normalize_path(path)
    597     if string.find(path, "://") then
    598         return path
    599     end
    600     path = utils.join_path(utils.getcwd(), path)
    601     if on_windows then
    602         path = string.gsub(path, "\\", "/")
    603     end
    604     path = string.gsub(path, "/%./", "/")
    605     local n
    606     repeat
    607         path, n = string.gsub(path, "/[^/]*/%.%./", "/", 1)
    608     until n == 0
    609     return path
    610 end
    611 
    612 function refresh_overlays(force)
    613     local todo = {}
    614     overlays.missing = {}
    615     for i = 1, 64 do
    616         local index = view.first + i - 1
    617         if index <= view.last then
    618             local filename = playlist[index].filename
    619             if force or overlays.active[i] ~= filename then
    620                 local filename_hash = hash_cache[filename]
    621                 if filename_hash == nil then
    622                     filename_hash = string.sub(sha256(normalize_path(filename)), 1, 12)
    623                     hash_cache[filename] = filename_hash
    624                 end
    625                 local thumb_filename = string.format("%s_%d_%d", filename_hash, geometry.size_x, geometry.size_y)
    626                 local thumb_path = utils.join_path(opts.thumbs_dir, thumb_filename)
    627                 if file_exists(thumb_path) then
    628                     show_overlay(i, thumb_path)
    629                     overlays.active[i] = filename
    630                 else
    631                     overlays.missing[thumb_path] = { index = i, input = filename }
    632                     remove_overlay(i)
    633                     todo[#todo + 1] = { input = filename, output = thumb_path }
    634                 end
    635             end
    636         else
    637             remove_overlay(i)
    638         end
    639     end
    640     -- reverse iterate so that the first thumbnail is at the top of the stack
    641     if opts.auto_generate_thumbnails and #generators >= 1 then
    642         for i = #todo, 1, -1 do
    643             local generator = generators[i % #generators + 1]
    644             local t = todo[i]
    645             mp.commandv("script-message-to", generator, "push-thumbnail-front",
    646                 mp.get_script_name(),
    647                 t.input,
    648                 tostring(geometry.size_x),
    649                 tostring(geometry.size_y),
    650                 opts.take_thumbnail_at,
    651                 t.output,
    652                 opts.generate_thumbnails_with_mpv and "true" or "false"
    653             )
    654         end
    655     end
    656 end
    657 
    658 function show_overlay(index_1, thumb_path)
    659     local g = geometry
    660     local index_0 = index_1 - 1
    661     mp.commandv("overlay-add",
    662         tostring(index_0),
    663         tostring(math.floor(0.5 + g.margin_x + (g.margin_x + g.size_x) * (index_0 % g.columns))),
    664         tostring(math.floor(0.5 + g.margin_y + (g.margin_y + g.size_y) * math.floor(index_0 / g.columns))),
    665         thumb_path,
    666         "0",
    667         "bgra",
    668         tostring(g.size_x),
    669         tostring(g.size_y),
    670         tostring(4*g.size_x))
    671     mp.osd_message("", 0.01)
    672 end
    673 
    674 function remove_overlays()
    675     for i = 1, 64 do
    676         remove_overlay(i)
    677     end
    678     overlays.missing = {}
    679 end
    680 
    681 function remove_overlay(index_1)
    682     if overlays.active[index_1] == "" then return end
    683     overlays.active[index_1] = ""
    684     mp.command("overlay-remove " .. index_1 - 1)
    685     mp.osd_message("", 0.01)
    686 end
    687 
    688 function playlist_changed(key, value)
    689     local did_change = function()
    690         if #playlist ~= #value then return true end
    691         for i = 1, #playlist do
    692             if playlist[i].filename ~= value[i].filename then return true end
    693         end
    694         return false
    695     end
    696     if not did_change() then return end
    697     if #value == 0 then
    698         quit_gallery_view()
    699         return
    700     end
    701     local sel_old_file = playlist[selection.old].filename
    702     local sel_new_file = playlist[selection.now].filename
    703     playlist = value
    704     selection.old = math.max(1, math.min(selection.old, #playlist))
    705     selection.now = math.max(1, math.min(selection.now, #playlist))
    706     for i, f in ipairs(playlist) do
    707         if sel_old_file == f.filename then
    708             selection.old = i
    709         end
    710         if sel_new_file == f.filename then
    711             selection.now = i
    712         end
    713     end
    714     ensure_view_valid()
    715     refresh_overlays(false)
    716     ass_show(true, true, true)
    717 end
    718 
    719 function start_gallery_view(record_time)
    720     if active then return end
    721     init()
    722     playlist = mp.get_property_native("playlist")
    723     if #playlist == 0 then return end
    724 
    725     local ww, wh = mp.get_osd_size()
    726     compute_geometry(ww, wh)
    727     if geometry.rows <= 0 or geometry.columns <= 0 then return end
    728 
    729     mp.observe_property("playlist", "native", playlist_changed)
    730     save_properties()
    731 
    732     local pos = mp.get_property_number("playlist-pos-1")
    733     if pos then
    734         local s = {}
    735         mp.set_property_bool("pause", true)
    736         if opts.resume_when_picking then
    737             s.time = record_time and mp.get_property_number("time-pos") or 0
    738         end
    739         s.vid = mp.get_property_number("vid") or "1"
    740         s.aid = mp.get_property_number("aid") or "1"
    741         s.sid = mp.get_property_number("sid") or "1"
    742         resume[playlist[pos].filename] = s
    743         mp.set_property("vid", "no")
    744         mp.set_property("aid", "no")
    745         mp.set_property("sid", "no")
    746     else
    747         -- this may happen if we enter the gallery too fast
    748         local func
    749         func = function()
    750             mp.set_property_bool("pause", true)
    751             mp.set_property("vid", "no")
    752             mp.set_property("aid", "no")
    753             mp.set_property("sid", "no")
    754             mp.unregister_event(func)
    755         end
    756         mp.register_event("file-loaded", func)
    757     end
    758     selection.old = pos or 1
    759     selection.now = selection.old
    760     ensure_view_valid()
    761     setup_ui_handlers()
    762     refresh_overlays(true)
    763     ass_show(true, true, true)
    764     active = true
    765 end
    766 
    767 function quit_gallery_view(select)
    768     if not active then return end
    769     teardown_ui_handlers()
    770     remove_overlays()
    771     mp.unobserve_property(playlist_changed)
    772     ass_hide()
    773     if select then
    774         resume_playback(select)
    775     end
    776     restore_properties()
    777     active = false
    778 end
    779 
    780 function toggle_gallery()
    781     if not active then
    782         start_gallery_view(true)
    783     else
    784         quit_gallery_view(opts.toggle_behaves_as_accept and selection.now or selection.old)
    785     end
    786 end
    787 
    788 mp.register_script_message("thumbnail-generated", function(thumbnail_path)
    789     if not active then return end
    790     local missing = overlays.missing[thumbnail_path]
    791     if missing == nil then return end
    792     show_overlay(missing.index, thumbnail_path)
    793     overlays.active[missing.index] = missing.input
    794     if not opts.always_show_placeholders then
    795         ass_show(false, false, true)
    796     end
    797     overlays.missing[thumbnail_path] = nil
    798 end)
    799 
    800 mp.register_script_message("thumbnails-generator-broadcast", function(generator_name)
    801     if #generators >= opts.max_generators then return end
    802     for _, g in ipairs(generators) do
    803         if generator_name == g then return end
    804     end
    805     generators[#generators + 1] = generator_name
    806 end)
    807 
    808 function write_flag_file()
    809     if next(flags) == nil then return end
    810     local out = io.open(opts.flagged_file_path, "w")
    811     for f, _ in pairs(flags) do
    812         out:write(f .. "\n")
    813     end
    814     out:close()
    815 end
    816 
    817 mp.register_event("shutdown", write_flag_file)
    818 
    819 if opts.start_gallery_on_file_end then
    820     mp.observe_property("eof-reached", "bool", function(_, val)
    821         if val and mp.get_property_number("playlist-count") > 1 then
    822             start_gallery_view(false)
    823 
    824         end
    825     end)
    826 end
    827 if opts.start_gallery_on_startup then
    828     local autostart
    829     autostart = function()
    830         if mp.get_property_number("playlist-count") == 0 then return end
    831         if mp.get_property_number("osd-width") <= 0 then return end
    832         start_gallery_view(false)
    833         mp.unobserve_property(autostart)
    834     end
    835     mp.observe_property("playlist-count", "number", autostart)
    836     mp.observe_property("osd-width", "number", autostart)
    837 end
    838 
    839 mp.add_key_binding("g", "gallery-view", toggle_gallery)
    840 mp.add_key_binding(nil, "gallery-write-flag-file", write_flag_file)