-- mpv oscf modern -- by maoiscat -- github.com/maoiscat/ package.path = package.path .. ";".. os.getenv("HOME") .. "/.config/mpv/lib/oscf/?.lua" require("expansion") local assdraw = require 'mp.assdraw' -- user options opts = { scale = 1, -- osc render scale fixedHeight = false, -- true to allow osc scale with window hideTimeout = 1, -- seconds untile osc hides, negative means never fadeDuration = 0.5, -- seconds during fade out, negative means never } -- logo and message works out of box addToIdleLayout('logo') -- define styles local styles = { background = { color = {'0', '0', '0', '0'}, alpha = {255, 255, 0, 0}, border = 140, blur = 140, }, tooltip = { color = {'FFFFFF', 'FFFFFF', '0', '0'}, border = 0.5, blur = 1, fontsize = 18, wrap = 2, }, button1 = { color1 = {'FFFFFF', 'FFFFFF', 'FFFFFF', 'FFFFFF'}, color2 = {'999999', '999999', '999999', '999999'}, fontsize = 36, border = 0, blur = 0, font = 'material-design-iconic-font', wrap = 2, }, button2 = { color1 = {'FFFFFF', 'FFFFFF', 'FFFFFF', 'FFFFFF'}, color2 = {'999999', '999999', '999999', '999999'}, border = 0, blur = 0, fontsize = 24, font = 'material-design-iconic-font', wrap = 2, }, seekbarFg = { color1 = {'E39C42', 'E39C42', '0', '0'}, color2 = {'999999', '999999', '0', '0'}, border = 0.5, blur = 1, }, seekbarBg = { color = {'eeeeee', 'eeeeee', '0', '0'}, border = 0, blur = 0, }, volumeSlider = { color = {'ffffff', '0', '0', '0'}, border = 0, blur = 0, }, time = { color1 = {'ffffff', 'ffffff', '0', '0'}, color2 = {'eeeeee', 'eeeeee', '0', '0'}, border = 0, blur = 0, fontsize = 17, }, title = { color = {'ffffff', '0', '0', '0'}, border = 0.5, blur = 1, fontsize = 48, wrap = 2, }, winControl = { color1 = {'ffffff', 'ffffff', '0', '0'}, color2 = {'eeeeee', 'eeeeee', '0', '0'}, border = 0.5, blur = 1, font = 'mpv-osd-symbols', fontsize = 20, }, } -- enviroment updater -- this element updates shared vairables, sets active areas and starts event generators local env env = newElement('env') env.layer = 1000 env.visible = false env.updateTime = function() dispatchEvent('time') end env.init = function(self) self.slowTimer = mp.add_periodic_timer(0.25, self.updateTime) --use a slower timer to update playtime -- event generators mp.observe_property('track-list/count', 'native', function(name, val) if val==0 then return end player.tracks = getTrackList() player.playlist = getPlaylist() player.chapters = getChapterList() player.playlistPos = getPlaylistPos() player.duration = mp.get_property_number('duration') showOsc() dispatchEvent('file-loaded') end) mp.observe_property('pause', 'bool', function(name, val) player.paused = val dispatchEvent('pause') end) mp.observe_property('fullscreen', 'bool', function(name, val) player.fullscreen = val dispatchEvent('fullscreen') end) mp.observe_property('window-maximized', 'bool', function(name, val) player.maximized = val dispatchEvent('window-maximized') end) mp.observe_property('current-tracks/audio/id', 'number', function(name, val) if val then player.audioTrack = val else player.audioTrack = 0 end dispatchEvent('audio-changed') end) mp.observe_property('current-tracks/sub/id', 'number', function(name, val) if val then player.subTrack = val else player.subTrack = 0 end dispatchEvent('sub-changed') end) mp.observe_property('mute', 'bool', function(name, val) player.muted = val dispatchEvent('mute') end) mp.observe_property('volume', 'number', function(name, val) player.volume = val dispatchEvent('volume') end) end env.tick = function(self) player.percentPos = mp.get_property_number('percent-pos') player.timePos = mp.get_property_number('time-pos') player.timeRem = mp.get_property_number('time-remaining') return '' end env.responder['resize'] = function(self) player.geo.refX = player.geo.width / 2 player.geo.refY = player.geo.height - 40 setPlayActiveArea('bg1', 0, player.geo.height - 120, player.geo.width, player.geo.height) if player.fullscreen then setPlayActiveArea('wc1', player.geo.width - 200, 0, player.geo.width, 48) else setPlayActiveArea('wc1', -1, -1, -1, -1) end return false end env.responder['pause'] = function(self) if player.idle then return end if player.paused then setVisibility('always') else setVisibility('normal') end end env.responder['idle'] = function(self) if player.idle then setVisibility('always') else setVisibility('normal') end return false end env:init() addToPlayLayout('env') -- background local ne ne = newElement('background', 'box') ne.geo.h = 1 ne.geo.an = 8 ne.layer = 5 -- DO NOT directly assign a shared style tabe!! ne.style = clone(styles.background) ne.responder['resize'] = function(self) self.geo.x = player.geo.refX self.geo.y = player.geo.height self.geo.w = player.geo.width self.setPos(self) self.render(self) return false end ne:init() addToPlayLayout('background') -- a shared tooltip ne = newElement('tip', 'tooltip') ne.layer = 20 ne.style = clone(styles.tooltip) ne:init() addToPlayLayout('tip') local tooltip = ne -- playpause button ne = newElement('btnPlay', 'button') ne.layer = 10 ne.style = clone(styles.button1) ne.geo.w = 45 ne.geo.h = 45 ne.geo.an = 5 ne.responder['resize'] = function(self) self.geo.x = player.geo.refX self.geo.y = player.geo.refY self:setPos() self:setHitBox() return false end ne.responder['mbtn_left_up'] = function(self, pos) if self.enabled and self:isInside(pos) then mp.commandv('cycle', 'pause') return true end return false end ne.responder['pause'] = function(self) if player.paused then self.text = '\xEF\x8E\xAA' else self.text = '\xEF\x8E\xA7' end self:render() return false end ne:init() addToPlayLayout('btnPlay') -- skip back button ne = newElement('btnBack', 'button') ne.layer = 10 ne.style = clone(styles.button2) ne.geo.w = 30 ne.geo.h = 24 ne.geo.an = 5 ne.text = '\xEF\x8E\xA0' ne.responder['resize'] = function(self) self.geo.x = player.geo.refX - 60 self.geo.y = player.geo.refY self:setPos() self:setHitBox() return false end ne.responder['mbtn_left_up'] = function(self, pos) if self.enabled and self:isInside(pos) then mp.commandv('seek', -5, 'relative', 'keyframes') return true end return false end ne:init() addToPlayLayout('btnBack') -- skip forward button ne = newElement('btnForward', 'btnBack') ne.text = '\xEF\x8E\x9F' ne.responder['mbtn_left_up'] = function(self, pos) if self.enabled and self:isInside(pos) then mp.commandv('seek', 5, 'relative', 'keyframes') return true end return false end ne.responder['resize'] = function(self) self.geo.x = player.geo.refX + 60 self.geo.y = player.geo.refY self:setPos() self:setHitBox() return false end ne:init() addToPlayLayout('btnForward') -- play previous file button ne = newElement('btnPrev', 'button') ne.layer = 10 ne.style = clone(styles.button2) ne.geo.w = 30 ne.geo.h = 24 ne.geo.an = 5 ne.text = '\xEF\x8E\xB5' ne.responder['mbtn_left_up'] = function(self, pos) if self.enabled and self:isInside(pos) then mp.commandv('playlist-prev', 'weak') return true end return false end ne.responder['resize'] = function(self) self.geo.x = player.geo.refX - 120 self.geo.y = player.geo.refY self:setPos() self:setHitBox() return false end ne.responder['file-loaded'] = function(self) if player.playlistPos <= 1 and player.loopPlaylist == 'no' then self:disable() else self:enable() end return false end ne:init() addToPlayLayout('btnPrev') -- play next file button ne = newElement('btnNext', 'btnPrev') ne.text = '\xEF\x8E\xB4' ne.responder['mbtn_left_up'] = function(self, pos) if self.enabled and self:isInside(pos) then mp.commandv('playlist-next', 'weak') return true end return false end ne.responder['resize'] = function(self) self.geo.x = player.geo.refX + 120 self.geo.y = player.geo.refY self:setPos() self:setHitBox() return false end ne.responder['file-loaded'] = function(self) if player.playlistPos >= #player.playlist and player.loopPlaylist == 'no' then self:disable() else self:enable() end return false end ne:init() addToPlayLayout('btnNext') -- cycle audio button ne = newElement('cycleAudio', 'button') ne.layer = 10 ne.style = clone(styles.button2) ne.geo.w = 30 ne.geo.h = 24 ne.geo.an = 5 ne.text = '\xEF\x8E\xB7' ne.tipText = '' ne.responder['resize'] = function(self) self.geo.x = 37 self.geo.y = player.geo.refY self.visible = player.geo.width >= 540 self:setPos() self:setHitBox() return false end ne.responder['mouse_move'] = function(self, pos) if self.enabled and self:isInside(pos) then tooltip:show(self.tipText, {self.geo.x, self.geo.y+30}, self) return true else tooltip:hide(self) return false end end ne.responder['file-loaded'] = function(self) if #player.tracks.audio > 0 then self:enable() else self:disable() end end ne.responder['audio-changed'] = function(self) if player.tracks then local lang if player.audioTrack == 0 then lang = 'OFF' else lang = player.tracks.audio[player.audioTrack].lang end if not lang then lang = 'unknown' end self.tipText = string.format('[%s/%s][%s]', player.audioTrack, #player.tracks.audio, lang) tooltip:update(self.tipText, self) end return false end ne.responder['mbtn_left_up'] = function(self, pos) if self.enabled and self:isInside(pos) then cycleTrack('audio') return true end return false end ne.responder['mbtn_right_up'] = function(self, pos) if self.enabled and self:isInside(pos) then cycleTrack('audio', 'prev') return true end return false end ne:init() addToPlayLayout('cycleAudio') -- cycle sub button ne = newElement('cycleSub', 'cycleAudio') ne.text = '\xEF\x8F\x93' ne.responder['resize'] = function(self) self.geo.x = 87 self.geo.y = player.geo.refY self.visible = player.geo.width >= 600 self:setPos() self:setHitBox() return false end ne.responder['file-loaded'] = function(self) if #player.tracks.sub > 0 then self:enable() else self:disable() end end ne.responder['audio-changed'] = nil ne.responder['sub-changed'] = function(self) if player.tracks then local title if player.subTrack == 0 then title = 'OFF' else title = player.tracks.sub[player.subTrack].title end if not title then title = 'unknown' end self.tipText = string.format('[%s/%s][%s]', player.subTrack, #player.tracks.sub, title) tooltip:update(self.tipText, self) end return false end ne.responder['mbtn_left_up'] = function(self, pos) if self.enabled and self:isInside(pos) then cycleTrack('sub') return true end return false end ne.responder['mbtn_right_up'] = function(self, pos) if self.enabled and self:isInside(pos) then cycleTrack('sub', 'prev') return true end return false end ne:init() addToPlayLayout('cycleSub') -- toggle mute ne = newElement('togMute', 'button') ne.layer = 10 ne.style = clone(styles.button2) ne.geo.x = 137 ne.geo.w = 30 ne.geo.h = 24 ne.geo.an = 5 ne.responder['resize'] = function(self) self.geo.y = player.geo.refY self.visible = player.geo.width >= 700 self:setPos() self:setHitBox() return false end ne.responder['mbtn_left_up'] = function(self, pos) if self.enabled and self:isInside(pos) then mp.commandv('cycle', 'mute') return true end return false end ne.responder['mute'] = function(self) if player.muted then self.text = '\xEF\x8E\xBB' else self.text = '\xEF\x8E\xBC' end self:render() return false end ne:init() addToPlayLayout('togMute', 'button') -- volume slider -- background ne = newElement('volumeSliderBg', 'box') ne.layer = 9 ne.style = clone(styles.volumeSlider) ne.geo.r = 0 ne.geo.h = 1 ne.geo.an = 4 ne.responder['resize'] = function(self) self.visible = player.geo.width > 740 self.geo.x = 156 self.geo.y = player.geo.refY self.geo.w = 80 self:init() end ne:init() addToPlayLayout('volumeSliderBg') -- seekbar ne = newElement('volumeSlider', 'slider') ne.layer = 10 ne.style = clone(styles.volumeSlider) ne.geo.an = 4 ne.geo.h = 14 ne.barHeight = 2 ne.barRadius = 0 ne.nobRadius = 4 ne.allowDrag = false ne.lastSeek = nil ne.responder['resize'] = function(self) self.visible = player.geo.width > 740 self.geo.an = 4 self.geo.x = 152 self.geo.y = player.geo.refY self.geo.w = 88 self:setParam() -- setParam may change geo settings self:setPos() self:render() end ne.responder['volume'] = function(self) local val = player.volume if val then if val > 140 then val = 140 elseif val < 0 then val = 0 end self.value = val/1.4 self.xValue = val/140 * self.xLength self:render() end return false end ne.responder['idle'] = ne.responder['volume'] ne.responder['mouse_move'] = function(self, pos) if not self.enabled then return false end local vol = self:getValueAt(pos) if self.allowDrag then if vol then mp.commandv('set', 'volume', vol*1.4) env.updateTime() end end if self:isInside(pos) then local tipText if vol then tipText = string.format('%d', vol*1.4) else tipText = 'N/A' end tooltip:show(tipText, {pos[1], self.geo.y}, self) return true else tooltip:hide(self) return false end end ne.responder['mbtn_left_down'] = function(self, pos) if not self.enabled then return false end if self:isInside(pos) then self.allowDrag = true local vol = self:getValueAt(pos) if vol then mp.commandv('set', 'volume', vol*1.4) return true end end return false end ne.responder['mbtn_left_up'] = function(self, pos) self.allowDrag = false self.lastSeek = nil end ne:init() addToPlayLayout('volumeSlider') -- toggle info ne = newElement('togInfo', 'button') ne.layer = 10 ne.style = clone(styles.button2) ne.geo.w = 30 ne.geo.h = 24 ne.geo.an = 5 ne.text = '\xEF\x87\xB7' ne.responder['resize'] = function(self) self.geo.x = player.geo.width - 87 self.geo.y = player.geo.refY self.visible = player.geo.width >= 640 self:setPos() self:setHitBox() return false end ne.responder['mbtn_left_up'] = function(self, pos) if self.enabled and self:isInside(pos) then mp.commandv('script-binding', 'stats/display-stats-toggle') return true end return false end ne:init() addToPlayLayout('togInfo') -- toggle fullscreen ne = newElement('togFs', 'togInfo') ne.text = '\xEF\x85\xAD' ne.responder['resize'] = function(self) self.geo.x = player.geo.width - 37 self.geo.y = player.geo.refY self.visible = player.geo.width >= 600 if (player.fullscreen) then self.text = '\xEF\x85\xAC' else self.text = '\xEF\x85\xAD' end self:render() self:setPos() self:setHitBox() return false end ne.responder['mbtn_left_up'] = function(self, pos) if self.enabled and self:isInside(pos) then mp.commandv('cycle', 'fullscreen') return true end return false end ne:init() addToPlayLayout('togFs') -- seekbar background ne = newElement('seekbarBg', 'box') ne.layer = 9 ne.style = clone(styles.seekbarBg) ne.geo.r = 0 ne.geo.h = 2 ne.geo.an = 5 ne.responder['resize'] = function(self) self.geo.x = player.geo.refX self.geo.y = player.geo.refY - 56 self.geo.w = player.geo.width - 50 self:init() end ne:init() addToPlayLayout('seekbarBg') -- seekbar ne = newElement('seekbar', 'slider') ne.layer = 10 ne.style = clone(styles.seekbarFg) ne.geo.an = 5 ne.geo.h = 20 ne.barHeight = 2 ne.barRadius = 0 ne.nobRadius = 8 ne.allowDrag = false ne.lastSeek = nil ne.responder['resize'] = function(self) self.geo.an = 5 self.geo.x = player.geo.refX self.geo.y = player.geo.refY - 56 self.geo.w = player.geo.width - 34 self:setParam() -- setParam may change geo settings self:setPos() self:render() end ne.responder['time'] = function(self) local val = player.percentPos if val and not self.enabled then self:enable() elseif not val and self.enabled then tooltip:hide(self) self:disable() end if val then self.value = val self.xValue = val/100 * self.xLength self:render() end return false end ne.responder['mouse_move'] = function(self, pos) if not self.enabled then return false end if self.allowDrag then local seekTo = self:getValueAt(pos) if seekTo then mp.commandv('seek', seekTo, 'absolute-percent') env.updateTime() end end if self:isInside(pos) then local tipText if player.duration then local seconds = self:getValueAt(pos)/100 * player.duration if #player.chapters > 0 then local ch = #player.chapters for i, v in ipairs(player.chapters) do if seconds < v.time then ch = i - 1 break end end if ch == 0 then tipText = string.format('[0/%d][unknown]\\N%s', #player.chapters, mp.format_time(seconds)) else local title = player.chapters[ch].title if not title then title = 'unknown' end tipText = string.format('[%d/%d][%s]\\N%s', ch, #player.chapters, title, mp.format_time(seconds)) end else tipText = mp.format_time(seconds) end else tipText = '--:--:--' end tooltip:show(tipText, {pos[1], self.geo.y}, self) return true else tooltip:hide(self) return false end end ne.responder['mbtn_left_down'] = function(self, pos) if not self.enabled then return false end if self:isInside(pos) then self.allowDrag = true local seekTo = self:getValueAt(pos) if seekTo then mp.commandv('seek', seekTo, 'absolute-percent') env.updateTime() return true end end return false end ne.responder['mbtn_left_up'] = function(self, pos) self.allowDrag = false self.lastSeek = nil end ne.responder['file-loaded'] = function(self) -- update chapter markers env.updateTime() self.markers = {} if player.duration then for i, v in ipairs(player.chapters) do self.markers[i] = (v.time*100 / player.duration) end self:render() end return false end ne:init() addToPlayLayout('seekbar') -- time display ne = newElement('time1', 'button') ne.layer = 10 ne.style = clone(styles.time) ne.geo.w = 64 ne.geo.h = 20 ne.geo.an = 7 ne.enabled = true ne.responder['resize'] = function(self) self.geo.x = 25 self.geo.y = player.geo.refY - 44 self:setPos() end ne.responder['time'] = function(self) if player.timePos then self.pack[4] = mp.format_time(player.timePos) else self.pack[4] = '--:--:--' end end ne:init() addToPlayLayout('time1') -- time duration ne = newElement('time2', 'time1') ne.geo.an = 9 ne.isDuration = true ne.responder['resize'] = function(self) self.geo.x = player.geo.width - 25 self.geo.y = player.geo.refY - 44 self:setPos() self:setHitBox() end ne.responder['time'] = function(self) if self.isDuration then val = player.duration else val = -player.timeRem end if val then self.pack[4] = mp.format_time(val) else self.pack[4] = '--:--:--' end end ne.responder['mbtn_left_up'] = function(self, pos) if self:isInside(pos) then self.isDuration = not self.isDuration return true end return false end ne:init() addToPlayLayout('time2') -- title ne = newElement('title') ne.layer = 10 ne.style = clone(styles.title) ne.geo.x = 20 ne.geo.an = 1 ne.visible = false ne.title = '' ne.render = function(self) local maxchars = player.geo.width / 23 local text = self.title -- 估计1个中文字符约等于1.5个英文字符 local charcount = (text:len() + select(2, text:gsub('[^\128-\193]', ''))*2) / 3 if not (maxchars == nil) and (charcount > maxchars) then local limit = math.max(0, maxchars - 3) if (charcount > limit) then while (charcount > limit) do text = text:gsub('.[\128-\191]*$', '') charcount = (text:len() + select(2, text:gsub('[^\128-\193]', ''))*2) / 3 end text = text .. '...' end end self.pack[4] = text end ne.tick = function(self) if not self.visible then return '' end if self.trans >= 0.9 then self.visible = false end return table.concat(self.pack) end ne.responder['resize'] = function(self) self.geo.y = player.geo.refY - 92 self:setPos() self:render() self.visible = self.visible and (player.geo.height >= 320) end ne.responder['pause'] = function(self) self.visible = (self.visible or player.paused) and (player.geo.height >= 320) end ne.responder['file-loaded'] = function(self) local title = mp.command_native({'expand-text', '${media-title}'}) title = title:gsub('\\n', ' '):gsub('\\$', ''):gsub('{','\\{') self.title = title self:render() self.visible = true end ne.responder['idle'] = function(self) self.visible = not player.idle return false end ne:init() addToPlayLayout('title') -- window controllers ne = newElement('winClose', 'button') ne.layer = 10 ne.style = clone(styles.winControl) ne.geo.y = 16 ne.geo.w = 40 ne.geo.h = 32 ne.geo.an = 5 ne.text = '\238\132\149' ne.responder['resize'] = function(self) self.geo.x = player.geo.width - 20 self:init() end ne.responder['mbtn_left_up'] = function(self, pos) if self.visible and self:isInside(pos) then mp.commandv('quit') return true end return false end ne.responder['fullscreen'] = function(self) self.visible = player.fullscreen end ne:init() addToPlayLayout('winClose') ne = newElement('winMax', 'winClose') ne.text = '' ne.responder['resize'] = function(self) self.geo.x = player.geo.width - 60 if player.maximized or player.fullscreen then self.text = '\238\132\148' else self.text = '\238\132\147' end self:init() end ne.responder['mbtn_left_up'] = function(self, pos) if self.visible and self:isInside(pos) then if player.fullscreen then mp.commandv('cycle', 'fullscreen') else mp.commandv('cycle', 'window-maximized') end return true end return false end ne:init() addToPlayLayout('winMax') ne = newElement('winMin', 'winClose') ne.text = '\238\132\146' ne.responder['resize'] = function(self) self.geo.x = player.geo.width - 100 self:init() end ne.responder['mbtn_left_up'] = function(self, pos) if self.visible and self:isInside(pos) then mp.commandv('cycle', 'window-minimized') return true end return false end ne:init() addToPlayLayout('winMin')