local Y=require("yaru.core") local gfx=require("yaru.gfx") local ui=require("yaru.ui") local fs=require("filesystem") local computer=require("computer") local event=require("event") local serialization=require("serialization") local unicode=require("unicode") local L=require("ocvlib") local M={} local CFG="/etc/yaru-player.cfg" local ROOTS={"/home/Videos","/home","/mnt"} local DEFAULTS={loop=false,playlist=false,view="window",scale="fit",skip=true,ram=0.4, speed=1,stats=true,extra={}} local function loadCfg() local c={} for k,v in pairs(DEFAULTS)do c[k]=v end local f=io.open(CFG) if f then local ok,t=pcall(serialization.unserialize,f:read("*a")) f:close() if ok and type(t)=="table"then for k,v in pairs(t)do c[k]=v end end end return c end local function saveCfg(c) local f=io.open(CFG,"w") if f then f:write(serialization.serialize(c));f:close()end end local function hms(s) s=math.max(0,math.floor(s)) if s>=3600 then return string.format("%d:%02d:%02d",s//3600,s//60%60,s%60)end return string.format("%d:%02d",s//60,s%60) end function M.open(path) local cfg=loadCfg() local st={view="library",list={},sel=1,top=0} local win=Y.newWindow{app="player",title="Media Player",w=104,h=36,minW=60,minH=16} local pb local function scan() local dirs=L.find(ROOTS,2) for _,d in ipairs(cfg.extra or{})do if L.isVideo(d)then local dup for _,x in ipairs(dirs)do if x==d then dup=true end end if not dup then dirs[#dirs+1]=d end end end st.list={} for _,d in ipairs(dirs)do local v,err=L.readIndex(d) st.list[#st.list+1]=v or{dir=d,name=fs.name(d),err=err} end st.sel=math.max(1,math.min(st.sel,#st.list)) end local function addFolder(p) p=fs.canonical(p) if not L.isVideo(p)then local found=L.find({p},2) if#found==0 then return Y.notify("Media Player","No OCV video (index.ocv) in "..p)end for _,d in ipairs(found)do table.insert(cfg.extra,d)end else table.insert(cfg.extra,p)end saveCfg(cfg) scan() for i,v in ipairs(st.list)do if v.dir==p or v.dir:sub(1,#p)==p then st.sel=i break end end end local function stopPlayback(reason) if not pb then return end pb.stream.close() local s=pb.stats local video=pb.video pb=nil st.view="library" win.tickInterval=nil win.title="Media Player" if win.maxState and st.autoMax then Y.maximize(win);st.autoMax=nil end if cfg.stats and s.drawn>0 and reason~="silent"then local el=math.max(computer.uptime()-s.started,1e-6) local body=string.format("%d frames in %s (%.1f fps), late %d, %.1f ms CPU/frame", s.drawn,hms(el),(s.drawn-s.late)/el,s.late,s.cpu/s.drawn*1000) if s.late>s.drawn/10 then local perSet=s.cpu/math.max(1,s.sets) local budget=math.floor(0.7/video.fps/perSet/50)*50 body=body..string.format(" · too slow: re-encode with --max-sets %d or a smaller size",math.max(100,budget)) end Y.notify("Played “"..video.name.."”",body) end end local function startVideo(v,keepStats) local stats=keepStats or{drawn=0,late=0,cpu=0,sets=0,started=computer.uptime()} local colors=L.colors(v) local idx={} for i=0,255 do idx[i]=gfx.color(colors[i])end local capacity=math.max(64*1024,math.floor(computer.freeMemory()*cfg.ram)) local stream=L.openStream(v,capacity) stream.fill(16) pb={video=v,colors=colors,idx=idx,stream=stream,grid=L.newGrid(v.width,v.height,idx[16]), gst={},k=0,t0=computer.uptime(),paused=false,stats=stats} win.title=v.name win.tickInterval=math.max(0.05,1/(v.fps*cfg.speed)) end local nativeFullscreen local function play(v) if not v or v.err then return end st.view="playing" st.current=st.sel startVideo(v) if cfg.view=="max"and not win.maxState then Y.maximize(win);st.autoMax=true end if cfg.view=="full"then nativeFullscreen()end end local function nextOrEnd() if cfg.loop then local stats=pb.stats pb.stream.close() startVideo(pb.video,stats) return true end if cfg.playlist and st.current and st.current<#st.list then local stats=pb.stats pb.stream.close() st.current=st.current+1 st.sel=st.current startVideo(st.list[st.current],stats) return true end stopPlayback() return false end local function advance() local v=pb.video local now=computer.uptime() local due=math.floor((now-pb.t0)*v.fps*cfg.speed) if due=v.frames then return nextOrEnd()end local c0=os.clock() pb.stats.sets=pb.stats.sets+L.decodeToGrid(data,pb.grid,v.width,pb.idx,pb.gst) pb.stats.cpu=pb.stats.cpu+(os.clock()-c0) pb.stats.drawn=pb.stats.drawn+1 pb.k=pb.k+1 n=n+1 if n>1 then pb.stats.late=pb.stats.late+1 end end if pb.k<=due then if cfg.skip then pb.stats.late=pb.stats.late+1 else pb.t0=now-pb.k/(v.fps*cfg.speed)end end pb.stream.fill(3) return true end function nativeFullscreen() if not pb then return end local gpu,v=Y.gpu,pb.video local mw,mh=gpu.maxResolution() if gpu.maxDepth()<8 or mw=v.frames then local again=nextOrEnd() if not again or not pb then break end v=pb.video gpu.setResolution(v.width,v.height) for i=0,15 do gpu.setPaletteColor(i,pb.colors[i])end gpu.setBackground(0);gpu.fill(1,1,v.width,v.height," ") render=L.gpuRenderer(gpu,pb.colors,L.glyphs()) interval=1/(v.fps*cfg.speed) data=pb.stream.next() if not data then break end end local c0=os.clock() render(data) pb.stats.sets=pb.stats.sets+L.decodeToGrid(data,pb.grid,v.width,pb.idx,pb.gst) pb.stats.cpu=pb.stats.cpu+(os.clock()-c0) pb.stats.drawn=pb.stats.drawn+1 local k=pb.k pb.k=k+1 local due=pb.t0+k*interval local now=computer.uptime() if buf and cfg.skip and now>due+interval and skipped<4 and pb.k=due present() skipped=0 if not cfg.skip and computer.uptime()>due+interval then pb.t0=computer.uptime()-pb.k*interval end end else local name,_,char,code=event.pull(math.huge) if name=="key_down"then if code==0x01 or code==0x57 or char==102 then cmd="exit" elseif char==32 then cmd="pause"elseif char==113 then cmd="stop"end elseif name=="interrupted"then cmd="stop"end end if cmd=="pause"then if pb.paused then pb.t0=pb.t0+(computer.uptime()-pb.pausedAt);pb.paused=false else pb.paused,pb.pausedAt=true,computer.uptime()end elseif cmd=="stop"then stopPlayback();break elseif cmd=="exit"then break end end if buf then gpu.setActiveBuffer(0);gpu.freeBuffer(buf)end end) gpu.setResolution(W0,H0) gfx.setCustomPalette(Y.theme.palette,true) Y.paused=false gfx.damageAll() if not okFs then Y.notify("Media Player","Full screen failed: "..tostring(err))end end local function radioRow(w,x,y,label,options,key) local t=Y.theme gfx.text(x,y,label,t.fg) local ox=x+20 for _,o in ipairs(options)do ui.radio(w,ox,y,cfg[key]==o[1],o[2],function()cfg[key]=o[1];saveCfg(cfg)end) ox=ox+unicode.len(o[2])+5 end end local function switchRow(w,x,y,label,sub,key) local t=Y.theme gfx.text(x,y,label,t.fg) if sub then gfx.text(x+20,y,sub,t.dim)end ui.switch(w,x+62,y,cfg[key],function()cfg[key]=not cfg[key];saveCfg(cfg)end) end local function drawLibrary(w,cw,ch) local t=Y.theme local SIDE=34 gfx.fill(1,1,SIDE,ch,t.sidebar) gfx.text(2,1,#st.list.." video"..(#st.list==1 and""or"s"),t.dim) if#st.list==0 then gfx.text(2,3,"No videos found in",t.dim) gfx.text(2,4,"/home/Videos, /home, /mnt",t.dim) gfx.text(2,6,"Copy a folder made by",t.dim) gfx.text(2,7,"ocv_encode.py, or use",t.dim) gfx.text(2,8,"`ocv_get URL folder`.",t.dim) end local rows=(ch-2)//3 if st.sel-1st.top+rows then st.top=st.sel-rows end for i=1,rows do local v=st.list[st.top+i] if not v then break end local y=2+(i-1)*3 local sel=st.top+i==st.sel if sel then ui.round(2,y,SIDE-2,2,t.sel)end local fg=sel and t.selFg or t.fg gfx.text(4,y,ui.trunc(v.name,SIDE-6),fg) local sub=v.err and v.err or string.format("%dx%d · %g fps · %s",v.width,v.height,v.fps,hms(v.duration)) gfx.text(4,y+1,ui.trunc(sub,SIDE-6),sel and fg or(v.err and t.danger or t.dim)) local idx=st.top+i ui.hit(w,1,y,SIDE,2,function(_,_,_,btn,dbl)st.sel=idx;if dbl then play(st.list[idx])end end) end local v=st.list[st.sel] local x,pw=SIDE+3,cw-SIDE-4 local y=2 Y.drawIcon(x,y,"player") if v and not v.err then gfx.text(x+10,y,ui.trunc(v.name,pw-10),t.fg) gfx.text(x+10,y+1,ui.trunc(v.dir,pw-10),t.dim) gfx.text(x+10,y+2,string.format("%d×%d cells · %g fps · %d frames · %s · %s in %d part%s", v.width,v.height,v.fps,v.frames,hms(v.duration),ui.size(v.bytes),v.parts,v.parts==1 and""or"s"),t.dim) local mw,mh=Y.gpu.maxResolution() if v.width>mw or v.height>mh then gfx.text(x+10,y+3,"Larger than this screen: full screen is not possible",t.danger)end else gfx.text(x+10,y,"Media Player",t.fg) gfx.text(x+10,y+1,"OCV5 videos encoded with ocv_encode.py",t.dim) end y=y+6 gfx.text(x,y,"Playback",t.dim);y=y+1 ui.round(x,y,pw,1,t.sep);y=y+2 radioRow(w,x,y,"Show in",{{"window","Window"},{"max","Maximized"},{"full","Full screen"}},"view");y=y+2 radioRow(w,x,y,"Scale in window",{{"fit","Fit to window"},{"1:1","1:1 (sharp, may crop)"}},"scale");y=y+2 radioRow(w,x,y,"Speed",{{0.5,"0.5×"},{1,"1×"},{1.5,"1.5×"},{2,"2×"}},"speed");y=y+2 switchRow(w,x,y,"Loop","repeat the video","loop");y=y+2 switchRow(w,x,y,"Play folder","continue with the next video","playlist");y=y+2 switchRow(w,x,y,"Skip late frames","keep the pace on slow machines","skip");y=y+2 radioRow(w,x,y,"RAM buffer",{{0.2,"20%"},{0.4,"40%"},{0.6,"60%"}},"ram");y=y+2 switchRow(w,x,y,"Statistics","notification after playback","stats");y=y+2 ui.button(w,x,math.min(y+1,ch-1),"▶ Play",function()play(st.list[st.sel])end,"suggested",14) gfx.text(x+16,math.min(y+1,ch-1),"Space pause · F full screen · M maximize · Esc stop",t.dim) end local function drawPlaying(w,cw,ch) local t=Y.theme local v=pb.video local vh=ch-2 gfx.fill(1,1,cw,vh,0x000000) local s0=math.min(cw/v.width,vh/v.height) if cfg.scale=="1:1"or math.abs(s0-1)<0.05 then gfx.blit(pb.grid,v.width,v.height,math.max(1,(cw-v.width)//2+1),math.max(1,(vh-v.height)//2+1)) else local s=s0 local dw,dh=math.max(1,math.floor(v.width*s)),math.max(1,math.floor(v.height*s)) gfx.blitScaled(pb.grid,v.width,v.height,(cw-dw)//2+1,(vh-dh)//2+1,dw,dh) end gfx.fill(1,ch-1,cw,2,t.header) local pos,dur=pb.k/v.fps,v.duration local ts=hms(pos).." / "..hms(dur) ui.progress(2,ch-1,cw-unicode.len(ts)-4,pb.k/v.frames) gfx.text(cw-unicode.len(ts),ch-1,ts,t.dim,t.header) local x=2 x=x+ui.button(w,x,ch,pb.paused and"▶"or"‖",function() if pb.paused then pb.t0=pb.t0+(computer.uptime()-pb.pausedAt);pb.paused=false else pb.paused,pb.pausedAt=true,computer.uptime()end end,"suggested",5)+1 x=x+ui.button(w,x,ch,"■",function()stopPlayback()end,nil,5)+1 x=x+ui.button(w,x,ch,"↻ Loop",function()cfg.loop=not cfg.loop;saveCfg(cfg)end,cfg.loop and"suggested"or"flat")+1 gfx.text(x+1,ch,string.format("%g× %d×%d %g fps",cfg.speed,v.width,v.height,v.fps),t.dim,t.header) local bx=cw-40 ui.button(w,bx,ch,w.maxState and"Restore"or"Maximize",function()Y.maximize(w)end,"flat",11) ui.button(w,bx+12,ch,"Full Screen",function()nativeFullscreen()end,nil,13) ui.button(w,bx+26,ch,"1:1 / Fit",function()cfg.scale=cfg.scale=="fit"and"1:1"or"fit";saveCfg(cfg)end,"flat",11) end function win.header(w,hw) if st.view=="library"then ui.button(w,2,1,"Add Folder…",function() Y.dialog{title="Add Video Folder",text="A folder with index.ocv (or a folder containing such folders)", entry="/home/Videos/",buttons={{"Cancel"},{"Add",function(p)if p and p~=""then addFolder(p)end end,"suggested"}}} end,nil,14) ui.button(w,17,1,"Refresh",function()scan()end,"flat",9) else ui.button(w,2,1,"‹ Library",function()stopPlayback("silent")end,"flat") end end function win.draw(w,cw,ch) if st.view=="playing"and pb then drawPlaying(w,cw,ch)else drawLibrary(w,cw,ch)end end function win.tick(w) if not pb or pb.paused then return end local ok,err=pcall(advance) if not ok then Y.notify("Media Player",tostring(err));stopPlayback("silent")end Y.redraw(w) end function win.key(w,char,code) local K=Y.K if st.view=="playing"and pb then if char==32 then if pb.paused then pb.t0=pb.t0+(computer.uptime()-pb.pausedAt);pb.paused=false else pb.paused,pb.pausedAt=true,computer.uptime()end elseif char==102 or code==0x57 then nativeFullscreen() elseif char==109 then Y.maximize(w) elseif char==108 then cfg.loop=not cfg.loop;saveCfg(cfg) elseif code==K.esc or char==113 then stopPlayback()end return end if code==K.down then st.sel=math.min(#st.list,st.sel+1) elseif code==K.up then st.sel=math.max(1,st.sel-1) elseif code==K.enter then play(st.list[st.sel]) elseif code==0x3F then scan()end end function win.scroll(w,x,y,dir)if st.view=="library"then st.top=math.max(0,st.top-dir)end end function win.cleanup() if pb then pb.stream.close();pb=nil end package.loaded["ocvlib"]=nil end fs.makeDirectory("/home/Videos") scan() if path then path=fs.canonical(path) if fs.name(path)=="index.ocv"then path=fs.path(path)end local found for i,v in ipairs(st.list)do if v.dir==path then st.sel,found=i,true end end if not found and L.isVideo(path)then table.insert(cfg.extra,path);saveCfg(cfg);scan() for i,v in ipairs(st.list)do if v.dir==path then st.sel=i end end end end return win end return M