diff --git a/src/web_ui/index.html b/src/web_ui/index.html index 398f7c5..75f81c4 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -446,6 +446,53 @@ .mx-prog>i{display:block;height:100%;border-radius:6px;transition:width .35s ease; background:linear-gradient(90deg,var(--accent),color-mix(in srgb,var(--accent) 38%,transparent))} + /* ── Cinematic video player ─────────────────────────────────────────────── + One custom player for both the direct (/files/) and transcode streams, + replacing the browser's native controls so the chrome matches the rest of + the UI and gains speed/captions/PiP/scrubbing it didn't have. */ + .vid-wrap{position:relative;flex:1;min-width:0;display:flex;align-items:center;justify-content:center;background:#000;border-radius:var(--radius-sm);overflow:hidden} + .vid-wrap video{width:100%;height:100%;max-height:72vh;background:#000;display:block;object-fit:contain} + .vid-wrap:fullscreen{border-radius:0} + .vid-wrap:fullscreen video{max-height:100vh;height:100%} + .vid-wrap.pl-idle{cursor:none} + .pl-center{position:absolute;top:50%;left:50%;width:88px;height:88px;border-radius:50%;z-index:3; + background:rgba(0,0,0,.5);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center; + font-size:36px;color:#fff;opacity:0;pointer-events:none;transform:translate(-50%,-50%) scale(.7)} + .pl-center.flash{animation:plFlash .55s ease-out} + @keyframes plFlash{0%{opacity:.95;transform:translate(-50%,-50%) scale(.7)}100%{opacity:0;transform:translate(-50%,-50%) scale(1.18)}} + .pl-scrim{position:absolute;left:0;right:0;bottom:0;height:130px;z-index:1;pointer-events:none;transition:opacity .3s; + background:linear-gradient(to top,rgba(0,0,0,.8),rgba(0,0,0,.22) 62%,transparent)} + .pl-controls{position:absolute;left:0;right:0;bottom:0;z-index:2;padding:6px 12px 9px;display:flex;flex-direction:column;gap:5px; + opacity:1;transform:translateY(0);transition:opacity .3s,transform .3s;font-variant-numeric:tabular-nums} + .pl-idle .pl-controls{opacity:0;pointer-events:none;transform:translateY(8px)} + .pl-idle .pl-scrim{opacity:0} + .pl-btnrow{display:flex;align-items:center;gap:5px} + .pl-controls button{background:transparent;border:none;color:#fff;cursor:pointer;font-size:16px;line-height:1;padding:5px 7px;border-radius:6px;transition:background .12s} + .pl-controls button:hover{background:rgba(255,255,255,.17);color:#fff} + .pl-time{font-size:12.5px;color:#e9e9e9;white-space:nowrap;margin:0 5px;letter-spacing:.02em} + .pl-spacer{flex:1} + .pl-vol{width:80px;accent-color:var(--accent);cursor:pointer;height:4px} + .pl-track{position:relative;height:17px;display:flex;align-items:center;cursor:pointer;touch-action:none} + .pl-rail{position:absolute;left:0;right:0;height:4px;border-radius:3px;background:rgba(255,255,255,.26);transition:height .12s} + .pl-track:hover .pl-rail,.pl-track.pl-drag .pl-rail{height:6px} + .pl-buffered{position:absolute;left:0;top:0;bottom:0;width:0;background:rgba(255,255,255,.34);border-radius:3px} + .pl-played{position:absolute;left:0;top:0;bottom:0;width:0;background:var(--accent);border-radius:3px} + .pl-thumb{position:absolute;right:-7px;top:50%;width:14px;height:14px;border-radius:50%;background:var(--accent); + transform:translateY(-50%) scale(0);transition:transform .12s;box-shadow:0 0 0 4px color-mix(in srgb,var(--accent) 28%,transparent)} + .pl-track:hover .pl-thumb,.pl-track.pl-drag .pl-thumb{transform:translateY(-50%) scale(1)} + .pl-tick{position:absolute;top:50%;width:2px;height:10px;transform:translate(-50%,-50%);background:rgba(255,255,255,.7);border-radius:1px;pointer-events:none} + .pl-hover{position:absolute;bottom:20px;transform:translateX(-50%);background:rgba(0,0,0,.86);color:#fff;font-size:11px; + padding:2px 7px;border-radius:5px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .12s} + .pl-track:hover .pl-hover,.pl-track.pl-drag .pl-hover{opacity:1} + .pl-speed{position:relative;display:flex} + .pl-speed-btn{font-size:13px!important;font-weight:600;min-width:40px} + .pl-menu{position:absolute;bottom:40px;right:0;background:rgba(14,14,20,.97);border:1px solid var(--hair);border-radius:9px; + padding:5px;display:none;flex-direction:column;gap:1px;min-width:76px;backdrop-filter:blur(8px);box-shadow:var(--shadow)} + .pl-menu.open{display:flex} + .pl-menu button{font-size:12px!important;text-align:left;padding:5px 10px;border-radius:5px;color:#dcdcdc} + .pl-menu button.on{color:var(--accent);font-weight:700} + #v-cc.on{color:var(--accent);background:color-mix(in srgb,var(--accent) 22%,transparent)} + @media(prefers-reduced-motion:reduce){*{animation:none!important;transition-duration:.001ms!important}} @@ -1602,26 +1649,86 @@ function playerSeek(t){ else{v.currentTime=t;v.play().catch(()=>{});} updateVctrl(); } -function vTogglePlay(){const v=document.getElementById('player-video');if(!v)return;if(v.paused)v.play().catch(()=>{});else v.pause();} +// Persisted player preferences (volume / speed / captions), shared across videos. +let plPrefs=(()=>{try{return JSON.parse(localStorage.getItem('plPrefs'))||{}}catch{return{}}})(); +function savePlPrefs(){try{localStorage.setItem('plPrefs',JSON.stringify(plPrefs))}catch{}} +const PL_SPEEDS=[0.5,0.75,1,1.25,1.5,1.75,2]; +let plDragging=false, plIdleTimer=null; +function plPct(a,b){return b>0?Math.max(0,Math.min(100,a/b*100)):0;} + +function plFlash(icon){const c=document.getElementById('pl-center');if(!c)return;c.textContent=icon;c.classList.remove('flash');void c.offsetWidth;c.classList.add('flash');} +function vTogglePlay(){const v=document.getElementById('player-video');if(!v)return;if(v.paused){v.play().catch(()=>{});plFlash('⏵');}else{v.pause();plFlash('⏸');}plActivity();} function vToggleMute(){const v=document.getElementById('player-video');if(!v)return;v.muted=!v.muted;updateVctrl();} -function vFullscreen(){const w=document.querySelector('.vid-wrap');if(w){if(document.fullscreenElement)document.exitFullscreen();else w.requestFullscreen&&w.requestFullscreen();}} +function vFullscreen(){const w=document.getElementById('pl-wrap')||document.querySelector('.vid-wrap');if(w){if(document.fullscreenElement)document.exitFullscreen();else w.requestFullscreen&&w.requestFullscreen();}} +function vPip(){const v=document.getElementById('player-video');if(!v)return;try{if(document.pictureInPictureElement)document.exitPictureInPicture();else v.requestPictureInPicture();}catch{}} +function vToggleCaptions(){ + const v=document.getElementById('player-video');if(!v||!v.textTracks||!v.textTracks.length)return; + const on=[...v.textTracks].some(t=>t.mode==='showing'); + for(const t of v.textTracks)t.mode='disabled'; + if(!on)v.textTracks[0].mode='showing'; + plPrefs.cc=!on;savePlPrefs(); + const b=document.getElementById('v-cc');if(b)b.classList.toggle('on',!on); +} +function vSpeedMenu(e){if(e)e.stopPropagation();const m=document.getElementById('v-speed-menu');if(!m)return;if(m.classList.toggle('open')){const v=document.getElementById('player-video');const cur=v?v.playbackRate:1;m.innerHTML=PL_SPEEDS.map(s=>``).join('');}} +function setSpeed(r){const v=document.getElementById('player-video');if(v)v.playbackRate=r;plPrefs.speed=r;savePlPrefs();const b=document.getElementById('v-speed');if(b)b.textContent=r+'×';const m=document.getElementById('v-speed-menu');if(m)m.classList.remove('open');} +// Close the speed popover on any outside click. +document.addEventListener('click',e=>{const m=document.getElementById('v-speed-menu');if(m&&m.classList.contains('open')&&!e.target.closest('.pl-speed'))m.classList.remove('open');}); + function updateVctrl(){ - const v=document.getElementById('player-video');if(!v||!player.isTranscode)return; - const t=effTime(),d=player.duration||0; + const v=document.getElementById('player-video');if(!v)return; + const d=player.duration||(isFinite(v.duration)?v.duration:0), t=effTime(); const tl=document.getElementById('v-time');if(tl)tl.textContent=fmtDur(t)+' / '+fmtDur(d); - const sk=document.getElementById('v-seek');if(sk&&!sk._drag)sk.value=Math.floor(t); + const pl=document.getElementById('pl-played');if(pl&&!plDragging)pl.style.width=plPct(t,d)+'%'; + let bend=0;try{if(v.buffered&&v.buffered.length){const base=player.isTranscode?player.seekBase:0;bend=base+v.buffered.end(v.buffered.length-1);}}catch{} + const bf=document.getElementById('pl-buf');if(bf)bf.style.width=plPct(bend,d)+'%'; const pb=document.getElementById('v-play');if(pb)pb.textContent=v.paused?'⏵':'⏸'; const mb=document.getElementById('v-mute');if(mb)mb.textContent=(v.muted||v.volume===0)?'🔇':'🔊'; } + +// Custom scrubber: click/drag to seek, hover for a time tooltip. Works for both +// modes because it routes through playerSeek (which reloads the transcode pipe). +function plBindScrubber(track){ + if(!track)return; + const rail=track.querySelector('.pl-rail'), played=document.getElementById('pl-played'), hover=document.getElementById('pl-hover'); + const frac=cx=>{const r=rail.getBoundingClientRect();return r.width?Math.max(0,Math.min(1,(cx-r.left)/r.width)):0;}; + const show=cx=>{const r=rail.getBoundingClientRect();const f=frac(cx);if(hover){hover.style.left=(f*r.width)+'px';hover.textContent=fmtDur(f*(player.duration||0));}}; + track.addEventListener('pointermove',e=>{show(e.clientX);if(plDragging&&played)played.style.width=(frac(e.clientX)*100)+'%';}); + track.addEventListener('pointerdown',e=>{plDragging=true;track.classList.add('pl-drag');try{track.setPointerCapture(e.pointerId)}catch{};show(e.clientX);if(played)played.style.width=(frac(e.clientX)*100)+'%';e.preventDefault();}); + const end=e=>{if(!plDragging)return;plDragging=false;track.classList.remove('pl-drag');playerSeek(frac(e.clientX)*(player.duration||0));}; + track.addEventListener('pointerup',end); + track.addEventListener('pointercancel',()=>{plDragging=false;track.classList.remove('pl-drag');}); +} + +// Auto-hide the controls (and cursor) after a short idle while playing. +function plActivity(){ + const w=document.getElementById('pl-wrap');if(!w)return; + w.classList.remove('pl-idle');clearTimeout(plIdleTimer); + const v=document.getElementById('player-video'); + if(v&&!v.paused)plIdleTimer=setTimeout(()=>{const ww=document.getElementById('pl-wrap');if(ww&&!plDragging)ww.classList.add('pl-idle');},2600); +} + +function syncVol(){const v=document.getElementById('player-video');if(!v)return;const s=document.getElementById('v-vol');if(s)s.value=v.volume;plPrefs.volume=v.volume;savePlPrefs();updateVctrl();} + function playerKey(e){ if(/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName))return; const v=document.getElementById('player-video');if(!v)return; - if(e.key===' '||e.key==='k'){e.preventDefault();vTogglePlay();} - else if(e.key==='f'){e.preventDefault();vFullscreen();} - else if(player.isTranscode&&e.key==='ArrowLeft'){e.preventDefault();playerSeek(effTime()-5);} - else if(player.isTranscode&&e.key==='ArrowRight'){e.preventDefault();playerSeek(effTime()+5);} - else if(player.isTranscode&&e.key==='j'){e.preventDefault();playerSeek(effTime()-10);} - else if(player.isTranscode&&e.key==='l'){e.preventDefault();playerSeek(effTime()+10);} + const k=e.key; + if(k===' '||k==='k'){e.preventDefault();vTogglePlay();} + else if(k==='f'){e.preventDefault();vFullscreen();} + else if(k==='m'){e.preventDefault();vToggleMute();} + else if(k==='c'){e.preventDefault();vToggleCaptions();} + else if(k==='p'){e.preventDefault();vPip();} + else if(k==='ArrowLeft'){e.preventDefault();playerSeek(effTime()-5);} + else if(k==='ArrowRight'){e.preventDefault();playerSeek(effTime()+5);} + else if(k==='j'){e.preventDefault();playerSeek(effTime()-10);} + else if(k==='l'){e.preventDefault();playerSeek(effTime()+10);} + else if(k==='ArrowUp'){e.preventDefault();v.muted=false;v.volume=Math.min(1,v.volume+0.1);syncVol();} + else if(k==='ArrowDown'){e.preventDefault();v.volume=Math.max(0,v.volume-0.1);syncVol();} + else if(k===','||k==='<'){e.preventDefault();setSpeed(Math.max(0.25,Math.round((v.playbackRate-0.25)*100)/100));} + else if(k==='.'||k==='>'){e.preventDefault();setSpeed(Math.min(3,Math.round((v.playbackRate+0.25)*100)/100));} + else if(k>='0'&&k<='9'){e.preventDefault();playerSeek((player.duration||0)*(+k/10));} + else return; + plActivity(); } function playVideo(id){ const v=findVideo(id);if(!v||!v.video_url)return; @@ -1629,7 +1736,9 @@ function playVideo(id){ transcript={cues:[],loaded:false}; // reset for the new video const bg=document.createElement('div');bg.className='modal-bg'; bg.onclick=e=>{if(e.target===bg)closeModal(bg)}; - const tracks=(v.subtitles||[]).map((s,i)=>``).join(''); + // No `default` — overlaid captions start off and are toggled by the CC button + // (the separate 📄 transcript pane is the searchable view). + const tracks=(v.subtitles||[]).map(s=>``).join(''); const chapPane=v.has_chapters?`

Chapters

Loading…
`:''; const hasSubs=v.subtitles&&v.subtitles.length; const transcriptPane=hasSubs?`