openvibers/openvibe.live Commits

PiP anywhere: drag it over the page, tuck it aside, and the mark while it waits (#806)

OP OpenVibers committed 2 days ago · df65e92dda · 8 files, +645 −289

- A PIP lives in the player or floats over the page (fixed, it stays on screen while the page scrolls). It follows the
  finger or mouse with a transform each frame and lands with a spring; dropped over the player it docks, anywhere
  else it floats; a flick carries on; near an edge it snaps.
- Thrown past a side, or with its » button, it tucks into that side: a tab with its picture still going, a tap brings
  it back (no hide and no restore pill any more).
- Waiting is the mark at work over the last picture, never a black box: connecting, no frames for 4 s, a dropped
  connection (the mark asleep, "trying again"). It reconnects by itself after 15 s without frames and on a backoff
  when the connection drops; a source restarted reconnects in place; one that ended leaves with an animation.
- Its place is kept per slot (not per session) in the browser and, signed in, in the account (GET/PATCH
  /api/me/layout, newest wins; a site without the route keeps the browser's copy). F pops it out of the player or
  back, T tucks, ↺ puts it back where the streamer put it.
- One stylesheet (css/features/pip.css, loaded with the player): a glass bar with round buttons, a corner grip, the
  player glows while a PIP is held over it; on a small PIP the name leaves the bar and a wait is the mark alone.
- docs/broadcasting.md says what viewers can do with a PIP.

Changes

ddiff --git a/docs/broadcasting.md b/docs/broadcasting.md
iindex 2dfeb531..595b5dcb 100644
---- a/docs/broadcasting.md
++++ b/docs/broadcasting.md
@@@ -101,10 +101,20 @@ camera goes out**):
- It goes live on one of your slots: the first of your idle slots already listed as a PIP here, else a new
"<title> camera" slot, added at the corner and size you chose.
- It gets its own VOD.
--- Viewers can move it or hide it.
++- Viewers can move it or tuck it aside (below).
- It plays over the screen share's VOD where it was.
- It ends with the screen share, or when you turn the camera off.
++**What viewers can do with a PIP** (phone and desktop alike):
++- Drag it anywhere: dropped over the player it sits in the player, anywhere else it floats over the page and stays on
++ screen while they scroll. A flick carries it on; near an edge it snaps to it.
++- Throw it past a side (or press its »): it tucks into that side, a tab with its picture still going; a tap brings it back.
++- Size it from its corner, with two fingers, or with a double tap (small and large); mute it; put it back where you put
++ it (↺).
++- While it connects, or when its picture stops, it shows the OpenVibe mark at work over its last frame and tries again
++ by itself.
++- Where they put it is kept per slot in their browser and, signed in, in their account, so it is there next time.
++
Restreams to Twitch, YouTube and the like get the screen only. Choose **Drawn into the screen share** to paint the
camera into the picture as before, so restreams show it too.
ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex f98b5918..8baef3a6 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -1256,41 +1256,6 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
.ai-tl-subtabs { display: flex; gap: 6px; margin: 14px 0 16px; border-bottom: 1px solid var(--border, color-mix(in srgb, var(--text-primary, #fff) 8%, transparent)); }
.ai-tl-subtab { font-size: 0.9rem; font-weight: 700; color: var(--text-muted); background: none; border: none; border-bottom: 2px solid transparent; padding: 8px 14px; cursor: pointer; display: inline-flex; align-items: center; gap: 7px; margin-bottom: -1px; transition: color var(--ov-dur-2, .15s); }
.ai-tl-pane--hidden { display: none; }
--.ov-pip:hover { border-color: rgba(255, 255, 255, .38); }
--.ov-pip.is-dragging { cursor: grabbing; box-shadow: 0 10px 30px rgba(0, 0, 0, .7); }
--.ov-pip.is-dragging, .ov-pip.is-resizing { user-select: none; }
--.ov-pip.is-hidden { display: none; }
--
--/* The box takes the source's own shape (stream-pip.js sets aspect-ratio from the video), so contain shows it whole: a
-- portrait camera is never cut to 16:9 (2026-11-07). */
--.ov-pip-video { width: 100%; height: 100%; object-fit: contain; display: block; background: #000; }
--.ov-pip:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
--.ov-pip.is-portrait .ov-pip-bar { padding-inline: 4px; }
--.ov-pip:hover .ov-pip-bar, .ov-pip.is-dragging .ov-pip-bar { opacity: 1; pointer-events: auto; }
--.ov-pip-title {
-- font-size: .72rem; color: #fff; font-weight: 600;
-- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
-- text-shadow: 0 1px 3px rgba(0,0,0,.8);
--}
--.ov-pip-actions { display: flex; gap: 3px; flex-shrink: 0; }
--.ov-pip:hover .ov-pip-resize { opacity: 1; }
--.ov-pip.is-offline .ov-pip-offline { display: flex; }
--/* The bar stays above the offline layer and shows while offline, so a PIP that cannot play can still be hidden (the
-- layer, drawn after the bar over the whole PIP, used to cover it). */
--.ov-pip-bar { z-index: 2; }
--.ov-pip-offline { z-index: 1; }
--.ov-pip.is-offline .ov-pip-bar { opacity: 1; pointer-events: auto; }
--.ov-pip-restore:hover { background: rgba(0,0,0,.8); }
--/* Several PIPs (stream_pips): each hidden one's "Show …" pill, in one row in the corner; a PIP's name opens its channel. */
--.ov-pip-restores { position: absolute; right: 12px; bottom: 12px; z-index: 25; display: flex; flex-wrap: wrap-reverse; justify-content: flex-end; gap: 6px; max-width: calc(100% - 24px); }
--.ov-pip-restores .ov-pip-restore { position: static; }
--.ov-pip-title a { color: inherit; text-decoration: none; }
--.ov-pip-title a:hover, .ov-pip-title a:focus-visible { text-decoration: underline; }
--
--@media (max-width: 640px) {
-- .ov-pip-resize { width: 26px; height: 26px; opacity: .8; }
-- .ov-pip-bar { opacity: 1; pointer-events: auto; }
--}
.ai-tl-tr-bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin: 0 0 8px; padding-bottom: 8px; border-bottom: 1px solid var(--border, color-mix(in srgb, var(--text-primary, #fff) 7%, transparent)); }
.ai-tl-tr-filter { display: inline-flex; gap: 4px; }
.ai-tl-tr-filter button { padding: 4px 10px; border-radius: 999px; border: 1px solid var(--border, color-mix(in srgb, var(--text-primary, #fff) 10%, transparent)); background: transparent; color: var(--text-secondary); font-size: 0.75rem; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; }
ddiff --git a/public/css/features/pip.css b/public/css/features/pip.css
nnew file mode 100644
iindex 00000000..2966334e
---- /dev/null
++++ b/public/css/features/pip.css
@@@ -0,0 +1,146 @@
++/* ── Picture-in-picture (public/js/stream-pip.js) ─────────────────────────────────────────────────────────────────
++ One stylesheet for every PIP (2026-11-11, owner: "move it anywhere … more solid feeling and more responsive … use the
++ OV animations"). A PIP sits in the player (absolute, its place in fractions of the player) or over the page
++ (.is-floating: fixed, fractions of the window, above the page and below the navbar's menus); while it is moved it is
++ lifted (.is-lifted: fixed, moved by a transform each frame), and it lands with a spring. Tucked into a side, only its
++ tab stays on screen (the `translate` property; `transform` is the landing's). Its waits are the mark at work. */
++
++.ov-pip {
++ container: ovpip / inline-size;
++ position: absolute;
++ z-index: 25; /* above the video, below the NSFW gate (30) */
++ aspect-ratio: 16 / 9;
++ border-radius: 14px;
++ overflow: hidden;
++ background: #05070c;
++ border: 1px solid rgba(255, 255, 255, .16);
++ box-shadow: 0 1px 0 rgba(255, 255, 255, .06) inset, 0 10px 28px -8px rgba(0, 0, 0, .7), 0 2px 6px rgba(0, 0, 0, .35);
++ cursor: grab;
++ touch-action: none; /* pointer events drive the move, the pinch and the size */
++ -webkit-user-select: none; user-select: none;
++ -webkit-tap-highlight-color: transparent;
++ contain: layout paint;
++ transition: box-shadow var(--ov-dur-2, .15s) var(--ov-ease-out, ease), border-color var(--ov-dur-2, .15s) var(--ov-ease-out, ease),
++ translate var(--ov-dur-3, .3s) var(--ov-ease-spring, cubic-bezier(.2, 1.25, .3, 1)), opacity var(--ov-dur-2, .2s);
++}
++.ov-pip.is-floating { position: fixed; z-index: 900; }
++.ov-pip.is-lifted { position: fixed; z-index: 905; will-change: transform; transition: none; }
++.ov-pip:hover { border-color: rgba(255, 255, 255, .3); }
++.ov-pip.is-dragging {
++ cursor: grabbing; border-color: color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent);
++ box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent), 0 26px 50px -14px rgba(0, 0, 0, .8), 0 6px 14px rgba(0, 0, 0, .4);
++}
++.ov-pip:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++
++/* Coming and going. */
++@keyframes ovPipIn { from { opacity: 0; scale: .9; } }
++@keyframes ovPipOut { to { opacity: 0; scale: .9; } }
++.ov-pip.is-entering { animation: ovPipIn var(--ov-dur-3, .32s) var(--ov-ease-spring, cubic-bezier(.2, 1.25, .3, 1)); }
++.ov-pip.is-leaving { animation: ovPipOut .24s var(--ov-ease-in, ease-in) forwards; pointer-events: none; }
++
++/* The box takes the source's own shape (stream-pip.js sets aspect-ratio from the video), so contain shows it whole: a
++ portrait camera is never cut to 16:9 (2026-11-07). */
++.ov-pip-video { width: 100%; height: 100%; object-fit: contain; display: block; background: #000; pointer-events: none; }
++
++/* The bar: its name and three round buttons, on a glass strip that comes up on hover, focus, a tap or a move. */
++.ov-pip-bar {
++ position: absolute; inset: 0 0 auto 0; z-index: 3;
++ display: flex; align-items: center; justify-content: space-between; gap: 6px;
++ padding: 6px 6px 14px 9px;
++ background: linear-gradient(180deg, rgba(0, 0, 0, .66), rgba(0, 0, 0, .28) 62%, transparent);
++ opacity: 0; translate: 0 -4px;
++ transition: opacity var(--ov-dur-2, .15s) var(--ov-ease-out, ease), translate var(--ov-dur-2, .15s) var(--ov-ease-out, ease);
++ pointer-events: none;
++}
++.ov-pip:hover .ov-pip-bar, .ov-pip:focus-within .ov-pip-bar, .ov-pip.is-active .ov-pip-bar, .ov-pip.is-dragging .ov-pip-bar,
++.ov-pip:not([data-state="live"]) .ov-pip-bar { opacity: 1; translate: 0 0; pointer-events: auto; }
++.ov-pip-title {
++ min-width: 0; display: inline-flex; align-items: center; gap: 5px;
++ font-size: .72rem; color: #fff; font-weight: 700; letter-spacing: .01em;
++ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
++ text-shadow: 0 1px 3px rgba(0, 0, 0, .8);
++}
++.ov-pip-title i { opacity: .85; font-size: .66rem; }
++.ov-pip-title a { color: inherit; text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
++.ov-pip-title a:hover, .ov-pip-title a:focus-visible { text-decoration: underline; }
++.ov-pip.is-portrait .ov-pip-bar { padding-inline: 5px; }
++.ov-pip-actions { display: flex; gap: 4px; flex-shrink: 0; }
++.ov-pip-actions button {
++ width: 26px; height: 26px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, .16); cursor: pointer;
++ background: rgba(10, 12, 18, .55); color: #fff; font-size: .68rem; display: grid; place-items: center;
++ -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
++ transition: background var(--ov-dur-1, .12s) var(--ov-ease-out, ease), transform var(--ov-dur-1, .12s) var(--ov-ease-spring, ease);
++}
++.ov-pip-actions button:hover { background: rgba(255, 255, 255, .22); transform: translateY(-1px); }
++.ov-pip-actions button:active { transform: scale(.92); }
++.ov-pip-actions button[aria-pressed="true"] { background: var(--accent, #3b82f6); border-color: transparent; }
++
++/* The corner that sizes it: a soft grip that shows on hover and is always there to a finger. */
++.ov-pip-resize {
++ position: absolute; right: 0; bottom: 0; z-index: 3;
++ width: 22px; height: 22px; cursor: nwse-resize; touch-action: none;
++ background:
++ linear-gradient(135deg, transparent 52%, rgba(255, 255, 255, .7) 52%, rgba(255, 255, 255, .7) 60%,
++ transparent 60%, transparent 70%, rgba(255, 255, 255, .7) 70%, rgba(255, 255, 255, .7) 78%, transparent 78%);
++ opacity: 0; transition: opacity var(--ov-dur-2, .15s) var(--ov-ease-out, ease);
++}
++.ov-pip:hover .ov-pip-resize, .ov-pip.is-active .ov-pip-resize, .ov-pip.is-resizing .ov-pip-resize { opacity: 1; }
++
++/* Waiting: the mark at work over its last picture, never a black box. Connecting and waiting for frames are "busy";
++ a dropped connection is the mark asleep while it tries again. */
++.ov-pip-state {
++ position: absolute; inset: 0; z-index: 2;
++ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; padding: 10px;
++ background: radial-gradient(ellipse at center, rgba(8, 10, 16, .55), rgba(8, 10, 16, .78));
++ -webkit-backdrop-filter: blur(6px) saturate(.8); backdrop-filter: blur(6px) saturate(.8);
++ color: rgba(255, 255, 255, .86); font-size: .72rem; font-weight: 600; text-align: center; line-height: 1.3;
++ opacity: 0; pointer-events: none; transition: opacity var(--ov-dur-3, .3s) var(--ov-ease-out, ease);
++}
++.ov-pip:not([data-state="live"]) .ov-pip-state { opacity: 1; }
++.ov-pip-state-t { max-width: 100%; overflow-wrap: anywhere; text-shadow: 0 1px 3px rgba(0, 0, 0, .6); }
++.ov-pip[data-state="offline"] .ov-pip-state-t { color: color-mix(in srgb, #fbbf24 70%, #fff); }
++
++/* Tucked into a side: only the tab and an edge of its picture stay on screen; a tap brings it back. */
++.ov-pip.is-tucked-left { translate: calc(-100% + 30px) 0; }
++.ov-pip.is-tucked-right { translate: calc(100% - 30px) 0; }
++.ov-pip.is-tucked .ov-pip-bar, .ov-pip.is-tucked .ov-pip-resize, .ov-pip.is-tucked .ov-pip-state-t { opacity: 0; pointer-events: none; }
++.ov-pip.is-tucked .ov-pip-video { filter: brightness(.75); }
++.ov-pip-tab {
++ position: absolute; top: 0; bottom: 0; z-index: 4; width: 30px; display: none; place-items: center; padding: 0; cursor: pointer;
++ border: 0; color: #fff; font-size: .85rem;
++ background: linear-gradient(90deg, rgba(0, 0, 0, .55), rgba(0, 0, 0, .25));
++ -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
++}
++.ov-pip.is-tucked .ov-pip-tab { display: grid; }
++.ov-pip.is-tucked-left .ov-pip-tab { right: 0; }
++.ov-pip.is-tucked-right .ov-pip-tab { left: 0; background: linear-gradient(270deg, rgba(0, 0, 0, .55), rgba(0, 0, 0, .25)); }
++.ov-pip-tab-l, .ov-pip-tab-r { display: none; }
++.ov-pip.is-tucked-right .ov-pip-tab-l, .ov-pip.is-tucked-left .ov-pip-tab-r { display: grid; place-items: center; }
++
++/* The player glows while a PIP is held over it: let go and it docks there. */
++.video-container.ov-pip-dock-hint { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent); }
++
++/* A small PIP (a phone's 150 px): the buttons keep the bar, the name goes (it is on the PIP's label and its wait), and a
++ wait is the mark alone. */
++@container ovpip (max-width: 230px) {
++ .ov-pip-title { display: none; }
++ .ov-pip-bar { justify-content: flex-end; padding: 5px 5px 12px; }
++ .ov-pip-state-t { display: none; }
++ /* Its wait keeps the whole picture: the bar comes up on a touch or a hover, not by itself. */
++ .ov-pip:not([data-state="live"]):not(:hover):not(.is-active):not(:focus-within) .ov-pip-bar { opacity: 0; pointer-events: none; }
++}
++
++/* Touch: the controls are always reachable (no hover) and big enough for a finger. */
++@media (pointer: coarse) {
++ .ov-pip-actions button { width: 32px; height: 32px; font-size: .78rem; }
++ .ov-pip-resize { width: 30px; height: 30px; opacity: .85; }
++}
++@media (max-width: 640px) {
++ .ov-pip { border-radius: 12px; }
++}
++
++/* Motion off: no spring and no fade, it is simply where it goes. */
++html[data-ov-motion="off"] .ov-pip, html[data-ov-motion="off"] .ov-pip-bar { transition: none; animation: none; }
++@media (prefers-reduced-motion: reduce) {
++ html:not([data-ov-motion="on"]) .ov-pip { animation: none; }
++}
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 870f76b2..5113017f 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -9363,68 +9363,6 @@ html[data-theme-mode="light"] .hero h1 { background-image: linear-gradient(90deg
.paste-md-body h2.md-h { font-size: 1.25rem; }
}
--/* ── Picture-in-picture camera overlay (stream-pip.js) ──────────────
-- Positioned in FRACTIONS of .video-container so the overlay keeps its relative
-- place at any window size and inside fullscreen, where the container is the
-- fullscreen element and these percentages still resolve against it. */
--
--.ov-pip {
-- position: absolute;
-- z-index: 25; /* above the video, below the NSFW gate (30) */
-- aspect-ratio: 16 / 9;
-- border-radius: 10px;
-- overflow: hidden;
-- background: #000;
-- border: 1px solid rgba(255, 255, 255, .22);
-- box-shadow: 0 6px 22px rgba(0, 0, 0, .55);
-- cursor: grab;
-- touch-action: none; /* pointer events drive drag/resize ourselves */
-- transition: box-shadow var(--ov-dur-2, .15s) var(--ov-ease-out, ease), border-color var(--ov-dur-2, .15s) var(--ov-ease-out, ease);
--}
--
--.ov-pip-bar {
-- position: absolute; inset: 0 0 auto 0;
-- display: flex; align-items: center; justify-content: space-between; gap: 6px;
-- padding: 5px 7px;
-- background: linear-gradient(180deg, rgba(0,0,0,.72), rgba(0,0,0,0));
-- opacity: 0; transition: opacity var(--ov-dur-2, .15s) var(--ov-ease-out, ease);
-- pointer-events: none;
--}
--.ov-pip-title i { opacity: .85; margin-right: 4px; }
--.ov-pip-actions button {
-- background: rgba(0,0,0,.45); border: none; color: #fff;
-- width: 22px; height: 22px; border-radius: 5px; cursor: pointer;
-- font-size: .68rem; display: grid; place-items: center;
-- transition: background var(--ov-dur-1, .12s) var(--ov-ease-out, ease);
--}
--.ov-pip-actions button:hover { background: rgba(255,255,255,.24); }
--
--.ov-pip-resize {
-- position: absolute; right: 0; bottom: 0;
-- width: 18px; height: 18px; cursor: nwse-resize;
-- background:
-- linear-gradient(135deg, transparent 50%, rgba(255,255,255,.55) 50%, rgba(255,255,255,.55) 62%,
-- transparent 62%, transparent 74%, rgba(255,255,255,.55) 74%, rgba(255,255,255,.55) 86%, transparent 86%);
-- opacity: 0; transition: opacity var(--ov-dur-2, .15s) var(--ov-ease-out, ease);
--}
--
--.ov-pip-offline {
-- position: absolute; inset: 0;
-- display: none; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
-- background: rgba(10,12,20,.9); color: var(--text-muted, #9aa3b2); font-size: .72rem;
--}
--.ov-pip-offline i { font-size: 1.1rem; opacity: .7; }
--
--/* Shown in place of the overlay once the viewer hides it, so it is recoverable. */
--.ov-pip-restore {
-- position: absolute; right: 12px; bottom: 12px; z-index: 25;
-- display: flex; align-items: center; gap: 6px;
-- padding: 6px 11px; border-radius: 999px; cursor: pointer;
-- background: rgba(0,0,0,.62); border: 1px solid rgba(255,255,255,.18);
-- color: #fff; font-size: .74rem;
-- backdrop-filter: blur(3px);
--}
--
/* ── AI Chat Viewers v3 panel ─────────────────────────────── */
.aiv-topline { display:flex; align-items:center; gap:16px; flex-wrap:wrap; margin-bottom:8px; }
.aiv-status { font-size:0.75rem; font-weight:700; padding:3px 10px; border-radius:999px; background:var(--bg-tertiary); color:var(--text-secondary); }
ddiff --git a/public/features.json b/public/features.json
iindex a76feba0..0be0772a 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -81,6 +81,9 @@
"deps": [
"clipJobs"
],
++ "css": [
++ "/css/features/pip.css"
++ ],
"js": [
"/shared/loading.js",
"/js/player-shape.js",
ddiff --git a/public/js/stream-pip.js b/public/js/stream-pip.js
iindex 2051fb4b..705857c2 100644
---- a/public/js/stream-pip.js
++++ b/public/js/stream-pip.js
@@@ -9,34 +9,81 @@
The server sends every PIP with the stream (`pip_overlays`: source slot, its live stream or null, the streamer's
default place and sound). Only live ones are drawn; every 30 s the list is asked for again
-- (GET /api/streams/:id/pips), so one that goes live appears and one that ends goes. Geometry is kept as FRACTIONS
-- of the player box, remembered per stream and per PIP, so a viewer who drags one out of the way finds it there next
-- time. A PIP that cannot play reads as "offline" and never stops the stream underneath.
--
-- Shape (2026-11-07, Maticus: "portrait is displayed as cropped"): the box takes the source's own proportions from the
-- video (and follows a phone that turns), so a portrait camera is shown whole, never cut to 16:9. `w` is the width a
-- 16:9 PIP would have; any other shape gets the same AREA, and never more than 85% of the player's height. Dropped near
-- an edge it snaps to that edge; a double click (or tap) toggles small and large; two fingers pinch it; with focus,
-- the arrows move it, + and − size it, M mutes and H hides.
++ (GET /api/streams/:id/pips), so one that goes live appears and one that ends goes.
++
++ Anywhere (owner, 2026-11-11: "move it anywhere … on top of shit … more solid feeling and more responsive"):
++ - a PIP lives in the player (docked) or over the page (floating, position: fixed, it stays on screen while the page
++ scrolls); it follows the finger or the mouse with a transform each frame and lands with a spring (FLIP), dropped
++ over the player it docks, anywhere else it floats; a flick carries on the way it was thrown;
++ - thrown past the side of the screen (or of the player) it tucks into that side, a tab with its picture still going
++ at the edge, and a tap brings it out ("get out of the way" without hiding it);
++ - it is never smaller than a 150 px wide 16:9 box on a phone; two fingers pinch it, the corner sizes it, a double
++ tap (or click) toggles small and large; with focus the arrows move it, + and − size it, M mutes, T tucks, F pops it
++ out of the player or back in, Home puts it back where the streamer put it.
++ Waiting (owner: "this black thing that's just stuck there … use the OV animations"): until its first frame, and
++ whenever its frames stop for 4 s, it shows the mark at work over its last picture, and tries again by itself after
++ 15 s and on a backoff when its connection drops (2, 4, 8, 15, 30 s); a source that ended leaves with an animation.
++
++ Its place (the source's own shape: a portrait camera is shown whole, `w` is the width a 16:9 PIP would have and any
++ other shape gets the same area, never more than 85% of its frame's height) is kept as FRACTIONS of its frame (the
++ player, or the window when floating), per slot (not per session, so it is there next time the streamer is live) and
++ per PIP: in this browser at once, and in the person's account when they are signed in (GET/PUT /api/me/layout,
++ newest wins), so another device opens with it where they left it.
─────────────────────────────────────────────────────────────── */
(function () {
'use strict';
-- const LS_KEY = (streamId, sourceMsid) => `ov-pip-geom-${streamId}-${sourceMsid}`;
-- const LS_OLD = (streamId) => `ov-pip-geom-${streamId}`; // the single PIP before 0021: the first PIP still reads it
++ const LS_KEY = (slot, sourceMsid) => `ov-pip-${slot}-${sourceMsid}`;
++ const LS_V1 = (streamId, sourceMsid) => `ov-pip-geom-${streamId}-${sourceMsid}`; // per session, before 2026-11-11
const MIN_W = 0.08, MAX_W = 0.60;
const POLL_MS = 30000;
const BASE = 16 / 9; // the shape `w` is measured for
-- const MAX_H = 0.85; // of the player's height, whatever the shape
++ const MAX_H = 0.85; // of the frame's height, whatever the shape
const SNAP = 0.04; // within this fraction of an edge, a dropped PIP snaps to it
const EDGE_PX = 12; // the margin it snaps to
-- const BIG_W = 0.5; // a double click's large size
-- const MIN_PX = 150; // the smallest 16:9 width on a phone-sized player
--
-- let host = null; // { streamId, container, overlays: Map(source_msid → overlay), timer, gen }
++ const BIG_W = 0.5; // a double tap's large size
++ const MIN_PX = 150; // the smallest 16:9 width on a phone-sized frame
++ const TAB_PX = 30; // what stays on screen of a tucked PIP
++ const DRAG_PX = 4; // a press that moves less is a tap
++ const STALL_MS = 4000; // no new frame for this long: show the wait
++ const RETRY_MS = 15000; // still nothing: connect again
++ const BACKOFF = [2000, 4000, 8000, 15000, 30000];
++ const LAND_MS = 300;
++
++ let host = null; // { streamId, slot, container, overlays: Map(source_msid → overlay), timer, gen, ro }
let gen = 0;
++ const reduced = () => {
++ const m = document.documentElement && document.documentElement.getAttribute('data-ov-motion');
++ if (m === 'off') return true;
++ try { return m !== 'on' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; }
++ };
++
++ /* ── the account copy (GET/PUT /api/me/layout; a site without it keeps the browser's copy only) ───────────── */
++
++ const account = {
++ data: null, loading: null, pending: {}, timer: 0, off: false,
++ signedIn() { return typeof currentUser !== 'undefined' && !!currentUser; },
++ load() {
++ if (this.off || !this.signedIn() || typeof api !== 'function') return Promise.resolve(null);
++ if (!this.loading) this.loading = api('/me/layout').then((d) => { this.data = d && typeof d === 'object' ? d : {}; return this.data; })
++ .catch((e) => { if (e && (e.status === 404 || e.status === 401)) this.off = true; return null; });
++ return this.loading;
++ },
++ get(key) { return (this.data && this.data.pip && this.data.pip[key]) || null; },
++ put(key, value) {
++ if (this.off || !this.signedIn() || typeof api !== 'function') return;
++ this.pending[key] = value;
++ if (this.data) { this.data.pip = this.data.pip || {}; this.data.pip[key] = value; }
++ clearTimeout(this.timer);
++ this.timer = setTimeout(() => {
++ const pip = this.pending; this.pending = {};
++ api('/me/layout', { method: 'PATCH', body: { pip } }).catch((e) => { if (e && e.status === 404) this.off = true; });
++ }, 1500);
++ },
++ };
++
/* ── geometry ─────────────────────────────────────────────── */
function clamp(v, lo, hi, dflt) {
@@@ -45,54 +92,91 @@
return Math.min(hi, Math.max(lo, n));
}
-- function loadGeom(streamId, pip, first) {
++ function defaultsOf(pip) {
const d = pip.defaults || {};
-- const fallback = {
-- x: clamp(d.x, 0, 1, 0.72), y: clamp(d.y, 0, 1, 0.70), w: clamp(d.w, MIN_W, MAX_W, 0.25),
-- // The streamer's choice; by default muted: the stream underneath already carries the voice, and two live
-- // audio tracks would double every word.
-- hidden: false, muted: pip.muted !== false,
++ // The streamer's choice; by default muted: the stream underneath already carries the voice, and two live audio
++ // tracks would double every word.
++ return { x: clamp(d.x, 0, 1, 0.72), y: clamp(d.y, 0, 1, 0.70), w: clamp(d.w, MIN_W, MAX_W, 0.25), mode: 'dock', tuck: null, muted: pip.muted !== false, at: 0 };
++ }
++
++ function shapeGeom(raw, fallback) {
++ if (!raw || typeof raw !== 'object') return null;
++ return {
++ x: clamp(raw.x, 0, 1, fallback.x), y: clamp(raw.y, 0, 1, fallback.y), w: clamp(raw.w, MIN_W, MAX_W, fallback.w),
++ mode: raw.mode === 'float' ? 'float' : 'dock',
++ tuck: raw.tuck === 'left' || raw.tuck === 'right' ? raw.tuck : (raw.hidden ? 'right' : null),
++ muted: typeof raw.muted === 'boolean' ? raw.muted : fallback.muted,
++ at: Number(raw.at) || 0,
};
++ }
++
++ /** This browser's copy, else the per-session one it had before, else the streamer's place; the account's if newer. */
++ function loadGeom(slot, streamId, pip) {
++ const fallback = defaultsOf(pip);
++ let local = null;
try {
-- let raw = JSON.parse(localStorage.getItem(LS_KEY(streamId, pip.source_msid)) || 'null');
-- if (!raw && first) raw = JSON.parse(localStorage.getItem(LS_OLD(streamId)) || 'null');
-- if (!raw) return fallback;
-- return {
-- x: clamp(raw.x, 0, 1, fallback.x), y: clamp(raw.y, 0, 1, fallback.y), w: clamp(raw.w, MIN_W, MAX_W, fallback.w),
-- hidden: !!raw.hidden, muted: typeof raw.muted === 'boolean' ? raw.muted : fallback.muted,
-- };
-- } catch { return fallback; }
++ local = shapeGeom(JSON.parse(localStorage.getItem(LS_KEY(slot, pip.source_msid)) || 'null'), fallback)
++ || shapeGeom(JSON.parse(localStorage.getItem(LS_V1(streamId, pip.source_msid)) || 'null'), fallback);
++ } catch { local = null; }
++ const remote = shapeGeom(account.get(`${slot}:${pip.source_msid}`), fallback);
++ if (remote && (!local || remote.at > local.at)) return remote;
++ return local || fallback;
}
function saveGeom(o) {
-- try { localStorage.setItem(LS_KEY(o.hostId, o.pip.source_msid), JSON.stringify(o.geom)); } catch { /* */ }
++ o.geom.at = Date.now();
++ try { localStorage.setItem(LS_KEY(o.slot, o.pip.source_msid), JSON.stringify(o.geom)); } catch { /* */ }
++ account.put(`${o.slot}:${o.pip.source_msid}`, o.geom);
++ try { window.dispatchEvent(new CustomEvent('ov:pip', { detail: { slot: o.slot, source: o.pip.source_msid, geom: { ...o.geom } } })); } catch { /* */ }
++ }
++
++ /** The PIP's frame: the player when docked, the window when floating (fullscreen: always the player). */
++ function frameRect(o) {
++ if (o.geom && o.geom.mode === 'float' && !inFullscreen(o)) {
++ const w = window.innerWidth || 0, h = window.innerHeight || 0;
++ return { left: 0, top: 0, width: w, height: h };
++ }
++ return o.container ? o.container.getBoundingClientRect() : { left: 0, top: 0, width: 0, height: 0 };
++ }
++
++ function inFullscreen(o) {
++ const fs = typeof document !== 'undefined' && document.fullscreenElement;
++ return !!(fs && o && o.container && (fs === o.container || fs.contains(o.container)));
}
-- /** The box as fractions of the player: width, height and the place it may go, for the source's own shape. */
++ /** The box as fractions of its frame: width, height and the place it may go, for the source's own shape. */
function boxOf(o) {
const a = o.aspect || BASE;
-- const r = o.container ? o.container.getBoundingClientRect() : { width: 0, height: 0 };
-- // On a phone-sized player a quarter of the width is a postage stamp: never smaller than a 150 px wide 16:9 box
++ const r = frameRect(o);
++ // On a phone-sized frame a quarter of the width is a postage stamp: never smaller than a 150 px wide 16:9 box
// (the saved size is unchanged, so a desktop shows it as saved).
const w = r.width && r.width < 640 ? Math.max(o.geom.w, Math.min(MAX_W, MIN_PX / r.width)) : o.geom.w;
let wf = w * Math.sqrt(a / BASE); // the same area as a 16:9 PIP of width w
if (r.width && r.height && (wf * r.width) / a > MAX_H * r.height) wf = (MAX_H * r.height * a) / r.width;
wf = Math.min(wf, 1);
const hf = r.width && r.height ? (wf * r.width) / a / r.height : wf / a;
-- return { a, wf, hf, rw: r.width, rh: r.height };
++ return { a, wf, hf, rw: r.width, rh: r.height, rl: r.left || 0, rt: r.top || 0 };
}
function applyGeom(o) {
-- const { x, y, hidden } = o.geom;
++ const { x, y, tuck } = o.geom;
const b = boxOf(o);
-- // Shown inside the player even when a turn of the phone or a smaller window would push it out; the saved
-- // place is not changed, so it comes back when there is room again.
-- o.el.style.left = `${Math.min(Math.max(0, x), Math.max(0, 1 - b.wf)) * 100}%`;
++ const floating = o.geom.mode === 'float' && !inFullscreen(o);
++ if (o.el.classList.toggle) {
++ o.el.classList.toggle('is-floating', floating);
++ o.el.classList.toggle('is-tucked', !!tuck);
++ o.el.classList.toggle('is-tucked-left', tuck === 'left');
++ o.el.classList.toggle('is-tucked-right', tuck === 'right');
++ o.el.classList.toggle('is-portrait', b.a < 1);
++ }
++ // Shown inside its frame even when a turn of the phone or a smaller window would push it out; the saved place
++ // is not changed, so it comes back when there is room again. A tucked one sits at its side (CSS `translate`
++ // leaves only its tab on screen; `transform` stays free for the landing).
++ const left = tuck === 'left' ? 0 : tuck === 'right' ? Math.max(0, 1 - b.wf) : Math.min(Math.max(0, x), Math.max(0, 1 - b.wf));
++ o.el.style.left = `${left * 100}%`;
o.el.style.top = `${Math.min(Math.max(0, y), Math.max(0, 1 - b.hf)) * 100}%`;
o.el.style.width = `${b.wf * 100}%`;
o.el.style.aspectRatio = String(b.a);
-- o.el.classList.toggle('is-portrait', b.a < 1);
-- o.el.classList.toggle('is-hidden', !!hidden);
}
/** After a drop: keep it inside, and snap it to an edge it was dropped close to. */
@@@ -122,8 +206,42 @@
}
function resetGeom(o) {
-- const d = o.pip.defaults || {};
-- o.geom.x = clamp(d.x, 0, 1, 0.72); o.geom.y = clamp(d.y, 0, 1, 0.70); o.geom.w = clamp(d.w, MIN_W, MAX_W, 0.25);
++ const d = defaultsOf(o.pip);
++ Object.assign(o.geom, { x: d.x, y: d.y, w: d.w, mode: 'dock', tuck: null });
++ }
++
++ /* ── where it lives: the player, or over the page ─────────── */
++
++ /** The element the PIP is a child of for its mode: the player, or the page (the fullscreen element in fullscreen). */
++ function placeIn(o) {
++ const fs = document.fullscreenElement;
++ const want = o.geom.mode === 'float' && !inFullscreen(o) ? document.body : (fs && !fs.contains(o.container) ? fs : o.container);
++ if (want && o.el.parentNode !== want) {
++ const v = o.el.querySelector('.ov-pip-video');
++ want.appendChild(o.el);
++ // A media element moved in one task keeps playing; play() covers a browser that paused it.
++ if (v && v.paused) v.play().catch(() => {});
++ }
++ }
++
++ /** Move it to its place with a spring from where it was (`from`: its rect on screen before). */
++ function land(o, from) {
++ placeIn(o);
++ o.el.style.transition = 'none';
++ o.el.style.transform = '';
++ applyGeom(o);
++ if (!from || reduced()) { o.el.style.transition = ''; return; }
++ const to = o.el.getBoundingClientRect();
++ if (!to.width) { o.el.style.transition = ''; return; }
++ const dx = from.left - to.left, dy = from.top - to.top, s = from.width / to.width;
++ if (Math.abs(dx) < 1 && Math.abs(dy) < 1 && Math.abs(s - 1) < 0.01) { o.el.style.transition = ''; return; }
++ o.el.style.transformOrigin = '0 0';
++ o.el.style.transform = `translate3d(${dx}px, ${dy}px, 0) scale(${s})`;
++ void o.el.offsetWidth;
++ o.el.style.transition = `transform ${LAND_MS}ms var(--ov-ease-spring, cubic-bezier(.2, 1.25, .3, 1))`;
++ o.el.style.transform = '';
++ clearTimeout(o.landTimer);
++ o.landTimer = setTimeout(() => { if (o.el) { o.el.style.transition = ''; o.el.style.transformOrigin = ''; } }, LAND_MS + 40);
}
/* ── DOM ──────────────────────────────────────────────────── */
@@@ -135,66 +253,111 @@
function muteButton(btn, muted, label) {
btn.title = muted ? `Turn on ${label}'s sound` : `Mute ${label}`;
btn.setAttribute('aria-label', btn.title);
++ btn.setAttribute('aria-pressed', String(!muted));
btn.innerHTML = `<i class="fa-solid ${muted ? 'fa-volume-xmark' : 'fa-volume-high'}"></i>`;
}
++ const STATE_TEXT = {
++ connecting: (l) => `Connecting to ${l}`,
++ waiting: (l) => `Waiting for ${l}'s picture`,
++ offline: (l) => `${l} dropped · trying again`,
++ };
++
++ function setState(o, state) {
++ if (!o.el || o.state === state) return;
++ o.state = state;
++ o.el.dataset.state = state;
++ o.el.classList.toggle('is-offline', state === 'offline');
++ const s = o.el.querySelector('.ov-pip-state');
++ if (!s) return;
++ const label = o.pip.title || 'the camera';
++ const mark = s.querySelector('.ov-mark');
++ if (mark) mark.setAttribute('data-state', state === 'offline' ? 'sleep' : 'busy');
++ const t = s.querySelector('.ov-pip-state-t');
++ if (t && STATE_TEXT[state]) t.textContent = STATE_TEXT[state](label);
++ }
++
function build(o, container) {
const label = o.pip.title || 'Picture-in-picture';
const href = o.pip.username ? `/@${encodeURIComponent(o.pip.username)}${o.pip.slug ? `/${encodeURIComponent(o.pip.slug)}` : ''}` : null;
const el = document.createElement('div');
-- el.className = 'ov-pip';
++ el.className = 'ov-pip is-entering';
el.dataset.pipSource = String(o.pip.source_msid);
el.innerHTML = `
<video class="ov-pip-video" autoplay playsinline muted></video>
++ <div class="ov-pip-state" aria-live="polite"><span class="ov-mark" data-size="34" data-state="busy" aria-hidden="true"></span><span class="ov-pip-state-t"></span></div>
<div class="ov-pip-bar">
-- <span class="ov-pip-title" title="${escapeHtml(label)}"><i class="fa-solid fa-video"></i> ${href
++ <span class="ov-pip-title" title="${escapeHtml(label)}"><i class="fa-solid fa-video" aria-hidden="true"></i> ${href
? `<a href="${escapeHtml(href)}" onclick="return handleLinkClick(event, '${escapeHtml(href)}')">${escapeHtml(label)}</a>` : escapeHtml(label)}</span>
<span class="ov-pip-actions">
<button type="button" data-act="mute"></button>
-- <button type="button" data-act="reset" title="Back to where the streamer put it" aria-label="Back to where the streamer put it"><i class="fa-solid fa-arrows-to-dot"></i></button>
-- <button type="button" data-act="hide" title="Hide ${escapeHtml(label)}" aria-label="Hide ${escapeHtml(label)}"><i class="fa-solid fa-eye-slash"></i></button>
++ <button type="button" data-act="reset" title="Back to where the streamer put it" aria-label="Back to where the streamer put it"><i class="fa-solid fa-rotate-left"></i></button>
++ <button type="button" data-act="tuck" title="Tuck ${escapeHtml(label)} to the side" aria-label="Tuck ${escapeHtml(label)} to the side"><i class="fa-solid fa-angles-right"></i></button>
</span>
</div>
-- <div class="ov-pip-resize" title="Drag to resize"></div>
-- <div class="ov-pip-offline"><i class="fa-solid fa-video-slash"></i><span>${escapeHtml(label)} is offline</span></div>`;
-- container.appendChild(el);
++ <button type="button" class="ov-pip-tab" data-act="untuck" title="Bring ${escapeHtml(label)} back" aria-label="Bring ${escapeHtml(label)} back"><span class="ov-pip-tab-l"><i class="fa-solid fa-chevron-left"></i></span><span class="ov-pip-tab-r"><i class="fa-solid fa-chevron-right"></i></span></button>
++ <div class="ov-pip-resize" title="Drag to resize" aria-hidden="true"></div>`;
o.el = el;
o.container = container;
el.tabIndex = 0;
el.setAttribute('role', 'group');
-- el.setAttribute('aria-label', `${label}, picture-in-picture. Arrow keys move it, plus and minus size it, M mutes it, H hides it.`);
++ el.setAttribute('aria-label', `${label}, picture-in-picture. Drag it anywhere; arrow keys move it, plus and minus size it, M mutes it, T tucks it aside, F pops it out of the player.`);
muteButton(el.querySelector('[data-act="mute"]'), o.geom.muted, label);
watchShape(o, el.querySelector('.ov-pip-video'));
++ setState(o, 'connecting');
-- el.querySelector('.ov-pip-actions').addEventListener('click', (e) => {
-- const act = e.target.closest('[data-act]')?.dataset.act;
-- if (!act) return;
++ el.addEventListener('click', (e) => {
++ const btn = e.target.closest('[data-act]');
++ if (!btn || !el.contains(btn)) return;
e.stopPropagation();
-- if (act === 'hide') { o.geom.hidden = true; saveGeom(o); applyGeom(o); showRestore(o, container); }
-- else if (act === 'reset') { resetGeom(o); settle(o); saveGeom(o); applyGeom(o); }
-- else if (act === 'mute') {
-- o.geom.muted = !o.geom.muted;
-- const v = el.querySelector('.ov-pip-video');
-- v.muted = o.geom.muted;
-- if (!o.geom.muted) v.play().catch(() => {});
-- muteButton(e.target.closest('[data-act="mute"]'), o.geom.muted, label);
-- saveGeom(o);
-- }
++ act(o, btn.dataset.act);
});
-- wireDrag(o, container);
-- wireResize(o, container);
++ wireMove(o);
++ wireResize(o);
wireKeys(o);
-- // A double click (a double tap on a phone) toggles small and large.
-- el.addEventListener('dblclick', (e) => {
-- if (e.target.closest('[data-act]') || e.target.closest('a') || e.target.closest('.ov-pip-resize')) return;
-- o.geom.w = o.geom.w < BIG_W - 0.01 ? BIG_W : clamp((o.pip.defaults || {}).w, MIN_W, MAX_W, 0.25);
-- settle(o); saveGeom(o); applyGeom(o);
-- });
++ placeIn(o);
applyGeom(o);
-- if (o.geom.hidden) showRestore(o, container);
++ setTimeout(() => el.classList.remove('is-entering'), 420);
++ }
++
++ function act(o, what) {
++ const el = o.el;
++ const from = el.getBoundingClientRect();
++ if (what === 'tuck') {
++ const b = boxOf(o);
++ o.geom.tuck = o.geom.x + b.wf / 2 < 0.5 ? 'left' : 'right';
++ saveGeom(o); land(o, from);
++ } else if (what === 'untuck') {
++ o.geom.tuck = null;
++ const b = boxOf(o);
++ o.geom.x = from.left + from.width / 2 < (b.rl + b.rw / 2) ? (b.rw ? EDGE_PX / b.rw : 0) : Math.max(0, 1 - b.wf - (b.rw ? EDGE_PX / b.rw : 0));
++ saveGeom(o); land(o, from);
++ } else if (what === 'reset') { resetGeom(o); settle(o); saveGeom(o); land(o, from); }
++ else if (what === 'float') { toMode(o, o.geom.mode === 'float' ? 'dock' : 'float', from); saveGeom(o); land(o, from); }
++ else if (what === 'mute') {
++ o.geom.muted = !o.geom.muted;
++ const v = el.querySelector('.ov-pip-video');
++ v.muted = o.geom.muted;
++ if (!o.geom.muted) v.play().catch(() => {});
++ muteButton(el.querySelector('[data-act="mute"]'), o.geom.muted, o.pip.title || 'Picture-in-picture');
++ saveGeom(o);
++ }
++ }
++
++ /** Switch frames keeping its size and place on screen (`rect`: where it is now). */
++ function toMode(o, mode, rect) {
++ o.geom.mode = mode;
++ const b0 = boxOf(o);
++ if (!b0.rw || !b0.rh) return;
++ const k = Math.sqrt((o.aspect || BASE) / BASE) || 1;
++ o.geom.w = clamp(rect.width / b0.rw / k, MIN_W, MAX_W, o.geom.w);
++ const b = boxOf(o);
++ o.geom.x = (rect.left - b.rl) / b.rw;
++ o.geom.y = (rect.top - b.rt) / b.rh;
++ settle(o);
}
-- /** With focus: arrows move it (Shift: further), + and − size it, M mutes, H hides, Home puts it back. */
++ /** With focus: arrows move it (Shift: further), + and − size it, M mutes, T tucks, F floats, Home puts it back. */
function wireKeys(o) {
o.el.addEventListener('keydown', (e) => {
if (e.target !== o.el || e.altKey || e.ctrlKey || e.metaKey) return;
@@@ -207,88 +370,138 @@
else if (k === 'ArrowDown') o.geom.y += step;
else if (k === '+' || k === '=') o.geom.w = clamp(o.geom.w + 0.03, MIN_W, MAX_W, o.geom.w);
else if (k === '-' || k === '_') o.geom.w = clamp(o.geom.w - 0.03, MIN_W, MAX_W, o.geom.w);
-- else if (k === 'Home') resetGeom(o);
-- else if (k === 'm' || k === 'M') { o.el.querySelector('[data-act="mute"]').click(); moved = false; }
-- else if (k === 'h' || k === 'H') { o.el.querySelector('[data-act="hide"]').click(); moved = false; }
++ else if (k === 'Home') { act(o, 'reset'); e.preventDefault(); return; }
++ else if (k === 'm' || k === 'M') { act(o, 'mute'); moved = false; }
++ else if (k === 't' || k === 'T' || k === 'h' || k === 'H') { act(o, o.geom.tuck ? 'untuck' : 'tuck'); moved = false; }
++ else if (k === 'f' || k === 'F') { act(o, 'float'); moved = false; }
else return;
e.preventDefault();
if (!moved) return;
const b = boxOf(o);
++ o.geom.tuck = null;
o.geom.x = Math.min(Math.max(0, o.geom.x), Math.max(0, 1 - b.wf));
o.geom.y = Math.min(Math.max(0, o.geom.y), Math.max(0, 1 - b.hf));
saveGeom(o); applyGeom(o);
});
}
-- /** The "Show …" pill for a hidden PIP; pills of several hidden PIPs sit side by side. */
-- function showRestore(o, container) {
-- if (o.pill) return;
-- let bar = container.querySelector('.ov-pip-restores');
-- if (!bar) { bar = document.createElement('div'); bar.className = 'ov-pip-restores'; container.appendChild(bar); }
-- const pill = document.createElement('button');
-- pill.type = 'button';
-- pill.className = 'ov-pip-restore';
-- pill.innerHTML = `<i class="fa-solid fa-video"></i> Show ${escapeHtml(o.pip.title || 'picture-in-picture')}`;
-- pill.addEventListener('click', () => { o.geom.hidden = false; saveGeom(o); applyGeom(o); removeRestore(o); });
-- bar.appendChild(pill);
-- o.pill = pill;
-- }
--
-- function removeRestore(o) {
-- if (!o.pill) return;
-- const bar = o.pill.parentNode;
-- o.pill.remove();
-- o.pill = null;
-- if (bar && !bar.children.length) bar.remove();
-- }
--
/**
-- * Pointer-driven move, constrained so the overlay can never be dragged off-screen, and snapped to an edge when
-- * dropped near one. Two fingers on it pinch it bigger or smaller.
++ * The move: the PIP rises over the page as soon as the press moves, follows the pointer with a transform each frame
++ * (nothing is laid out while it moves), and lands with a spring where it is dropped: over the player it docks,
++ * anywhere else it floats, past a side it tucks. A flick carries on. Two fingers pinch it; a tap shows its bar for a
++ * moment on a touch screen; a double tap toggles small and large.
*/
-- function wireDrag(o, container) {
++ function wireMove(o) {
const el = o.el;
-- let start = null;
-- const touches = new Map(); // pointerId → { x, y } while two fingers pinch
-- let pinch = null;
-- const gap = () => { const [a, b] = [...touches.values()]; return Math.hypot(a.x - b.x, a.y - b.y) || 1; };
++ const pts = new Map(); // pointerId → { x, y }
++ let drag = null, pinch = null, raf = 0, lastTap = 0, barTimer = 0;
++ const gap = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.x - b.x, a.y - b.y) || 1; };
++ const paint = () => {
++ raf = 0;
++ if (!drag || !drag.lifted) return;
++ el.style.transform = `translate3d(${drag.dx}px, ${drag.dy}px, 0)`;
++ const c = drag.r;
++ const cx = c.left + drag.dx + c.width / 2, cy = c.top + drag.dy + c.height / 2;
++ const p = o.container.getBoundingClientRect();
++ o.container.classList.toggle('ov-pip-dock-hint', cx >= p.left && cx <= p.right && cy >= p.top && cy <= p.bottom);
++ };
++ const lift = () => {
++ drag.lifted = true;
++ // Over the page from the first move, at the same place on screen, so it can go anywhere.
++ const r = drag.r;
++ const layer = document.fullscreenElement || document.body;
++ if (el.parentNode !== layer) layer.appendChild(el);
++ el.classList.add('is-dragging', 'is-lifted');
++ el.style.transition = 'none';
++ el.style.left = `${r.left}px`; el.style.top = `${r.top}px`; el.style.width = `${r.width}px`;
++ const v = el.querySelector('.ov-pip-video');
++ if (v && v.paused) v.play().catch(() => {});
++ };
++ const showBar = () => {
++ el.classList.add('is-active');
++ clearTimeout(barTimer);
++ barTimer = setTimeout(() => el.classList.remove('is-active'), 3200);
++ };
const onDown = (e) => {
++ if (e.button > 0) return;
if (e.target.closest('[data-act]') || e.target.closest('.ov-pip-resize') || e.target.closest('a')) return;
-- touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
-- el.setPointerCapture?.(e.pointerId);
++ pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
++ try { el.setPointerCapture(e.pointerId); } catch { /* */ }
e.preventDefault();
-- if (touches.size === 2) { start = null; pinch = { d: gap(), w: o.geom.w }; el.classList.add('is-resizing'); return; }
-- if (touches.size > 2) return;
-- const r = container.getBoundingClientRect();
-- start = { px: e.clientX, py: e.clientY, x: o.geom.x, y: o.geom.y, rw: r.width, rh: r.height };
-- el.classList.add('is-dragging');
++ if (pts.size === 2) {
++ if (drag && drag.lifted) { el.style.transform = ''; }
++ drag = null;
++ pinch = { d: gap(), w: o.geom.w };
++ el.classList.add('is-resizing');
++ return;
++ }
++ if (pts.size > 2) return;
++ drag = { sx: e.clientX, sy: e.clientY, dx: 0, dy: 0, r: el.getBoundingClientRect(), lifted: false, hist: [{ x: e.clientX, y: e.clientY, t: performance.now() }] };
};
const onMove = (e) => {
-- if (touches.has(e.pointerId)) touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
-- if (pinch && touches.size === 2) {
++ if (pts.has(e.pointerId)) pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
++ if (pinch && pts.size === 2) {
o.geom.w = clamp(pinch.w * (gap() / pinch.d), MIN_W, MAX_W, pinch.w);
applyGeom(o);
return;
}
-- if (!start) return;
-- const b = boxOf(o);
-- o.geom.x = Math.min(Math.max(0, start.x + (e.clientX - start.px) / start.rw), Math.max(0, 1 - b.wf));
-- o.geom.y = Math.min(Math.max(0, start.y + (e.clientY - start.py) / start.rh), Math.max(0, 1 - b.hf));
-- applyGeom(o);
++ if (!drag) return;
++ const dx = e.clientX - drag.sx, dy = e.clientY - drag.sy;
++ if (!drag.lifted && Math.hypot(dx, dy) < DRAG_PX) return;
++ if (!drag.lifted) lift();
++ // Anywhere on screen; most of it may go past a side (that is how it tucks), never past the top or bottom.
++ const r = drag.r, vw = window.innerWidth, vh = window.innerHeight;
++ drag.dx = Math.min(Math.max(dx, -r.left - r.width + TAB_PX), vw - r.left - TAB_PX);
++ drag.dy = Math.min(Math.max(dy, -r.top), vh - r.top - r.height);
++ const now = performance.now();
++ drag.hist.push({ x: e.clientX, y: e.clientY, t: now });
++ while (drag.hist.length > 2 && now - drag.hist[0].t > 90) drag.hist.shift();
++ if (!raf) raf = requestAnimationFrame(paint);
};
const onUp = (e) => {
-- touches.delete(e.pointerId);
-- el.releasePointerCapture?.(e.pointerId);
++ pts.delete(e.pointerId);
++ try { el.releasePointerCapture(e.pointerId); } catch { /* */ }
if (pinch) {
-- if (touches.size < 2) { pinch = null; el.classList.remove('is-resizing'); settle(o); applyGeom(o); saveGeom(o); }
++ if (pts.size < 2) { pinch = null; el.classList.remove('is-resizing'); settle(o); applyGeom(o); saveGeom(o); }
return;
}
-- if (!start) return;
-- start = null;
-- el.classList.remove('is-dragging');
-- settle(o);
-- applyGeom(o);
++ if (!drag) return;
++ const d = drag; drag = null;
++ if (raf) { cancelAnimationFrame(raf); raf = 0; }
++ o.container.classList.remove('ov-pip-dock-hint');
++ if (!d.lifted) {
++ // A tap: the bar for a moment (a touch screen has no hover); a second within 300 ms toggles the size.
++ const now = performance.now();
++ if (e.type === 'pointerup' && now - lastTap < 300) { lastTap = 0; toggleSize(o); }
++ else { lastTap = now; if (e.pointerType !== 'mouse') showBar(); }
++ if (o.geom.tuck && e.type === 'pointerup') act(o, 'untuck');
++ return;
++ }
++ el.classList.remove('is-dragging', 'is-lifted');
++ // Where it was let go, and where a flick carries it (the last 90 ms, 120 ms on).
++ const h = d.hist, a = h[0], z = h[h.length - 1], dt = Math.max(16, z.t - a.t);
++ const vx = (z.x - a.x) / dt, vy = (z.y - a.y) / dt;
++ const fast = Math.hypot(vx, vy) > 0.6;
++ const from = { left: d.r.left + d.dx, top: d.r.top + d.dy, width: d.r.width, height: d.r.height };
++ const vwid = window.innerWidth, vhei = window.innerHeight;
++ let left = from.left + (fast ? vx * 120 : 0), top = from.top + (fast ? vy * 120 : 0);
++ top = Math.min(Math.max(0, top), vhei - from.height);
++ const p = o.container.getBoundingClientRect();
++ const cx = left + from.width / 2, cy = top + from.height / 2;
++ const fs = inFullscreen(o);
++ const overPlayer = fs || (cx >= p.left && cx <= p.right && cy >= p.top && cy <= p.bottom);
++ const frame = overPlayer ? p : { left: 0, top: 0, right: vwid, bottom: vhei, width: vwid, height: vhei };
++ // Past a side of its frame by more than a third of itself (or thrown at it): tucked into that side.
++ const out = from.width / 3;
++ let tuck = null;
++ if (left < frame.left - out || (fast && vx < -1.2 && left < frame.left + EDGE_PX)) tuck = 'left';
++ else if (left + from.width > frame.right + out || (fast && vx > 1.2 && left + from.width > frame.right - EDGE_PX)) tuck = 'right';
++ el.style.transform = '';
++ el.style.left = ''; el.style.top = ''; el.style.width = '';
++ toMode(o, overPlayer ? 'dock' : 'float', { left: Math.min(Math.max(frame.left, left), frame.right - from.width), top, width: from.width, height: from.height });
++ o.geom.tuck = tuck;
saveGeom(o);
++ land(o, from);
};
el.addEventListener('pointerdown', onDown);
el.addEventListener('pointermove', onMove);
@@@ -296,23 +509,30 @@
el.addEventListener('pointercancel', onUp);
}
-- function wireResize(o, container) {
++ function toggleSize(o) {
++ const from = o.el.getBoundingClientRect();
++ o.geom.w = o.geom.w < BIG_W - 0.01 ? BIG_W : clamp((o.pip.defaults || {}).w, MIN_W, MAX_W, 0.25);
++ o.geom.tuck = null;
++ settle(o); saveGeom(o); land(o, from);
++ }
++
++ function wireResize(o) {
const el = o.el;
const handle = el.querySelector('.ov-pip-resize');
let start = null;
handle.addEventListener('pointerdown', (e) => {
-- const r = container.getBoundingClientRect();
-- start = { px: e.clientX, w: o.geom.w, rw: r.width };
++ const b = boxOf(o);
++ start = { px: e.clientX, py: e.clientY, w: o.geom.w, rw: b.rw };
el.classList.add('is-resizing');
-- handle.setPointerCapture?.(e.pointerId);
++ try { handle.setPointerCapture(e.pointerId); } catch { /* */ }
e.preventDefault(); e.stopPropagation();
});
handle.addEventListener('pointermove', (e) => {
-- if (!start) return;
-- // The handle's sideways move is the box's own width; `w` is the 16:9 width of the same area.
++ if (!start || !start.rw) return;
++ // The handle's diagonal pull is the box's own width; `w` is the 16:9 width of the same area.
const k = Math.sqrt((o.aspect || BASE) / BASE) || 1;
-- o.geom.w = clamp(start.w + (e.clientX - start.px) / start.rw / k, MIN_W, MAX_W, start.w);
-- // Keep it inside the player as it grows.
++ const pull = ((e.clientX - start.px) + (e.clientY - start.py) * (o.aspect || BASE)) / 2;
++ o.geom.w = clamp(start.w + pull / start.rw / k, MIN_W, MAX_W, start.w);
const b = boxOf(o);
o.geom.x = Math.min(o.geom.x, Math.max(0, 1 - b.wf));
o.geom.y = Math.min(o.geom.y, Math.max(0, 1 - b.hf));
@@@ -322,17 +542,13 @@
if (!start) return;
start = null;
el.classList.remove('is-resizing');
-- handle.releasePointerCapture?.(e.pointerId);
-- saveGeom(o);
++ try { handle.releasePointerCapture(e.pointerId); } catch { /* */ }
++ settle(o); applyGeom(o); saveGeom(o);
};
handle.addEventListener('pointerup', end);
handle.addEventListener('pointercancel', end);
}
-- function markOffline(o, off) {
-- if (o.el) o.el.classList.toggle('is-offline', !!off);
-- }
--
/* ── viewer sessions ──────────────────────────────────────── */
function waitFor(ws, type, timeoutMs = 15000) {
@@@ -349,10 +565,10 @@
});
}
-- const alive = (o) => host && host.overlays.get(o.pip.source_msid) === o && o.gen === host.gen && !o.closed;
++ const alive = (o, session) => host && host.overlays.get(o.pip.source_msid) === o && o.gen === host.gen && !o.closed && (session == null || session === o.session);
/** A WebRTC source: a viewer of its mediasoup room, like the main player. */
-- async function connectWebrtc(o, videoEl) {
++ async function connectWebrtc(o, videoEl, session) {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${window.location.host}/ws/broadcast?streamId=${o.pip.stream_id}&role=viewer`);
o.ws = ws;
@@@ -360,21 +576,21 @@
ws.addEventListener('open', res, { once: true });
ws.addEventListener('error', () => rej(new Error('pip: websocket failed')), { once: true });
});
-- if (!alive(o)) return;
-- ws.addEventListener('close', () => { if (alive(o)) markOffline(o, true); });
++ if (!alive(o, session)) return;
++ ws.addEventListener('close', () => { if (alive(o, session)) dropped(o); });
ws.send(JSON.stringify({ type: 'watch' }));
const ready = await waitFor(ws, 'sfu-viewer-ready', 20000);
-- if (!alive(o)) return;
++ if (!alive(o, session)) return;
const mod = await (window.loadMediasoupClient ? window.loadMediasoupClient() : Promise.reject(new Error('pip: mediasoup-client unavailable')));
const Device = mod.Device || mod.default?.Device;
const device = new Device();
await device.load({ routerRtpCapabilities: ready.rtpCapabilities });
-- if (!alive(o)) return;
++ if (!alive(o, session)) return;
ws.send(JSON.stringify({ type: 'sfu-viewer-create-transport' }));
const tp = await waitFor(ws, 'sfu-viewer-transport-created');
-- if (!alive(o)) return;
++ if (!alive(o, session)) return;
const iceServers = (typeof sanitizeIceServers === 'function' ? sanitizeIceServers(tp.iceServers) : tp.iceServers) || [];
const transport = device.createRecvTransport({
id: tp.id, iceParameters: tp.iceParameters, iceCandidates: tp.iceCandidates, dtlsParameters: tp.dtlsParameters,
@@@ -385,15 +601,16 @@
ws.send(JSON.stringify({ type: 'sfu-viewer-connect-transport', transportId: transport.id, dtlsParameters }));
waitFor(ws, 'sfu-viewer-transport-connected').then(() => cb()).catch(errb);
});
-- transport.on('connectionstatechange', (st) => { if (st === 'failed' || st === 'closed') markOffline(o, true); });
++ transport.on('connectionstatechange', (st) => { if ((st === 'failed' || st === 'closed') && alive(o, session)) dropped(o); });
const media = new MediaStream();
for (const prod of (ready.producers || [])) {
ws.send(JSON.stringify({ type: 'sfu-viewer-consume', transportId: transport.id, producerId: prod.id, rtpCapabilities: device.rtpCapabilities }));
const cp = await waitFor(ws, 'sfu-viewer-consumed');
-- if (!alive(o)) return;
++ if (!alive(o, session)) return;
const consumer = await transport.consume({ id: cp.id, producerId: cp.producerId, kind: cp.kind, rtpParameters: cp.rtpParameters });
o.consumers.push(consumer);
++ consumer.track.addEventListener('ended', () => { if (alive(o, session)) setState(o, 'waiting'); });
media.addTrack(consumer.track);
ws.send(JSON.stringify({ type: 'sfu-viewer-resume', consumerId: cp.id }));
}
@@@ -401,64 +618,118 @@
}
/** An RTMP source: its FLV through Live's proxy (flv.js, loaded by the main player when it needed it). */
-- async function connectFlv(o, videoEl) {
++ async function connectFlv(o, videoEl, session) {
if (typeof flvjs === 'undefined' || !flvjs.isSupported()) throw new Error('pip: flv.js unavailable');
const player = flvjs.createPlayer({ type: 'flv', isLive: true, url: `/api/streams/rtmp-proxy/${o.pip.stream_id}.flv` },
{ enableStashBuffer: false, liveBufferLatencyChasing: true });
o.flv = player;
player.attachMediaElement(videoEl);
-- player.on(flvjs.Events.ERROR, () => { if (alive(o)) markOffline(o, true); });
++ player.on(flvjs.Events.ERROR, () => { if (alive(o, session)) dropped(o); });
player.load();
}
++ /** End this PIP's viewer session, keeping its element and its last picture. */
++ function teardown(o) {
++ o.session = (o.session || 0) + 1;
++ try { o.consumers.forEach((c) => { try { c.close(); } catch { /* */ } }); } catch { /* */ }
++ o.consumers = [];
++ try { o.transport?.close(); } catch { /* */ }
++ o.transport = null;
++ try { o.ws?.close(); } catch { /* */ }
++ o.ws = null;
++ try { o.flv?.destroy(); } catch { /* */ }
++ o.flv = null;
++ }
++
async function connect(o) {
const videoEl = o.el.querySelector('.ov-pip-video');
++ teardown(o);
++ const session = o.session;
++ o.started = performance.now();
++ if (o.state !== 'offline') setState(o, 'connecting');
try {
-- if (o.pip.protocol === 'rtmp') await connectFlv(o, videoEl);
-- else await connectWebrtc(o, videoEl);
-- if (!alive(o)) return;
++ if (o.pip.protocol === 'rtmp') await connectFlv(o, videoEl, session);
++ else await connectWebrtc(o, videoEl, session);
++ if (!alive(o, session)) return;
videoEl.muted = o.geom.muted;
videoEl.play().catch(() => {});
-- markOffline(o, false);
++ o.tries = 0;
++ // Live once a frame arrives (watchFrames); until then it says it is waiting for the picture.
++ if (o.state !== 'live') setState(o, 'waiting');
} catch (err) {
console.warn(`[PiP] ${o.pip.title || o.pip.source_msid} unavailable:`, err.message);
-- if (alive(o)) markOffline(o, true);
++ if (alive(o, session)) dropped(o);
}
}
-- function close(o) {
++ /** The connection went: say so over the last picture and try again on a backoff while it is still listed. */
++ function dropped(o) {
++ if (o.closed) return;
++ setState(o, 'offline');
++ if (o.retryTimer) return;
++ const delay = BACKOFF[Math.min(o.tries || 0, BACKOFF.length - 1)];
++ o.tries = (o.tries || 0) + 1;
++ o.retryTimer = setTimeout(() => { o.retryTimer = 0; if (alive(o) && !document.hidden) connect(o); else if (alive(o)) o.retryLater = true; }, delay);
++ }
++
++ /** Frames: live while they come; the wait after STALL_MS without one; a new connection after RETRY_MS. */
++ function watchFrames(o) {
++ const v = o.el.querySelector('.ov-pip-video');
++ o.lastFrame = 0;
++ const seen = () => { o.lastFrame = performance.now(); if (o.state !== 'live') setState(o, 'live'); };
++ if (v && typeof v.requestVideoFrameCallback === 'function') {
++ const cb = () => { if (o.closed) return; seen(); v.requestVideoFrameCallback(cb); };
++ v.requestVideoFrameCallback(cb);
++ } else if (v) v.addEventListener('timeupdate', seen);
++ o.watch = setInterval(() => {
++ if (o.closed) { clearInterval(o.watch); return; }
++ if (document.hidden) return;
++ if (o.retryLater) { o.retryLater = false; connect(o); return; }
++ const idle = performance.now() - Math.max(o.lastFrame || 0, o.started || 0);
++ if (o.state === 'live' && idle > STALL_MS) setState(o, 'waiting');
++ else if (o.state === 'waiting' && idle > RETRY_MS && !o.retryTimer) connect(o);
++ }, 1000);
++ }
++
++ function close(o, animate) {
o.closed = true;
-- try { o.consumers.forEach((c) => { try { c.close(); } catch { /* */ } }); } catch { /* */ }
-- try { o.transport?.close(); } catch { /* */ }
-- try { o.ws?.close(); } catch { /* */ }
-- try { o.flv?.destroy(); } catch { /* */ }
-- removeRestore(o);
-- try { o.el?.remove(); } catch { /* */ }
++ clearInterval(o.watch);
++ clearTimeout(o.retryTimer);
++ clearTimeout(o.landTimer);
++ teardown(o);
++ const el = o.el;
++ if (!el) return;
++ if (animate && !reduced()) { el.classList.add('is-leaving'); setTimeout(() => { try { el.remove(); } catch { /* */ } }, 260); }
++ else { try { el.remove(); } catch { /* */ } }
}
/* ── the list ─────────────────────────────────────────────── */
-- /** Draw the live PIPs of `pips` and drop the rest; a PIP whose source restarted (a new stream) reconnects. */
++ /** Draw the live PIPs of `pips` and drop the rest; a PIP whose source restarted (a new stream) reconnects in place. */
function sync(pips) {
if (!host) return;
const live = (Array.isArray(pips) ? pips : []).filter((p) => p && p.live && p.stream_id && p.source_msid);
const keep = new Set(live.map((p) => p.source_msid));
for (const [key, o] of host.overlays) {
-- if (!keep.has(key)) { close(o); host.overlays.delete(key); }
++ if (!keep.has(key)) { close(o, true); host.overlays.delete(key); }
}
-- live.forEach((pip, i) => {
++ live.forEach((pip) => {
const cur = host.overlays.get(pip.source_msid);
-- if (cur && cur.pip.stream_id === pip.stream_id && !cur.el.classList.contains('is-offline')) return;
-- if (cur) { close(cur); host.overlays.delete(pip.source_msid); }
-- const o = { pip, hostId: host.streamId, gen: host.gen, geom: loadGeom(host.streamId, pip, i === 0), consumers: [], el: null, pill: null };
++ if (cur) {
++ if (cur.pip.stream_id !== pip.stream_id) { cur.pip = pip; cur.tries = 0; connect(cur); }
++ else if (cur.state === 'offline' && !cur.retryTimer) connect(cur);
++ return;
++ }
++ const o = { pip, slot: host.slot, hostId: host.streamId, gen: host.gen, geom: loadGeom(host.slot, host.streamId, pip), consumers: [], el: null, started: performance.now() };
host.overlays.set(pip.source_msid, o);
build(o, host.container);
++ watchFrames(o);
connect(o);
});
}
async function poll() {
-- if (!host) return;
++ if (!host || document.hidden) return;
const myGen = host.gen;
try {
const r = await fetch(`/api/streams/${encodeURIComponent(host.streamId)}/pips`, { credentials: 'same-origin' });
@@@ -468,28 +739,41 @@
} catch { /* the next poll tries again */ }
}
++ function relayout() {
++ if (!host) return;
++ for (const o of host.overlays.values()) if (o.el && !o.el.classList.contains('is-lifted')) { placeIn(o); applyGeom(o); }
++ }
++
/* ── public API ───────────────────────────────────────────── */
/**
* Draw the PIPs of a stream (`pip_overlays`, or the N-1 `pip_overlay`). Safe to call on every player init; it tears
-- * down any previous ones first.
++ * down any previous ones first. The account's places are read first (one request, kept for the visit).
*/
-- function attach(stream) {
++ async function attach(stream) {
detach();
const container = document.getElementById('video-container');
if (!container || !stream || !stream.id) return;
const pips = Array.isArray(stream.pip_overlays) ? stream.pip_overlays
: (stream.pip_overlay ? [Object.assign({ source_msid: stream.pip_overlay.source_msid || 0 }, stream.pip_overlay)] : []);
if (!pips.length) return; // a slot with no PIPs never polls
-- host = { streamId: stream.id, container, overlays: new Map(), gen: ++gen, timer: null, ro: null };
++ const h = { streamId: stream.id, slot: stream.managed_stream_id || stream.id, container, overlays: new Map(), gen: ++gen, timer: null, ro: null };
++ host = h;
++ await Promise.race([account.load(), new Promise((r) => setTimeout(r, 900))]);
++ if (host !== h) return;
sync(pips);
-- host.timer = setInterval(poll, POLL_MS);
-- // The player changes size (a window, theatre mode, fullscreen, a phone turned): the cap on height follows.
++ h.timer = setInterval(poll, POLL_MS);
++ // The player changes size (a window, theatre mode, fullscreen, a phone turned): the cap on height follows, and
++ // a floating PIP keeps its place in the window.
if (typeof ResizeObserver === 'function') {
-- const h = host;
-- h.ro = new ResizeObserver(() => { if (host === h) for (const o of h.overlays.values()) if (o.el) applyGeom(o); });
++ h.ro = new ResizeObserver(() => { if (host === h) relayout(); });
h.ro.observe(container);
}
++ h.onWin = () => { if (host === h) relayout(); };
++ window.addEventListener('resize', h.onWin);
++ document.addEventListener('fullscreenchange', h.onWin);
++ h.onVis = () => { if (host === h && !document.hidden) for (const o of h.overlays.values()) if (o.retryLater) { o.retryLater = false; connect(o); } };
++ document.addEventListener('visibilitychange', h.onVis);
}
function detach() {
@@@ -497,10 +781,19 @@
if (!host) return;
clearInterval(host.timer);
try { host.ro?.disconnect(); } catch { /* */ }
-- for (const o of host.overlays.values()) close(o);
-- try { host.container.querySelector('.ov-pip-restores')?.remove(); } catch { /* */ }
++ window.removeEventListener('resize', host.onWin);
++ document.removeEventListener('fullscreenchange', host.onWin);
++ document.removeEventListener('visibilitychange', host.onVis);
++ for (const o of host.overlays.values()) close(o, false);
++ try { host.container.classList.remove('ov-pip-dock-hint'); } catch { /* */ }
host = null;
}
-- window.streamPip = { attach, detach, _test: { boxOf, settle, applyGeom, BASE, MAX_H } };
++ /** Where every PIP of this stream is, for the page and for tools (the same shape the account keeps). */
++ function layout() {
++ if (!host) return [];
++ return [...host.overlays.values()].map((o) => ({ slot: o.slot, source: o.pip.source_msid, title: o.pip.title || null, state: o.state, geom: { ...o.geom } }));
++ }
++
++ window.streamPip = { attach, detach, layout, act: (source, what) => { const o = host && host.overlays.get(Number(source)); if (o) act(o, what); }, _test: { boxOf, settle, applyGeom, loadGeom, BASE, MAX_H } };
})();
ddiff --git a/test/pip-camera-slot.test.js b/test/pip-camera-slot.test.js
iindex 14720e74..6a32afcb 100644
---- a/test/pip-camera-slot.test.js
++++ b/test/pip-camera-slot.test.js
@@@ -98,11 +98,12 @@ for (const msg of ['sfu-viewer-create-transport', 'sfu-viewer-consume', 'sfu-vie
}
console.log('OK H: overlay is a full independent viewer session, attached and detached with the player');
--// ── I: viewer control — geometry persists, and hiding stays recoverable ──────────────
--assert.ok(/localStorage\.setItem\(LS_KEY\(o\.hostId, o\.pip\.source_msid\)/.test(pip), 'geometry must persist per stream and per PIP');
--assert.ok(/if \(!raw && first\) raw = JSON\.parse\(localStorage\.getItem\(LS_OLD\(streamId\)\)/.test(pip), 'the first PIP keeps where a viewer put the old single one');
--assert.ok(/ov-pip-restore/.test(pip) && read('public/css/style.css').includes('.ov-pip-restore'),
-- 'hiding the overlay must leave a way to bring it back');
++// ── I: viewer control — the place persists per slot and PIP (and in the account), tucking stays recoverable ───────
++assert.ok(/localStorage\.setItem\(LS_KEY\(o\.slot, o\.pip\.source_msid\)/.test(pip), 'the place persists per slot and per PIP');
++assert.ok(/account\.put\(`\$\{o\.slot\}:\$\{o\.pip\.source_msid\}`, o\.geom\)/.test(pip), 'and in the account when signed in');
++assert.ok(/localStorage\.getItem\(LS_V1\(streamId, pip\.source_msid\)\)/.test(pip), 'a place saved per session before still counts');
++assert.ok(/data-act="untuck"/.test(pip) && read('public/css/features/pip.css').includes('.ov-pip.is-tucked .ov-pip-tab { display: grid; }'),
++ 'a PIP tucked aside keeps its tab on screen to bring it back');
assert.ok(/muted: pip\.muted !== false/.test(pip),
'the overlay starts as the streamer chose, muted unless they said otherwise');
assert.ok(/is-offline/.test(pip), 'a camera going offline must degrade to a badge, not a broken overlay');
@@@ -240,10 +241,10 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mut
const pip = read('public/js/stream-pip.js');
assert.ok(/const pips = Array\.isArray\(stream\.pip_overlays\) \? stream\.pip_overlays/.test(pip), 'every PIP, the N-1 single one as a fallback');
assert.ok(/fetch\(`\/api\/streams\/\$\{encodeURIComponent\(host\.streamId\)\}\/pips`/.test(pip) && /setInterval\(poll, POLL_MS\)/.test(pip), 'asks again every 30 s');
-- assert.ok(/if \(!keep\.has\(key\)\) \{ close\(o\); host\.overlays\.delete\(key\); \}/.test(pip), 'an ended or removed PIP goes');
-- assert.ok(/if \(o\.pip\.protocol === 'rtmp'\) await connectFlv\(o, videoEl\);/.test(pip), 'an RTMP source plays its FLV');
++ assert.ok(/if \(!keep\.has\(key\)\) \{ close\(o, true\); host\.overlays\.delete\(key\); \}/.test(pip), 'an ended or removed PIP goes');
++ assert.ok(/if \(o\.pip\.protocol === 'rtmp'\) await connectFlv\(o, videoEl, session\);/.test(pip), 'an RTMP source plays its FLV');
assert.ok(!/token=/.test(pip), 'no token in a socket address');
-- assert.ok(read('public/css/features/channel.css').includes('.ov-pip-restores .ov-pip-restore { position: static; }'), "hidden PIPs' pills sit in one row");
++ assert.ok(/function watchFrames\(o\)/.test(pip) && /setState\(o, 'waiting'\)/.test(pip) && /data-state="busy"/.test(pip), 'a PIP whose frames stop shows the mark at work, never a black box');
console.log('OK O: the viewer draws every live PIP, polls, and plays WebRTC and RTMP sources');
}
ddiff --git a/test/stream-pip-shape.test.js b/test/stream-pip-shape.test.js
iindex 871d193d..85d06fc3 100644
---- a/test/stream-pip-shape.test.js
++++ b/test/stream-pip-shape.test.js
@@@ -87,7 +87,7 @@ check('on a phone-sized player it is never smaller than a 150 px wide 16:9 box',
near(boxOf(pip(16 / 9, { w: 0.25 })).wf, 0.25, 'a desktop player shows the saved size');
});
--const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'features', 'channel.css'), 'utf8');
++const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'features', 'pip.css'), 'utf8');
check('the video is contained, never cropped', () => {
assert.match(css, /\.ov-pip-video \{[^}]*object-fit: contain/);
assert.doesNotMatch(css, /\.ov-pip-video \{[^}]*object-fit: cover/);