Live portal: clip the timecode band with a measured game-aspect frame

CSS aspect-ratio on a flex:1 child was ignored, so object-fit:cover cropped arbitrarily. Player now
measures the panel (ResizeObserver: fires on every dockview resize/move), fits a frame of the game aspect,
lays the video across its width and hides the overflow at the bottom.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 13:14:25 -06:00
parent eb36ce771d
commit 68bb0e66d5
3 changed files with 34 additions and 12 deletions

View File

@@ -70,7 +70,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
## Unfinished, in rough priority
0. **Band removal**: deployed 2026-11-01; `vgdeband.sh` catch-up ran on the Vanguard-Training recordings (check `journalctl -u vgdeband` for the done lines). Live portal: band still shown on the live picture → canvas positioning + overflow hide, later.
0. **Band removal**: deployed 2026-11-01; `vgdeband.sh` catch-up ran on the Vanguard-Training recordings (check `journalctl -u vgdeband` for the done lines). Live portal: viewer now clips the band with a measured game-aspect frame (refits on every dockview resize/move) — committed 2026-11-01, deploy with `./deployLive.sh`.
1. Roles→groups policy: bound and updated (revokes only its own grants); will be proven by the next natural Discord logins (`/whoami` should show `bot-users`). Nested groups **verified 2026-11-01**: `git-admins` now reaches the header via the `Vanguard profile (nested groups)` scope mapping (README 5e). `akadmin` remains the recovery login if a policy misfires.
2. Confirm plugin 0.1.3 under Jellyfin → Plugins and the Gitea runner registered.

View File

@@ -9,7 +9,9 @@ export default function Player({ params, api }) {
const videoRef = useRef(null)
const [status, setStatus] = useState('connecting…')
const [viewers, setViewers] = useState(params.viewers ?? 0)
const [aspect, setAspect] = useState(null) // game aspect once we know the video size (crops the timecode band)
const wrapRef = useRef(null)
const [dims, setDims] = useState(null) // {w, h, gameH}: video size + game height (h minus the timecode band)
const [box, setBox] = useState(null) // {W, H}: game-aspect frame fitted into the panel; video overflows it at the bottom
const state = audio.use()
const muted = effectiveMuted(state, id)
const volume = state.streams[id]?.volume ?? 0.8
@@ -17,6 +19,26 @@ export default function Player({ params, api }) {
const focused = state.focused === id
useEffect(() => { audio.ensure(id) }, [id])
// size the frame: fit a box of the GAME aspect (video width / height-without-band) into the panel, letterboxed.
// The video is laid across the full box width, so its band rows fall below the box and are clipped.
useEffect(() => {
const el = wrapRef.current; if (!el) return
const fit = () => {
if (!dims) { setBox(null); return }
const cw = el.clientWidth, ch = el.clientHeight; if (!cw || !ch) return
const a = dims.w / dims.gameH
const W = cw / ch > a ? Math.floor(ch * a) : cw
setBox({ W, H: Math.floor(W / a) })
}
fit(); const ro = new ResizeObserver(fit); ro.observe(el); return () => ro.disconnect()
}, [dims])
const onSize = e => {
const v = e.currentTarget; const w = v.videoWidth, h = v.videoHeight; if (!w || !h) return
let band = Math.round(w / 160); band += band % 2
// only treat the extra rows as a band if what remains is a standard game height
const gameH = [720, 1080, 1440, 2160].includes(h - band) ? h - band : h
setDims(d => (d && d.w === w && d.h === h) ? d : { w, h, gameH })
}
useEffect(() => { const d = api.onDidActiveChange?.(() => {}) ; return () => d?.dispose?.() }, [api])
useEffect(() => { const t = setInterval(() => setViewers(params.viewers ?? 0), 2000); return () => clearInterval(t) }, [params.viewers])
@@ -73,12 +95,10 @@ export default function Player({ params, api }) {
const icon = muted ? '🔇' : volume === 0 ? '🔈' : volume < 0.5 ? '🔉' : '🔊'
return (
<div className={'player' + (focused ? ' focused' : '')} onMouseDown={() => api.setActive?.()}>
<div className="vwrap" style={aspect ? { aspectRatio: aspect } : undefined}>
<video ref={videoRef} autoPlay playsInline muted className={aspect ? 'cropband' : ''}
onLoadedMetadata={e => { const v = e.currentTarget; const w = v.videoWidth, h = v.videoHeight; if (!w || !h) return
let band = Math.round(w / 160); band += band % 2; const gameH = h - band
// only treat it as a band if the remaining height is a standard game height
setAspect([720, 1080, 1440, 2160].includes(gameH) ? `${w} / ${gameH}` : null) }} />
<div className="vwrap" ref={wrapRef}>
<div className="frame" style={box ? { width: box.W, height: box.H } : undefined}>
<video ref={videoRef} autoPlay playsInline muted onLoadedMetadata={onSize} onResize={onSize} />
</div>
</div>
{status && <div className="status">{status}</div>}
<div className="bar">

View File

@@ -16,10 +16,12 @@ html, body, #root { height:100%; margin:0; background:var(--bg); color:var(--fg)
--dv-separator-border:#2a2a2a; --dv-group-view-background-color:var(--panel); --dv-drag-over-background-color:rgba(61,139,253,.2);
--dv-drag-over-border-color:var(--acc); --dv-tabs-container-scrollbar-color:#444; --dv-icon-hover-background-color:#333; }
.player { position:relative; width:100%; height:100%; background:#000; display:flex; flex-direction:column; }
.player .vwrap { flex:1; min-height:0; display:flex; align-items:center; justify-content:center; background:#000; }
.player .vwrap[style*="aspect-ratio"] { max-height:100%; max-width:100%; margin:auto; }
.player video { width:100%; height:100%; object-fit:contain; background:#000; }
.player video.cropband { object-fit:cover; object-position:50% 0; } /* top-aligned cover inside a game-aspect box = band cropped */
.player .vwrap { flex:1; min-height:0; position:relative; display:flex; align-items:center; justify-content:center; background:#000; overflow:hidden; }
/* .frame = game-aspect box (sized by Player.jsx); the video spans its width, so the timecode band rows overflow the bottom and are clipped */
.player .frame { position:relative; width:100%; height:100%; overflow:hidden; background:#000; }
.player .frame video { position:absolute; top:0; left:0; width:100%; height:auto; display:block; background:#000; }
.player .frame[style] video { height:auto; }
.player .frame:not([style]) video { position:static; width:100%; height:100%; object-fit:contain; } /* before the size is known: plain contain */
.player .bar { display:flex; align-items:center; gap:10px; padding:6px 10px; background:#181818; border-top:1px solid #2a2a2a; font-size:13px; }
.player .live { background:var(--live); color:#fff; font-weight:700; font-size:11px; padding:2px 6px; border-radius:4px; letter-spacing:.5px; }
.player .who { font-weight:600; }