Compare commits

...

4 commits

Author SHA1 Message Date
c8655fc7c3 Merge remote-tracking branch 'origin/master' into feature/pwa
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 3s
Publier l'image Docker / publish (pull_request) Has been skipped
# Conflicts:
#	README.md
2026-09-16 15:51:34 +02:00
e65ba271fd Merge pull request 'Add picture-in-picture to the video player' (#8) from feature/picture-in-picture into master
All checks were successful
Publier l'image Docker / test (push) Successful in 23s
Publier l'image Docker / publish (push) Successful in 7s
2026-09-16 15:50:05 +02:00
dfd109fc50 Merge remote-tracking branch 'origin/master' into feature/picture-in-picture
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
# Conflicts:
#	README.md
#	docs/architecture.md
2026-09-16 10:16:16 +02:00
d6a65ce8b7 Add picture-in-picture to the video player
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
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>
2026-09-16 10:12:59 +02:00
5 changed files with 124 additions and 0 deletions

View file

@ -344,6 +344,10 @@ Then put it behind your existing reverse proxy like everything else
action — resolved server-side (one atomic operation, not one
request per track) the same way launching a playlist already
expands into everything it contains — see 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
- [x] Installable as a PWA (manifest + icons, standalone display,
no service worker — this app has nothing worth caching offline)
— see docs/architecture.md

View file

@ -366,6 +366,15 @@
.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
paused/torn down (see Player.ts's startCasting/cast.ts), so leaving
its last frame frozen with no explanation would just look stuck. */

View file

@ -127,6 +127,17 @@
<span id="ambientNotice" class="ambientNotice hide"></span>
</div>
<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>
</div>
</div>

View file

@ -196,6 +196,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement;
const burnInHint = root.querySelector('#burnInHint')!;
const castBtn = root.querySelector('#castBtn') as HTMLButtonElement;
const pipBtn = root.querySelector('#pipBtn') as HTMLButtonElement;
const fullscreenBtn = root.querySelector('#fullscreenBtn') as HTMLButtonElement;
const bottomBar = root.querySelector('#bottomBar') as HTMLElement;
const progressBar = root.querySelector('#progressBar') as HTMLElement;
@ -996,6 +997,44 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
});
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) {
itemTitleEl.textContent = msg.itemName || '';
hasNextQueued = !!msg.hasNext;

View file

@ -3343,6 +3343,67 @@ Authorization` Device/Client/Version header a real client would, only
`X-Emby-Token` - is genuinely unconfirmed. Worth watching the real
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).
## Queuing a whole album, or an artist's entire discography, in one action
Asked for directly: `views/album`'s and `views/artist`'s per-track/