openvibers/openvibe.live Commits
PiP anywhere: drag it over the page, tuck it aside, and the mark while it waits (#806)
Changes
@@@ -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 thecamera into the picture as before, so restreams show it too.@@@ -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; }nnew file mode 100644@@@ -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; }++}@@@ -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); }@@@ -81,6 +81,9 @@"deps": ["clipJobs"],++ "css": [++ "/css/features/pip.css"++ ],"js": ["/shared/loading.js","/js/player-shape.js",@@@ -9,34 +9,81 @@The server sends every PIP with the stream (`pip_overlays`: source slot, its live stream or null, the streamer'sdefault 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-11const 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 shapeconst SNAP = 0.04; // within this fraction of an edge, a dropped PIP snaps to itconst 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 wif (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 } };})();@@@ -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 mutconst 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');}@@@ -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/);
@@ -101,10 +101,20 @@ camera goes out**):
@@ -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
- 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.
"<title> camera" slot, added at the corner and size you chose.
- It gets its own VOD.
- 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 plays over the screen share's VOD where it was.
- It ends with the screen share, or when you turn the camera off.
- 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
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.
camera into the picture as before, so restreams show it too.
@@ -1256,41 +1256,6 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
@@ -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-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-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; }
.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-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 { 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; }
.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; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -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; }
+}
@@ -9363,68 +9363,6 @@ html[data-theme-mode="light"] .hero h1 { background-image: linear-gradient(90deg
@@ -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; }
.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 ─────────────────────────────── */
/* ── AI Chat Viewers v3 panel ─────────────────────────────── */
.aiv-topline { display:flex; align-items:center; gap:16px; flex-wrap:wrap; margin-bottom:8px; }
.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); }
.aiv-status { font-size:0.75rem; font-weight:700; padding:3px 10px; border-radius:999px; background:var(--bg-tertiary); color:var(--text-secondary); }
@@ -81,6 +81,9 @@
@@ -81,6 +81,9 @@
"deps": [
"deps": [
"clipJobs"
"clipJobs"
],
],
+ "css": [
+ "/css/features/pip.css"
+ ],
"js": [
"js": [
"/shared/loading.js",
"/shared/loading.js",
"/js/player-shape.js",
"/js/player-shape.js",
@@ -9,34 +9,81 @@
@@ -9,34 +9,81 @@
The server sends every PIP with the stream (`pip_overlays`: source slot, its live stream or null, the streamer's
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
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 () {
(function () {
'use strict';
'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 MIN_W = 0.08, MAX_W = 0.60;
const POLL_MS = 30000;
const POLL_MS = 30000;
const BASE = 16 / 9; // the shape `w` is measured for
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 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 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;
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 ─────────────────────────────────────────────── */
/* ── geometry ─────────────────────────────────────────────── */
function clamp(v, lo, hi, dflt) {
function clamp(v, lo, hi, dflt) {
@@ -45,54 +92,91 @@
@@ -45,54 +92,91 @@
return Math.min(hi, Math.max(lo, n));
return Math.min(hi, Math.max(lo, n));
}
}
- function loadGeom(streamId, pip, first) {
+ function defaultsOf(pip) {
const d = pip.defaults || {};
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 {
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) {
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) {
function boxOf(o) {
const a = o.aspect || BASE;
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).
// (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;
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
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;
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);
wf = Math.min(wf, 1);
const hf = r.width && r.height ? (wf * r.width) / a / r.height : wf / a;
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) {
function applyGeom(o) {
- const { x, y, hidden } = o.geom;
+ const { x, y, tuck } = o.geom;
const b = boxOf(o);
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.top = `${Math.min(Math.max(0, y), Math.max(0, 1 - b.hf)) * 100}%`;
o.el.style.width = `${b.wf * 100}%`;
o.el.style.width = `${b.wf * 100}%`;
o.el.style.aspectRatio = String(b.a);
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. */
/** After a drop: keep it inside, and snap it to an edge it was dropped close to. */
@@ -122,8 +206,42 @@
@@ -122,8 +206,42 @@
}
}
function resetGeom(o) {
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 ──────────────────────────────────────────────────── */
/* ── DOM ──────────────────────────────────────────────────── */
@@ -135,66 +253,111 @@
@@ -135,66 +253,111 @@
function muteButton(btn, muted, label) {
function muteButton(btn, muted, label) {
btn.title = muted ? `Turn on ${label}'s sound` : `Mute ${label}`;
btn.title = muted ? `Turn on ${label}'s sound` : `Mute ${label}`;
btn.setAttribute('aria-label', btn.title);
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>`;
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) {
function build(o, container) {
const label = o.pip.title || 'Picture-in-picture';
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 href = o.pip.username ? `/@${encodeURIComponent(o.pip.username)}${o.pip.slug ? `/${encodeURIComponent(o.pip.slug)}` : ''}` : null;
const el = document.createElement('div');
const el = document.createElement('div');
- el.className = 'ov-pip';
+ el.className = 'ov-pip is-entering';
el.dataset.pipSource = String(o.pip.source_msid);
el.dataset.pipSource = String(o.pip.source_msid);
el.innerHTML = `
el.innerHTML = `
<video class="ov-pip-video" autoplay playsinline muted></video>
<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">
<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>
? `<a href="${escapeHtml(href)}" onclick="return handleLinkClick(event, '${escapeHtml(href)}')">${escapeHtml(label)}</a>` : escapeHtml(label)}</span>
<span class="ov-pip-actions">
<span class="ov-pip-actions">
<button type="button" data-act="mute"></button>
<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>
</span>
</div>
</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.el = el;
o.container = container;
o.container = container;
el.tabIndex = 0;
el.tabIndex = 0;
el.setAttribute('role', 'group');
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);
muteButton(el.querySelector('[data-act="mute"]'), o.geom.muted, label);
watchShape(o, el.querySelector('.ov-pip-video'));
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();
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);
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);
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) {
function wireKeys(o) {
o.el.addEventListener('keydown', (e) => {
o.el.addEventListener('keydown', (e) => {
if (e.target !== o.el || e.altKey || e.ctrlKey || e.metaKey) return;
if (e.target !== o.el || e.altKey || e.ctrlKey || e.metaKey) return;
@@ -207,88 +370,138 @@
@@ -207,88 +370,138 @@
else if (k === 'ArrowDown') o.geom.y += step;
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 === '-' || 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;
else return;
e.preventDefault();
e.preventDefault();
if (!moved) return;
if (!moved) return;
const b = boxOf(o);
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.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));
o.geom.y = Math.min(Math.max(0, o.geom.y), Math.max(0, 1 - b.hf));
saveGeom(o); applyGeom(o);
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;
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) => {
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;
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();
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) => {
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);
o.geom.w = clamp(pinch.w * (gap() / pinch.d), MIN_W, MAX_W, pinch.w);
applyGeom(o);
applyGeom(o);
return;
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) => {
const onUp = (e) => {
- touches.delete(e.pointerId);
- el.releasePointerCapture?.(e.pointerId);
+ pts.delete(e.pointerId);
+ try { el.releasePointerCapture(e.pointerId); } catch { /* */ }
if (pinch) {
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;
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);
saveGeom(o);
+ land(o, from);
};
};
el.addEventListener('pointerdown', onDown);
el.addEventListener('pointerdown', onDown);
el.addEventListener('pointermove', onMove);
el.addEventListener('pointermove', onMove);
@@ -296,23 +509,30 @@
@@ -296,23 +509,30 @@
el.addEventListener('pointercancel', onUp);
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 el = o.el;
const handle = el.querySelector('.ov-pip-resize');
const handle = el.querySelector('.ov-pip-resize');
let start = null;
let start = null;
handle.addEventListener('pointerdown', (e) => {
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');
el.classList.add('is-resizing');
- handle.setPointerCapture?.(e.pointerId);
+ try { handle.setPointerCapture(e.pointerId); } catch { /* */ }
e.preventDefault(); e.stopPropagation();
e.preventDefault(); e.stopPropagation();
});
});
handle.addEventListener('pointermove', (e) => {
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;
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);
const b = boxOf(o);
o.geom.x = Math.min(o.geom.x, Math.max(0, 1 - b.wf));
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));
o.geom.y = Math.min(o.geom.y, Math.max(0, 1 - b.hf));
@@ -322,17 +542,13 @@
@@ -322,17 +542,13 @@
if (!start) return;
if (!start) return;
start = null;
start = null;
el.classList.remove('is-resizing');
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('pointerup', end);
handle.addEventListener('pointercancel', end);
handle.addEventListener('pointercancel', end);
}
}
- function markOffline(o, off) {
- if (o.el) o.el.classList.toggle('is-offline', !!off);
- }
-
/* ── viewer sessions ──────────────────────────────────────── */
/* ── viewer sessions ──────────────────────────────────────── */
function waitFor(ws, type, timeoutMs = 15000) {
function waitFor(ws, type, timeoutMs = 15000) {
@@ -349,10 +565,10 @@
@@ -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. */
/** 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 proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${window.location.host}/ws/broadcast?streamId=${o.pip.stream_id}&role=viewer`);
const ws = new WebSocket(`${proto}://${window.location.host}/ws/broadcast?streamId=${o.pip.stream_id}&role=viewer`);
o.ws = ws;
o.ws = ws;
@@ -360,21 +576,21 @@
@@ -360,21 +576,21 @@
ws.addEventListener('open', res, { once: true });
ws.addEventListener('open', res, { once: true });
ws.addEventListener('error', () => rej(new Error('pip: websocket failed')), { 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' }));
ws.send(JSON.stringify({ type: 'watch' }));
const ready = await waitFor(ws, 'sfu-viewer-ready', 20000);
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 mod = await (window.loadMediasoupClient ? window.loadMediasoupClient() : Promise.reject(new Error('pip: mediasoup-client unavailable')));
const Device = mod.Device || mod.default?.Device;
const Device = mod.Device || mod.default?.Device;
const device = new Device();
const device = new Device();
await device.load({ routerRtpCapabilities: ready.rtpCapabilities });
await device.load({ routerRtpCapabilities: ready.rtpCapabilities });
- if (!alive(o)) return;
+ if (!alive(o, session)) return;
ws.send(JSON.stringify({ type: 'sfu-viewer-create-transport' }));
ws.send(JSON.stringify({ type: 'sfu-viewer-create-transport' }));
const tp = await waitFor(ws, 'sfu-viewer-transport-created');
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 iceServers = (typeof sanitizeIceServers === 'function' ? sanitizeIceServers(tp.iceServers) : tp.iceServers) || [];
const transport = device.createRecvTransport({
const transport = device.createRecvTransport({
id: tp.id, iceParameters: tp.iceParameters, iceCandidates: tp.iceCandidates, dtlsParameters: tp.dtlsParameters,
id: tp.id, iceParameters: tp.iceParameters, iceCandidates: tp.iceCandidates, dtlsParameters: tp.dtlsParameters,
@@ -385,15 +601,16 @@
@@ -385,15 +601,16 @@
ws.send(JSON.stringify({ type: 'sfu-viewer-connect-transport', transportId: transport.id, dtlsParameters }));
ws.send(JSON.stringify({ type: 'sfu-viewer-connect-transport', transportId: transport.id, dtlsParameters }));
waitFor(ws, 'sfu-viewer-transport-connected').then(() => cb()).catch(errb);
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();
const media = new MediaStream();
for (const prod of (ready.producers || [])) {
for (const prod of (ready.producers || [])) {
ws.send(JSON.stringify({ type: 'sfu-viewer-consume', transportId: transport.id, producerId: prod.id, rtpCapabilities: device.rtpCapabilities }));
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');
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 });
const consumer = await transport.consume({ id: cp.id, producerId: cp.producerId, kind: cp.kind, rtpParameters: cp.rtpParameters });
o.consumers.push(consumer);
o.consumers.push(consumer);
+ consumer.track.addEventListener('ended', () => { if (alive(o, session)) setState(o, 'waiting'); });
media.addTrack(consumer.track);
media.addTrack(consumer.track);
ws.send(JSON.stringify({ type: 'sfu-viewer-resume', consumerId: cp.id }));
ws.send(JSON.stringify({ type: 'sfu-viewer-resume', consumerId: cp.id }));
}
}
@@ -401,64 +618,118 @@
@@ -401,64 +618,118 @@
}
}
/** An RTMP source: its FLV through Live's proxy (flv.js, loaded by the main player when it needed it). */
/** 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');
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` },
const player = flvjs.createPlayer({ type: 'flv', isLive: true, url: `/api/streams/rtmp-proxy/${o.pip.stream_id}.flv` },
{ enableStashBuffer: false, liveBufferLatencyChasing: true });
{ enableStashBuffer: false, liveBufferLatencyChasing: true });
o.flv = player;
o.flv = player;
player.attachMediaElement(videoEl);
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();
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) {
async function connect(o) {
const videoEl = o.el.querySelector('.ov-pip-video');
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 {
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.muted = o.geom.muted;
videoEl.play().catch(() => {});
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) {
} catch (err) {
console.warn(`[PiP] ${o.pip.title || o.pip.source_msid} unavailable:`, err.message);
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;
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 ─────────────────────────────────────────────── */
/* ── 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) {
function sync(pips) {
if (!host) return;
if (!host) return;
const live = (Array.isArray(pips) ? pips : []).filter((p) => p && p.live && p.stream_id && p.source_msid);
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));
const keep = new Set(live.map((p) => p.source_msid));
for (const [key, o] of host.overlays) {
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);
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);
host.overlays.set(pip.source_msid, o);
build(o, host.container);
build(o, host.container);
+ watchFrames(o);
connect(o);
connect(o);
});
});
}
}
async function poll() {
async function poll() {
- if (!host) return;
+ if (!host || document.hidden) return;
const myGen = host.gen;
const myGen = host.gen;
try {
try {
const r = await fetch(`/api/streams/${encodeURIComponent(host.streamId)}/pips`, { credentials: 'same-origin' });
const r = await fetch(`/api/streams/${encodeURIComponent(host.streamId)}/pips`, { credentials: 'same-origin' });
@@ -468,28 +739,41 @@
@@ -468,28 +739,41 @@
} catch { /* the next poll tries again */ }
} 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 ───────────────────────────────────────────── */
/* ── 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
* 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();
detach();
const container = document.getElementById('video-container');
const container = document.getElementById('video-container');
if (!container || !stream || !stream.id) return;
if (!container || !stream || !stream.id) return;
const pips = Array.isArray(stream.pip_overlays) ? stream.pip_overlays
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)] : []);
: (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
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);
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') {
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.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() {
function detach() {
@@ -497,10 +781,19 @@
@@ -497,10 +781,19 @@
if (!host) return;
if (!host) return;
clearInterval(host.timer);
clearInterval(host.timer);
try { host.ro?.disconnect(); } catch { /* */ }
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;
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 } };
})();
})();
@@ -98,11 +98,12 @@ for (const msg of ['sfu-viewer-create-transport', 'sfu-viewer-consume', 'sfu-vie
@@ -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');
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),
assert.ok(/muted: pip\.muted !== false/.test(pip),
'the overlay starts as the streamer chose, muted unless they said otherwise');
'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');
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
@@ -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');
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(/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(/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(!/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');
console.log('OK O: the viewer draws every live PIP, polls, and plays WebRTC and RTMP sources');
}
}
@@ -87,7 +87,7 @@ check('on a phone-sized player it is never smaller than a 150 px wide 16:9 box',
@@ -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');
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', () => {
check('the video is contained, never cropped', () => {
assert.match(css, /\.ov-pip-video \{[^}]*object-fit: contain/);
assert.match(css, /\.ov-pip-video \{[^}]*object-fit: contain/);
assert.doesNotMatch(css, /\.ov-pip-video \{[^}]*object-fit: cover/);
assert.doesNotMatch(css, /\.ov-pip-video \{[^}]*object-fit: cover/);