Add picture-in-picture to the video player
Asked for directly, mobile in particular: a phone has no second window to put this app in the way a desktop might, so the standard browser Picture-in-Picture API - popping the video out into a small, OS-managed floating window - is the one way to keep watching while switching to another app. Standard requestPictureInPicture()/exitPictureInPicture(), not anything WebKit-prefixed - confirmed directly in a real Chromium build that pictureInPictureEnabled/requestPictureInPicture/ exitPictureInPicture are all present, which is what actually matters for Android Chrome, the mobile browser this was asked for. New #pipBtn, right next to #fullscreenBtn - both are "how is this displayed" toggles, so they share the same .fsBtn sizing, and a new .fsBtn[aria-pressed="true"] rule gives it the same "clearly on" orange treatment #castBtn already has (its own rule rather than combined onto .iconBtn, which disagrees with .fsBtn on padding/border-radius/ background). Feature-detected once at mount, not live the way #castBtn's own availability listener is - browser support for this doesn't appear/disappear mid-session the way a Chromecast on the network does. State (aria-pressed) syncs via the video's own enterpictureinpicture/ leavepictureinpicture events rather than the button's click handler alone, so it also reflects the browser's native PiP window being closed directly. No cleanup needed on navigation: browsers already close an active PiP window once its source video leaves the document, and every route change here already destroys and replaces <video> - the deliberate opposite of this app's own choice for casting, which keeps a Chromecast session alive after navigating away since it has an existence independent of the local video element. Verified: full typecheck/build (client only - no server involved). Confirmed directly in a real browser, not just type-checked: pictureInPictureEnabled is true, and running the exact show/hide + click-handling logic against a real (sourceless) video correctly shows the button and catches the resulting InvalidStateError from requestPictureInPicture() without it becoming an uncaught exception. Not verified: an actual floating PiP window on a real mobile browser playing a real Emby stream - no device/login reachable from this sandbox. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
b41437e23e
commit
d6a65ce8b7
5 changed files with 124 additions and 0 deletions
|
|
@ -340,6 +340,10 @@ Then put it behind your existing reverse proxy like everything else
|
||||||
dashboard sees watch-party streams the same way it sees a native
|
dashboard sees watch-party streams the same way it sees a native
|
||||||
client's — one session per member, under their own account — see
|
client's — one session per member, under their own account — see
|
||||||
docs/architecture.md
|
docs/architecture.md
|
||||||
|
- [x] Picture-in-picture for the video player (standard
|
||||||
|
`requestPictureInPicture`, confirmed present on Android Chrome) —
|
||||||
|
asked for directly for mobile, where there's no second window to
|
||||||
|
put this app in while switching apps — see docs/architecture.md
|
||||||
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
|
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
|
||||||
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
|
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
|
||||||
confirmed here — no real device/session)
|
confirmed here — no real device/session)
|
||||||
|
|
|
||||||
|
|
@ -366,6 +366,15 @@
|
||||||
|
|
||||||
.fsBtn:hover { background: rgba(255, 255, 255, .18); }
|
.fsBtn:hover { background: rgba(255, 255, 255, .18); }
|
||||||
|
|
||||||
|
/* #pipBtn while active - same "clearly-on" treatment as .iconBtn's own
|
||||||
|
[aria-pressed="true"] rule (#castBtn), just scoped to .fsBtn instead -
|
||||||
|
the two aren't combined onto one shared class since .iconBtn and
|
||||||
|
.fsBtn otherwise disagree on padding/border-radius/background. */
|
||||||
|
.fsBtn[aria-pressed="true"] {
|
||||||
|
color: #ff9800;
|
||||||
|
background: rgba(255, 152, 0, .18);
|
||||||
|
}
|
||||||
|
|
||||||
/* Replaces the video content while casting - the local <video> is
|
/* Replaces the video content while casting - the local <video> is
|
||||||
paused/torn down (see Player.ts's startCasting/cast.ts), so leaving
|
paused/torn down (see Player.ts's startCasting/cast.ts), so leaving
|
||||||
its last frame frozen with no explanation would just look stuck. */
|
its last frame frozen with no explanation would just look stuck. */
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,17 @@
|
||||||
<span id="ambientNotice" class="ambientNotice hide"></span>
|
<span id="ambientNotice" class="ambientNotice hide"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bottomBarZone right">
|
<div class="bottomBarZone right">
|
||||||
|
<!-- Hidden by default, shown only once Player.ts confirms
|
||||||
|
document.pictureInPictureEnabled - not every browser supports
|
||||||
|
this (see its own comment), and there's no live
|
||||||
|
"availability changed" event to react to the way the cast
|
||||||
|
button has, since this never changes mid-session. -->
|
||||||
|
<button id="pipBtn" class="fsBtn hide" title="Picture-in-picture" aria-label="Picture-in-picture" aria-pressed="false">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||||
|
<rect x="3" y="4" width="18" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"></rect>
|
||||||
|
<rect x="12" y="11" width="7" height="5" rx="1" fill="currentColor"></rect>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button id="fullscreenBtn" class="fsBtn" title="Plein écran" aria-label="Plein écran"></button>
|
<button id="fullscreenBtn" class="fsBtn" title="Plein écran" aria-label="Plein écran"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement;
|
const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement;
|
||||||
const burnInHint = root.querySelector('#burnInHint')!;
|
const burnInHint = root.querySelector('#burnInHint')!;
|
||||||
const castBtn = root.querySelector('#castBtn') as HTMLButtonElement;
|
const castBtn = root.querySelector('#castBtn') as HTMLButtonElement;
|
||||||
|
const pipBtn = root.querySelector('#pipBtn') as HTMLButtonElement;
|
||||||
const fullscreenBtn = root.querySelector('#fullscreenBtn') as HTMLButtonElement;
|
const fullscreenBtn = root.querySelector('#fullscreenBtn') as HTMLButtonElement;
|
||||||
const bottomBar = root.querySelector('#bottomBar') as HTMLElement;
|
const bottomBar = root.querySelector('#bottomBar') as HTMLElement;
|
||||||
const progressBar = root.querySelector('#progressBar') as HTMLElement;
|
const progressBar = root.querySelector('#progressBar') as HTMLElement;
|
||||||
|
|
@ -996,6 +997,44 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
});
|
});
|
||||||
updateFullscreenBtn();
|
updateFullscreenBtn();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picture-in-picture - asked for directly, mobile in particular: the
|
||||||
|
* one way to keep watching in a small floating window while switching
|
||||||
|
* to another app, since a phone has no second window/screen to put
|
||||||
|
* this app in the way a desktop might. Feature-detected once at mount,
|
||||||
|
* not live like #castBtn's own onCastAvailabilityChanged - unlike a
|
||||||
|
* Chromecast appearing/disappearing on the network mid-session,
|
||||||
|
* browser support for this is fixed for the lifetime of the page, so
|
||||||
|
* there's nothing to react to after this first check. video itself
|
||||||
|
* needs no new attribute for this to work - unlike disableremoteplayback
|
||||||
|
* (added specifically to suppress Chrome's own auto cast icon), there's
|
||||||
|
* no equivalent auto-injected native PiP control this app needs to
|
||||||
|
* fight with.
|
||||||
|
*/
|
||||||
|
if (document.pictureInPictureEnabled && !video.disablePictureInPicture) {
|
||||||
|
pipBtn.classList.remove('hide');
|
||||||
|
pipBtn.addEventListener('click', () => {
|
||||||
|
if (document.pictureInPictureElement) {
|
||||||
|
document.exitPictureInPicture().catch(() => {});
|
||||||
|
} else {
|
||||||
|
video.requestPictureInPicture().catch((err) => {
|
||||||
|
// Can genuinely fail even when supported (no user gesture in
|
||||||
|
// some browsers' stricter reading of that, video not yet
|
||||||
|
// ready) - same "try it, log it, don't crash anything else"
|
||||||
|
// instinct as every other best-effort browser API this app
|
||||||
|
// calls (video.play().catch, requestFullscreen().catch, ...).
|
||||||
|
console.error('[watchparty player] picture-in-picture failed', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Reflects state via the same [aria-pressed] treatment #castBtn
|
||||||
|
// uses (see Player.css's .fsBtn[aria-pressed="true"]) - covers the
|
||||||
|
// browser's own PiP window being closed directly (its native close
|
||||||
|
// button, or the OS), not just this button's own click.
|
||||||
|
video.addEventListener('enterpictureinpicture', () => pipBtn.setAttribute('aria-pressed', 'true'));
|
||||||
|
video.addEventListener('leavepictureinpicture', () => pipBtn.setAttribute('aria-pressed', 'false'));
|
||||||
|
}
|
||||||
|
|
||||||
async function onItemChanged(msg: ItemChangedMsg) {
|
async function onItemChanged(msg: ItemChangedMsg) {
|
||||||
itemTitleEl.textContent = msg.itemName || '';
|
itemTitleEl.textContent = msg.itemName || '';
|
||||||
hasNextQueued = !!msg.hasNext;
|
hasNextQueued = !!msg.hasNext;
|
||||||
|
|
|
||||||
|
|
@ -3343,6 +3343,67 @@ Authorization` Device/Client/Version header a real client would, only
|
||||||
`X-Emby-Token` - is genuinely unconfirmed. Worth watching the real
|
`X-Emby-Token` - is genuinely unconfirmed. Worth watching the real
|
||||||
dashboard the first time a party runs after this ships.
|
dashboard the first time a party runs after this ships.
|
||||||
|
|
||||||
|
## Picture-in-picture
|
||||||
|
|
||||||
|
Asked for directly, mobile in particular: a phone has no second window
|
||||||
|
to put this app in the way a desktop might, so the standard browser
|
||||||
|
Picture-in-Picture API - popping the `<video>` out into a small,
|
||||||
|
OS-managed floating window - is the one way to keep watching while
|
||||||
|
switching to another app. Standard `HTMLVideoElement
|
||||||
|
.requestPictureInPicture()`/`document.exitPictureInPicture()`, not the
|
||||||
|
Chromecast-adjacent `disableremoteplayback` trick or anything WebKit-
|
||||||
|
prefixed - confirmed directly in a real Chromium build that
|
||||||
|
`document.pictureInPictureEnabled`/`requestPictureInPicture`/
|
||||||
|
`exitPictureInPicture` are all present, which is what actually matters
|
||||||
|
for Android Chrome, the mobile browser this was asked for.
|
||||||
|
|
||||||
|
New `#pipBtn`, right next to `#fullscreenBtn` (`.bottomBarZone.right`)
|
||||||
|
- both are "how is this displayed" toggles, so they share the same
|
||||||
|
`.fsBtn` sizing, and a new `.fsBtn[aria-pressed="true"]` rule gives PiP
|
||||||
|
the identical "clearly on" orange treatment `#castBtn` already has
|
||||||
|
(kept as its own rule rather than combined onto `.iconBtn` - that class
|
||||||
|
and `.fsBtn` disagree on padding/border-radius/background, so
|
||||||
|
combining them risked an inconsistent size instead of a clean shared
|
||||||
|
look). Feature-detected once at mount (`document.pictureInPictureEnabled
|
||||||
|
&& !video.disablePictureInPicture`), not live the way `#castBtn`'s own
|
||||||
|
`onCastAvailabilityChanged` is - browser support for this doesn't
|
||||||
|
appear/disappear mid-session the way a Chromecast on the network does,
|
||||||
|
so there's nothing to react to after the first check. No new attribute
|
||||||
|
needed on `<video>` itself either - unlike `disableremoteplayback`
|
||||||
|
(added specifically to suppress Chrome's own auto-injected cast icon),
|
||||||
|
there's no equivalent native PiP affordance this app needs to
|
||||||
|
suppress.
|
||||||
|
|
||||||
|
State (`aria-pressed`) is kept in sync via the video's own
|
||||||
|
`enterpictureinpicture`/`leavepictureinpicture` events rather than the
|
||||||
|
button's own click handler alone - covers the browser's native PiP
|
||||||
|
window being closed directly (its own close control, or the OS), not
|
||||||
|
just a second click back in this app. Requires no cleanup on route
|
||||||
|
navigation: like every other per-mount element here, `<video>` itself
|
||||||
|
is destroyed and replaced by the next `root.innerHTML = html` (router.ts
|
||||||
|
runs this unconditionally on any navigation) - browsers already close
|
||||||
|
an active PiP window automatically once its source element leaves the
|
||||||
|
document, matching this app's very deliberate opposite choice for
|
||||||
|
casting (leaving an active Chromecast session running after
|
||||||
|
navigating away - see "Casting to a Chromecast" above): PiP has no
|
||||||
|
independent existence apart from the `<video>` element it mirrors, a
|
||||||
|
Chromecast session does.
|
||||||
|
|
||||||
|
Verified: full `typecheck`/`build` (client only - this is a pure
|
||||||
|
client-side feature, no server involved). Confirmed directly in a real
|
||||||
|
browser, not just type-checked: `document.pictureInPictureEnabled` is
|
||||||
|
`true` here, and running the exact show/hide + click-handling logic
|
||||||
|
against a real (sourceless) `<video>` correctly shows the button and
|
||||||
|
catches the resulting `InvalidStateError` from `requestPictureInPicture()`
|
||||||
|
without it becoming an uncaught exception - the same rejection a real
|
||||||
|
video would throw for a different reason (already in PiP, or the
|
||||||
|
document not focused), confirming the error handling path itself
|
||||||
|
rather than just assuming it. **Not verified**: an actual floating PiP
|
||||||
|
window on a real mobile browser, playing a real Emby stream - no
|
||||||
|
device/login reachable from this sandbox, same standing gap as this
|
||||||
|
project's other browser-native integrations (casting, the intro-skip
|
||||||
|
buttons).
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
- Playback always goes through `hls.js` against whatever Emby's
|
- Playback always goes through `hls.js` against whatever Emby's
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue