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:
@@ -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">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user