Compare commits
3 commits
b922e4236f
...
e65ba271fd
| Author | SHA1 | Date | |
|---|---|---|---|
| e65ba271fd | |||
| dfd109fc50 | |||
| d6a65ce8b7 |
5 changed files with 124 additions and 0 deletions
|
|
@ -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
|
||||
- [ ] 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
|
||||
confirmed here — no real device/session)
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
Loading…
Reference in a new issue