Pick formats by decode cost, and fix auto picture-in-picture

Two things, both about playing several videos at once.

Capping the resolution per panel wasn't enough, because pixel count isn't
the only cost. A split panel now also prefers a progressive file over HLS
-- every HLS panel runs its own JavaScript demuxer over every segment, so
four panels means four media pipelines doing work a plain MP4 skips
entirely -- and H.264 over AV1 or VP9, which are often decoded in software
and are a cliff rather than a gradient, and 30fps over 60. The height
ceiling still comes first, so cheapness cannot argue a panel into a bigger
picture than it should have, and every format stays reachable as fallback.
The preloaded step's hls.js instances now park after buffering one
fragment and resume when the reader swipes to them, instead of fetching
and demuxing ahead for a step nobody reached.

Auto picture-in-picture had been implemented since the custom player was
written and had never worked. requestPictureInPicture() from a
visibilitychange handler carries no user activation, browsers refuse those,
and .catch(() => {}) swallowed the refusal -- so it failed silently every
time, in the reels feed and the standalone player alike. The declarative
autoPictureInPicture attribute is the form made for this: the browser is
told in advance which video should follow the reader out. The imperative
call stays as a fallback.

With panels there are several candidates and only one window, so binding
every pane made them race for it. The feed picks one deliberately -- the
panel you can hear, or the first if they are all muted -- re-picks when the
step or a mute switch changes, and releases it on close.

Whether a window actually opens is browser policy, not ours: Safari honours
the attribute, Chrome honours it for installed apps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
This commit is contained in:
Simon
2026-09-10 20:16:08 +00:00
parent 764e3416a3
commit 451bf0f983
4 changed files with 164 additions and 4 deletions

View File

@@ -123,5 +123,37 @@ ok('the tighter of the two wins (preference)',
ok('a cap never raises the preference',
heightOf(resolveStreamSource(video, { maxHeight: 2160 })) === 480);
console.log('\nranking by what it costs to decode');
// Same picture, four ways of arriving at it.
const mixed = [
{ url: 'hls720', height: 720, vcodec: 'avc1', protocol: 'm3u8_native', fps: 30 },
{ url: 'av1720', height: 720, vcodec: 'av01.0.05M.08', protocol: 'https', ext: 'mp4', fps: 30 },
{ url: 'avc720', height: 720, vcodec: 'avc1', protocol: 'https', ext: 'mp4', fps: 30, tbr: 900 },
{ url: 'avc720p60', height: 720, vcodec: 'avc1', protocol: 'https', ext: 'mp4', fps: 60, tbr: 2000 },
];
const mixedVideo = { id: 'v2', url: 'https://example.com/w2', meta: { formats: mixed } };
sandbox.App.storage.getPreferredQuality = () => 'auto';
const best = (opts) => resolveStreamSource(mixedVideo, opts).url;
ok('without the flag, bitrate still wins', best({}) === 'avc720p60', best({}));
ok('cheapest avoids HLS demuxing in JS', best({ cheapest: true }) !== 'hls720');
ok('cheapest avoids software-decoded AV1', best({ cheapest: true }) !== 'av1720');
ok('cheapest avoids 60fps', best({ cheapest: true }) !== 'avc720p60');
ok('cheapest picks progressive H.264 at 30fps',
best({ cheapest: true }) === 'avc720', best({ cheapest: true }));
// Cheapness must not override the size ceiling, or a split panel would get a
// bigger picture than it can afford just because it is cheap per pixel.
const tall = [
{ url: 'hls480', height: 480, vcodec: 'avc1', protocol: 'm3u8_native' },
{ url: 'mp4_1080', height: 1080, vcodec: 'avc1', protocol: 'https', ext: 'mp4' },
];
const tallVideo = { id: 'v3', url: 'https://example.com/w3', meta: { formats: tall } };
ok('the height ceiling still comes first',
resolveStreamSource(tallVideo, { maxHeight: 480, cheapest: true }).url === 'hls480');
ok('every format stays reachable as fallback',
rankFormats(mixed, null, { cheapest: true }).length === mixed.length);
console.log(`\n${failed ? 'FAILED' : 'OK'}: ${failed} check(s) failed`);
process.exit(failed ? 1 : 0);