diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7b900bc..f5981ab1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,9 @@ env: jobs: checks: name: Typecheck, lint, unit tests - runs-on: ubuntu-latest + # Pinned: `ubuntu-latest` moves to Ubuntu 26 from 2026-10-19. The e2e job's apt packages + # (xvfb, libasound2t64) are only known to work on 24.04. + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - uses: actions/checkout@v4 @@ -42,7 +44,7 @@ jobs: e2e-linux: name: E2E (Linux, Xvfb) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 25 steps: - uses: actions/checkout@v4 diff --git a/FEATURES.md b/FEATURES.md index af443f37..fe07fded 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,14 +4,14 @@ Every row is one behaviour from the clean-room spec. Status per ID: **spec’d** (every row) → **built** → **tested** (a unit or e2e test names the ID) → **feels right** (lead sign-off after a hands-on review against `feel.md`). -**Totals:** 485 spec’d · 140 built (45 partial) · 189 tested · 0 feel right. +**Totals:** 485 spec’d · 146 built (57 partial) · 208 tested · 0 feel right. | Priority | Spec’d | Built | Tested | |---|---|---|---| | ? | 7 | 0 | 0 | -| P0 | 174 | 97 | 118 | -| P1 | 221 | 39 | 66 | -| P2 | 83 | 4 | 5 | +| P0 | 174 | 101 | 127 | +| P1 | 221 | 40 | 75 | +| P2 | 83 | 5 | 6 | P0 = milestone-1 core experience, P1 = parity, P2 = later. @@ -30,7 +30,7 @@ Spec: `spec/sidebar.md` | SB-07 | "+ New Tab" row | P0 | ✅ | ✅ e2e | – | + New Tab row with press animation opens the command bar | | SB-08 | Empty sidebar area: double-click, drag, right-click | P1 | – | – | – | | | SB-09 | Bottom bar: Library, Space switcher, "+" menu | P0 | 🟡 partial | ✅ e2e | – | switcher and + menu done; Library button arrives with the archive lane | -| SB-10 | Transient trays above the bottom bar | P1 | – | – | – | | +| SB-10 | Transient trays above the bottom bar | P1 | 🟡 partial | ✅ e2e | – | media tray and call controls (media lane), above the bottom bar, pushing the list up; download progress and the update banner belong to other lanes | | SB-11 | Show / hide the sidebar | P0 | 🟡 partial | – | – | scaffold | | SB-12 | Hover to reveal the hidden sidebar | P0 | – | – | – | | | SB-13 | Feedback when things happen while the sidebar is hidden | P1 | – | – | – | | @@ -70,8 +70,8 @@ Spec: `spec/tabs.md` | TB-12 | Rename a tab and set a custom icon | P1 | ✅ | ✅ e2e | – | double-click/menu rename, empty reverts; emoji icon picker | | TB-13 | Multi-select tabs | P1 | 🟡 partial | ✅ e2e | – | Cmd-click, Shift-click ranges, Escape/plain click clears; Cmd+W, Copy URL, menu pin/unpin/move/new folder and group drag act on the selection; open-as-split is the split lane's | | TB-14 | Tab context menu | P0 | 🟡 partial | ✅ e2e | – | native menus with every tab, pinned, Favorite, folder and multi-selection item; not theme-coloured; split/share items belong to other lanes | -| TB-15 | Audio, mute, microphone/camera and screen-share indicators | P1 | 🟡 partial | ✅ e2e | – | audio icon toggles mute; no mic/camera/screen-share indicators | -| TB-16 | Tab sleeping and background resource limits | P1 | 🟡 partial | ✅ e2e+unit | – | sleep timer (settings.sleepAfterMs), Favorites warm 4x, audible/selected never sleep | +| TB-15 | Audio, mute, microphone/camera and screen-share indicators | P1 | 🟡 partial | ✅ e2e | – | audio icon toggles mute; camera/microphone indicator (media lane); no screen-share indicator or Favorites notes | +| TB-16 | Tab sleeping and background resource limits | P1 | 🟡 partial | ✅ e2e+unit | – | sleep timer (settings.sleepAfterMs), Favorites warm 4x; selected, audible, playing (muted too), PiP and camera/mic tabs never sleep | | TB-17 | Tabs across multiple windows | P1 | 🟡 partial | ✅ e2e+unit | – | tab handoff; no two windows ever show the same tab (close, Clear, archive, moves, undo, drag-out); no blank window yet | | TB-18 | Undo sidebar actions | P1 | ✅ | ✅ e2e+unit | – | Cmd+Z undoes moves, close, Clear, archive below, renames, icons and folder changes (page text fields keep their own undo); Cmd+Shift+Z redoes moves, closes and renames | | TB-19 | Back on a fresh child tab returns to its opener | P2 | 🟡 partial | ✅ e2e | – | Back with no history closes a link-opened Today tab and selects its opener (shortcut/command) | @@ -306,22 +306,22 @@ Spec: `spec/media.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| -| MD-01 | Automatic picture-in-picture when leaving a video tab | P0 | – | – | – | | -| MD-02 | Open picture-in-picture manually | P1 | – | – | – | | -| MD-03 | The floating player's look and placement | P0 | – | – | – | | -| MD-04 | Floating player controls | P0 | – | – | – | | -| MD-05 | Keyboard and media keys for the floating player | P1 | – | – | – | | -| MD-06 | Move, resize and zoom the floating player | P1 | – | – | – | | -| MD-07 | Ending picture-in-picture | P0 | – | – | – | | -| MD-08 | Turning automatic picture-in-picture off | P0 | – | – | – | | -| MD-09 | Sidebar media controls for background audio | P0 | – | – | – | | -| MD-10 | Several players: the media tray | P1 | – | – | – | | -| MD-11 | Audio and recording indicators, mute | P0 | – | – | – | | -| MD-12 | Media and the tab lifecycle | P0 | – | – | – | | -| MD-13 | Video calls: automatic call picture-in-picture | P1 | – | – | – | | -| MD-14 | Call controls in the sidebar | P1 | – | – | – | | +| MD-01 | Automatic picture-in-picture when leaving a video tab | P0 | 🟡 partial | ✅ e2e+unit | – | Orbit's own floating player: leaving a tab whose main video plays (tab, Space or split change; the largest playing video) moves it there within ~300 ms, still playing; not when paused, muted or turned off (MD-08); selecting the tab again puts it back in the page with no blank frame; moving between a split's panes doesn't float it; media inside frames (embedded players) isn't seen yet: no automatic PiP, and its controller has only Go to tab and Close, which leaves it playing | +| MD-02 | Open picture-in-picture manually | P1 | 🟡 partial | ✅ e2e | – | Picture in Picture in a video's page right-click menu, the Picture in Picture action and the tray's PiP button; while the tab stays selected the engine's own PiP window shows the video (Orbit's player needs the tab's view), so its controls are the engine's until the tab is left and Orbit's player takes over | +| MD-03 | The floating player's look and placement | P0 | ✅ | ✅ e2e+unit | – | frameless, always-on-top window, 320x180 for 16:9 (keeps the video's shape), bottom-right 16 px inside the work area; rounded corners (a window shape where the system has none) and shadow; video only until hovered, then controls over a dark gradient; the same player on every platform | +| MD-04 | Floating player controls | P0 | ✅ | ✅ e2e+unit | – | on hover: play/pause, +/-15 s, progress with elapsed and total time (seekable; LIVE badge for streams), Back to tab (selects it in its window and Space), x (keeps playing in the sidebar), ... menu (playback speed, turn off today / always for the site), previous/next for Media Session pages; double-click goes back to the tab | +| MD-05 | Keyboard and media keys for the floating player | P1 | 🟡 partial | ✅ e2e | – | with the player focused (clicked) Space plays/pauses and arrows seek 5 s (tested); hardware media keys go through Chromium's media key handling to the playing page, not verifiable under Xvfb | +| MD-06 | Move, resize and zoom the floating player | P1 | 🟡 partial | ✅ e2e+unit | – | drag or two-finger hover-scroll moves it and it glides to the corner the release or fling heads for; corner drag resizes keeping the shape, at least 200 px wide (tall videos too) and at most half the screen width and the screen's height, with rubber-band resistance; Cmd/Ctrl+scroll or pinch zooms (crops) inside the window; corner and size remembered; doesn't yet move aside for Orbit's alerts and dialogs | +| MD-07 | Ending picture-in-picture | P0 | ✅ | ✅ e2e | – | returning (select, double-click, Back to tab) ends it with the video playing in the page; x closes it and the media keeps playing in the sidebar controls; closing or unloading the tab stops media and player; navigating the tab or the video ending closes it, without replay | +| MD-08 | Turning automatic picture-in-picture off | P0 | 🟡 partial | ✅ e2e+unit | – | player ... menu Turn Off for Today / Always Turn Off for This Site (also in the tray controller menu); global switch (settings.autoPip, Picture in Picture action); turning off keeps playback (sidebar controls); muted tabs never trigger; the site controls checkbox (SB-04) and Settings > Advanced (ST-10) belong to other lanes (media.setSitePip and the setting are ready) | +| MD-09 | Sidebar media controls for background audio | P0 | 🟡 partial | ✅ e2e+unit | – | controller above the bottom bar: favicon, Media Session title/artist or tab title (marquee), play/pause, next, previous where supported, x pauses and hides; click jumps to the tab (switching Space); quiet audio counts; hidden while its tab is selected; no artwork yet; media inside frames (embedded players) isn't seen yet: no automatic PiP, and its controller has only Go to tab and Close, which leaves it playing | +| MD-10 | Several players: the media tray | P1 | ✅ | ✅ e2e+unit | – | one controller per background tab, newest first, appears and disappears by itself, works across windows; a video's controller has Picture in Picture (Back to PiP): the player grows out of the controller while the controller gives way | +| MD-11 | Audio and recording indicators, mute | P0 | 🟡 partial | ✅ e2e | – | pulsing camera/microphone indicator on tab rows (tracks the page shows); per-pane Mute/Unmute in the split row's menu (split lane's menu, tested here: only that pane goes silent); no screen-share indicator; no switcher indicators (TB-05 has no overlay yet) | +| MD-12 | Media and the tab lifecycle | P0 | ✅ | ✅ e2e+unit | – | playing (muted too), PiP and camera/mic tabs never sleep, auto-archive, Clear or Archive Below; closing stops media, PiP and controller; isAnyMediaPlaying() is ready for the updates lane | +| MD-13 | Video calls: automatic call picture-in-picture | P1 | 🟡 partial | ✅ e2e | – | leaving a call tab floats the largest participant video (never the self view) in Orbit's player with microphone/camera toggles and Back to call tab; moves and resizes like any video; no document picture-in-picture, leave-call or chat toggle | +| MD-14 | Call controls in the sidebar | P1 | 🟡 partial | ✅ e2e+unit | – | a background call tab (or a closed call window) gets a sidebar call controller: microphone and camera toggles (track.enabled on the tracks the page shows) and jump back; detection needs the page to show its own camera (a self view) | | MD-15 | Switch which tab is being screen-shared | P2 | – | – | – | | -| MD-16 | Feel: entering and leaving picture-in-picture | P1 | – | – | – | | +| MD-16 | Feel: entering and leaving picture-in-picture | P1 | 🟡 partial | ✅ e2e+unit | – | enter: the video shrinks from its place in the page to the corner (~300 ms, standard curve); Back to tab/double-click: the tab is selected at once and a still of the last frame flies back into the page; release glides to the corner; Back to PiP grows from the controller; tray slides with the snappy spring; with reduced motion (FL-28) the player appears and goes without motion and a release settles with a 120 ms ease-out; no haptic tick on macOS | ## Archive, recently closed, history and sidebar backups (prefix AR) @@ -357,7 +357,7 @@ Spec: `spec/shortcuts.md` | KS-08 | Page navigation and page tools | P0 | – | – | – | | | KS-09 | Split view | P0 | – | – | – | | | KS-10 | Peek and Mini Window | P1 | – | – | – | | -| KS-11 | Media | P1 | – | – | – | | +| KS-11 | Media | P1 | 🟡 partial | ✅ e2e | – | Space and arrows in the focused player, Cmd/Ctrl+scroll and pinch zoom (tested), hardware media keys via Chromium (untested), Mute Tab action assignable; the calendar meeting shortcut (P2) belongs to TB-21/FV-09 | | KS-12 | Library, history, downloads, settings, extensions, help | P1 | – | – | – | | | KS-13 | Developer tools | P1 | – | – | – | | | KS-14 | Canvases, notes, capture and Tweaks | P2 | – | – | – | | @@ -401,11 +401,11 @@ Spec: `spec/feel.md` | FL-25 | Menus, popovers and tooltips | P1 | – | – | – | | | FL-26 | Recent-tab switcher (Ctrl+Tab) | P1 | – | – | – | | | FL-27 | Window-level motion | P1 | – | – | – | | -| FL-28 | Reduced motion | P0 | – | – | – | | +| FL-28 | Reduced motion | P0 | – | ✅ e2e+unit | – | | | FL-29 | Responsiveness budgets (input to visual) | P0 | – | – | – | | | FL-30 | What makes it feel native and smooth | P0 | – | – | – | | | FL-31 | Haptics (macOS trackpads) | P2 | – | – | – | | -| FL-32 | Picture-in-picture and media tray motion | P2 | – | – | – | | +| FL-32 | Picture-in-picture and media tray motion | P2 | ✅ | ✅ e2e+unit | – | corner resize follows the pointer, resists past its limits (rubber band) and springs back on release; a fling heads for the corner in its direction and settles with the spring; tray slides up with the snappy timing; Back to tab flies to the video's place; pinch zooms (MD-06) rather than resizes | ## Appearance (global) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7ad97105..758b9cfc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -112,7 +112,7 @@ Rules: no synchronous disk I/O on hot paths after boot, no full-state broadcasts ## 9. Testing and CI - **Unit** (`vitest`): shared logic (tree moves, URL parsing, accelerators, theme math, persistence normalization), main-process models with fakes. Fast; run on every commit. -- **E2E** (`@playwright/test` `_electron`): `tests/e2e/helpers.ts` launches the built app with an isolated `userData` and `ORBIT_E2E=1`. That exposes `globalThis.__orbit` test hooks in main (state, view bounds, run command) and serves local fixture pages from a loopback HTTP server, so there's no network dependence. **Every test title starts with the feature IDs it covers**, e.g. `test('[TB-06][TB-07] close and reopen a tab', …)`. `scripts/features.mjs` reads those IDs to mark rows tested in `FEATURES.md`. +- **E2E** (`@playwright/test` `_electron`): `tests/e2e/helpers.ts` launches the built app with an isolated `userData` and `ORBIT_E2E=1`. That exposes `globalThis.__orbit` test hooks in main (state, view bounds, run command) and serves local fixture pages from a loopback HTTP server. The app runs behind a dead local proxy that loopback bypasses, and `launchOrbit` waits until every profile's session uses it, so a test that searches or follows a link to a real site gets the URL but loads nothing. Playwright reaches main through the inspector, which can interrupt main-process code part-way, even inside a store update. So `launchOrbit` makes each `app.evaluate` start in a task of its own, and the hooks' `run` and `action` defer the same way (D25). Main logs an exception nothing caught as `[orbit] uncaught exception:` rather than letting Electron show its modal box, which blocks quitting. `app.close()` fails a test whose main process logged one (`takeUncaught()` claims one thrown on purpose), and kills an app that hasn't quit 15 s after being asked (D28). **Every test title starts with the feature IDs it covers**, e.g. `test('[TB-06][TB-07] close and reopen a tab', …)`. `scripts/features.mjs` reads those IDs to mark rows tested in `FEATURES.md`. - **CI** (`.github/workflows/ci.yml`): typecheck + lint + unit (Linux), e2e under Xvfb (Linux), and build + unit + e2e + unsigned `.dmg`/`.zip` packaging (macOS). Green CI is required before merge. - **Feel checks and clips**: `scripts/capture/record.mjs ` drives the built app through `scripts/capture/scenarios/.mjs` while ffmpeg (`ORBIT_FFMPEG`, needs x11grab) records the X display, so native tab views are captured exactly as composited. Clips are named by feature ID and stored in `clips/` in the Orca context directory, screenshots in `screenshots/`. "Feels right" in `FEATURES.md` is a lead sign-off against `feel.md`. diff --git a/docs/feature-status.json b/docs/feature-status.json index 214351bc..3a7526d3 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -27,8 +27,8 @@ "TB-12": {"built": "yes", "note": "double-click/menu rename, empty reverts; emoji icon picker"}, "TB-13": {"built": "partial", "note": "Cmd-click, Shift-click ranges, Escape/plain click clears; Cmd+W, Copy URL, menu pin/unpin/move/new folder and group drag act on the selection; open-as-split is the split lane's"}, "TB-14": {"built": "partial", "note": "native menus with every tab, pinned, Favorite, folder and multi-selection item; not theme-coloured; split/share items belong to other lanes"}, - "TB-15": {"built": "partial", "note": "audio icon toggles mute; no mic/camera/screen-share indicators"}, - "TB-16": {"built": "partial", "note": "sleep timer (settings.sleepAfterMs), Favorites warm 4x, audible/selected never sleep"}, + "TB-15": {"built": "partial", "note": "audio icon toggles mute; camera/microphone indicator (media lane); no screen-share indicator or Favorites notes"}, + "TB-16": {"built": "partial", "note": "sleep timer (settings.sleepAfterMs), Favorites warm 4x; selected, audible, playing (muted too), PiP and camera/mic tabs never sleep"}, "TB-17": {"built": "partial", "note": "tab handoff; no two windows ever show the same tab (close, Clear, archive, moves, undo, drag-out); no blank window yet"}, "TB-18": {"built": "yes", "note": "Cmd+Z undoes moves, close, Clear, archive below, renames, icons and folder changes (page text fields keep their own undo); Cmd+Shift+Z redoes moves, closes and renames"}, "TB-19": {"built": "partial", "note": "Back with no history closes a link-opened Today tab and selects its opener (shortcut/command)"}, @@ -154,6 +154,24 @@ "PR-07": {"built": "yes", "note": "hover badge on the title row, 'Space · Profile' tooltip, check mark in the Profile submenu"}, "PR-08": {"built": "partial", "note": "profiles.delete refuses Default and profiles in use, erases the profile's Favorites and clears its stored data; the partition folder stays on disk and the Settings button is the settings lane's"}, "PR-09": {"built": "partial", "note": "profiles.rename command; the Settings UI is the settings lane's"}, + "MD-01": {"built": "partial", "note": "Orbit's own floating player: leaving a tab whose main video plays (tab, Space or split change; the largest playing video) moves it there within ~300 ms, still playing; not when paused, muted or turned off (MD-08); selecting the tab again puts it back in the page with no blank frame; moving between a split's panes doesn't float it; media inside frames (embedded players) isn't seen yet: no automatic PiP, and its controller has only Go to tab and Close, which leaves it playing"}, + "MD-02": {"built": "partial", "note": "Picture in Picture in a video's page right-click menu, the Picture in Picture action and the tray's PiP button; while the tab stays selected the engine's own PiP window shows the video (Orbit's player needs the tab's view), so its controls are the engine's until the tab is left and Orbit's player takes over"}, + "MD-03": {"built": "yes", "note": "frameless, always-on-top window, 320x180 for 16:9 (keeps the video's shape), bottom-right 16 px inside the work area; rounded corners (a window shape where the system has none) and shadow; video only until hovered, then controls over a dark gradient; the same player on every platform"}, + "MD-04": {"built": "yes", "note": "on hover: play/pause, +/-15 s, progress with elapsed and total time (seekable; LIVE badge for streams), Back to tab (selects it in its window and Space), x (keeps playing in the sidebar), ... menu (playback speed, turn off today / always for the site), previous/next for Media Session pages; double-click goes back to the tab"}, + "MD-05": {"built": "partial", "note": "with the player focused (clicked) Space plays/pauses and arrows seek 5 s (tested); hardware media keys go through Chromium's media key handling to the playing page, not verifiable under Xvfb"}, + "MD-06": {"built": "partial", "note": "drag or two-finger hover-scroll moves it and it glides to the corner the release or fling heads for; corner drag resizes keeping the shape, at least 200 px wide (tall videos too) and at most half the screen width and the screen's height, with rubber-band resistance; Cmd/Ctrl+scroll or pinch zooms (crops) inside the window; corner and size remembered; doesn't yet move aside for Orbit's alerts and dialogs"}, + "MD-07": {"built": "yes", "note": "returning (select, double-click, Back to tab) ends it with the video playing in the page; x closes it and the media keeps playing in the sidebar controls; closing or unloading the tab stops media and player; navigating the tab or the video ending closes it, without replay"}, + "MD-08": {"built": "partial", "note": "player ... menu Turn Off for Today / Always Turn Off for This Site (also in the tray controller menu); global switch (settings.autoPip, Picture in Picture action); turning off keeps playback (sidebar controls); muted tabs never trigger; the site controls checkbox (SB-04) and Settings > Advanced (ST-10) belong to other lanes (media.setSitePip and the setting are ready)"}, + "MD-09": {"built": "partial", "note": "controller above the bottom bar: favicon, Media Session title/artist or tab title (marquee), play/pause, next, previous where supported, x pauses and hides; click jumps to the tab (switching Space); quiet audio counts; hidden while its tab is selected; no artwork yet; media inside frames (embedded players) isn't seen yet: no automatic PiP, and its controller has only Go to tab and Close, which leaves it playing"}, + "MD-10": {"built": "yes", "note": "one controller per background tab, newest first, appears and disappears by itself, works across windows; a video's controller has Picture in Picture (Back to PiP): the player grows out of the controller while the controller gives way"}, + "MD-11": {"built": "partial", "note": "pulsing camera/microphone indicator on tab rows (tracks the page shows); per-pane Mute/Unmute in the split row's menu (split lane's menu, tested here: only that pane goes silent); no screen-share indicator; no switcher indicators (TB-05 has no overlay yet)"}, + "MD-12": {"built": "yes", "note": "playing (muted too), PiP and camera/mic tabs never sleep, auto-archive, Clear or Archive Below; closing stops media, PiP and controller; isAnyMediaPlaying() is ready for the updates lane"}, + "MD-13": {"built": "partial", "note": "leaving a call tab floats the largest participant video (never the self view) in Orbit's player with microphone/camera toggles and Back to call tab; moves and resizes like any video; no document picture-in-picture, leave-call or chat toggle"}, + "MD-14": {"built": "partial", "note": "a background call tab (or a closed call window) gets a sidebar call controller: microphone and camera toggles (track.enabled on the tracks the page shows) and jump back; detection needs the page to show its own camera (a self view)"}, + "MD-16": {"built": "partial", "note": "enter: the video shrinks from its place in the page to the corner (~300 ms, standard curve); Back to tab/double-click: the tab is selected at once and a still of the last frame flies back into the page; release glides to the corner; Back to PiP grows from the controller; tray slides with the snappy spring; with reduced motion (FL-28) the player appears and goes without motion and a release settles with a 120 ms ease-out; no haptic tick on macOS"}, + "FL-32": {"built": "yes", "note": "corner resize follows the pointer, resists past its limits (rubber band) and springs back on release; a fling heads for the corner in its direction and settles with the spring; tray slides up with the snappy timing; Back to tab flies to the video's place; pinch zooms (MD-06) rather than resizes"}, + "KS-11": {"built": "partial", "note": "Space and arrows in the focused player, Cmd/Ctrl+scroll and pinch zoom (tested), hardware media keys via Chromium (untested), Mute Tab action assignable; the calendar meeting shortcut (P2) belongs to TB-21/FV-09"}, + "SB-10": {"built": "partial", "note": "media tray and call controls (media lane), above the bottom bar, pushing the list up; download progress and the update banner belong to other lanes"}, "PK-01": {"built": "yes", "note": "floating card over the source tab; one Peek per window; links inside navigate it"}, "PK-02": {"built": "yes", "note": "other-site (eTLD+1) link clicks, Enter on a focused link, and target=_blank in pinned tabs/Favorites; same-site navigates. Only a navigation to the clicked link counts, so form posts, script navigations and auto-posted sign-ins stay in the tab"}, "PK-03": {"built": "yes", "note": "Shift-click in any tab; link menu 'Open Link in Peek'"}, diff --git a/src/main/context.ts b/src/main/context.ts index cc8485d0..862216c9 100644 --- a/src/main/context.ts +++ b/src/main/context.ts @@ -1,4 +1,10 @@ -import type { HandlerDetails, WebContents, WindowOpenHandlerResponse } from 'electron' +import type { + ContextMenuParams, + HandlerDetails, + MenuItemConstructorOptions, + WebContents, + WindowOpenHandlerResponse, +} from 'electron' import type { TabId } from '../shared/state' import type { Perf } from './perf' import type { ActionRegistry, CommandRegistry, EventRegistry } from './registry' @@ -17,6 +23,8 @@ export interface Hooks { tabCreated: ((tabId: TabId, contents: WebContents) => void)[] /** Called when a tab's main frame finished a navigation (url committed). */ tabNavigated: ((tabId: TabId, contents: WebContents, url: string) => void)[] + /** Extra items at the top of a page's right-click menu (e.g. Picture in Picture on a video, MD-02). */ + pageContextMenu?: ((tabId: TabId, contents: WebContents, params: ContextMenuParams) => MenuItemConstructorOptions[])[] } /** Everything a feature module needs. Passed to each module's register(). */ diff --git a/src/main/index.ts b/src/main/index.ts index fb2cff53..a70b9e20 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,6 +17,10 @@ import { WindowManager } from './windows' const perf = new Perf() perf.mark('main:start') +// An exception nothing caught is logged, and Orbit carries on. Electron's default shows it in a modal +// box that blocks the main thread until someone dismisses it: until then Orbit can't even quit (D28). +process.on('uncaughtException', (err) => console.error('[orbit] uncaught exception:', err)) + // Tests and perf runs isolate their profile data. if (process.env.ORBIT_USER_DATA) app.setPath('userData', process.env.ORBIT_USER_DATA) const e2e = process.env.ORBIT_E2E === '1' diff --git a/src/main/media/agent.ts b/src/main/media/agent.ts new file mode 100644 index 00000000..ad7bcdac --- /dev/null +++ b/src/main/media/agent.ts @@ -0,0 +1,613 @@ +/** + * Orbit's media page script (media lane). It runs in an isolated world, so the page can't see or + * change it, and only in tabs that started playing media (installed by MediaManager on + * `media-started-playing` / audible). It watches the page's media elements with capture-phase + * listeners (no timers, no polling) and reports changes through a long-poll: the main process + * calls `__orbitMedia.next(v)`, which resolves with a fresh report when something changed after + * report `v`. Commands (play, pause, seek, speed, picture-in-picture, call toggles) run here too. + * + * Orbit's floating player (MD-03, player.ts) borrows the tab's view. Here the chosen video is + * lifted into the page's top layer (a manual popover) and drawn at the player's size at the + * top-left of the page, over a black backdrop; the player window shows just that corner of the + * view. The page's own layout doesn't change, so returning to the tab is seamless: the main + * process makes the borrowed view a little taller than the page area, and the page's `resize` + * when the view is placed back in its window puts the video back before that frame is drawn. + * + * Reports follow `AgentSnapshot` in src/shared/media.ts. + */ + +/** Isolated world for the media script (the tabs runtime uses 999, the fullscreen fix 1001). */ +export const MEDIA_WORLD = 1010 + +/** Installs the script once per document; returns the first report either way. */ +export const AGENT_SOURCE = `(() => { + const w = window + if (w.__orbitMedia) return w.__orbitMedia.state() + let version = 1 + let waiter = null + let timer = 0 + let lastPlayed = null + let pausedByOrbit = [] + let orbitExit = false + let pipEnd = null + let pipEnds = 0 + const watchedTracks = new WeakSet() + + const list = () => Array.from(document.querySelectorAll('video, audio')) + const running = (m) => !m.paused && !m.ended + const unmuted = (m) => !m.muted && m.volume > 0 + const frames = (m) => m.tagName === 'VIDEO' && m.videoWidth > 0 && m.videoHeight > 0 + const area = (m) => { + const r = m.getBoundingClientRect() + return r.width * r.height + } + const seekable = (m) => Number.isFinite(m.duration) && m.duration > 0 && m.seekable.length > 0 + const seekEnd = (m) => Math.min(m.duration, m.seekable.end(m.seekable.length - 1)) + const clamp = (n, lo, hi) => Math.min(Math.max(n, lo), hi) + + // The media the controls act on: the video in Orbit's player; else playing first, then + // audible, then the last one played, then the largest video. + function main() { + if (lift) return lift.v + let best = null + let bestScore = -1 + for (const m of list()) { + const started = running(m) || m === lastPlayed || (m.currentTime > 0 && !m.ended) + if (!started) continue + let s = 0 + if (running(m)) s += 8 + if (unmuted(m)) s += 4 + if (m === lastPlayed) s += 2 + if (frames(m)) s += 1 + (Math.min(area(m), 4e6) / 4e6) * 0.9 + if (s > bestScore) { + best = m + bestScore = s + } + } + return best + } + + // The page's main video for automatic picture-in-picture (MD-01): the largest playing, unmuted, + // visible video. Muted decorative loops and hidden players don't count. In a call (the page + // uses the camera or microphone), the largest participant video counts even when muted (call + // apps often play voices through separate audio elements), never the self view (MD-13). + function pipVideo() { + const inCall = localTracks().length > 0 + let best = null + let bestArea = 64 * 36 - 1 + for (const m of list()) { + if (m.tagName !== 'VIDEO' || !running(m) || !frames(m) || m.disablePictureInPicture) continue + if (!unmuted(m) && !(inCall && remoteVideo(m))) continue + const a = area(m) + if (a > bestArea) { + best = m + bestArea = a + } + } + return best + } + + // A video showing a stream from elsewhere (a call participant), not this device's camera. + function remoteVideo(m) { + const s = m.srcObject + if (!s || typeof s.getVideoTracks !== 'function') return false + const tracks = s.getVideoTracks() + return tracks.length > 0 && tracks.every((t) => { + let device = '' + try { + device = t.getSettings().deviceId || '' + } catch {} + return !inputs.has(device) + }) + } + + // The system's camera and microphone inputs. A page's own canvas or Web Audio streams have + // device ids too, so a track counts as the camera or microphone only if its device is one of these. + let inputs = new Set() + const askedAbout = new Set() + let enumerating = false + function refreshInputs() { + if (enumerating || !navigator.mediaDevices) return + enumerating = true + navigator.mediaDevices.enumerateDevices().then( + (list) => { + enumerating = false + const ids = list.filter((d) => d.kind === 'audioinput' || d.kind === 'videoinput').map((d) => d.deviceId) + inputs = new Set(ids.filter(Boolean)) + bump() + }, + () => { + enumerating = false + }, + ) + } + if (navigator.mediaDevices) navigator.mediaDevices.addEventListener('devicechange', refreshInputs) + + // Live camera and microphone tracks the page shows (a call's self view), best effort (MD-11). + function localTracks() { + const out = [] + let ask = false + for (const m of list()) { + const s = m.srcObject + if (!s || typeof s.getTracks !== 'function') continue + for (const t of s.getTracks()) { + if (t.readyState !== 'live') continue + let device = '' + try { + device = t.getSettings().deviceId || '' + } catch {} + if (!device) continue + if (!inputs.has(device)) { + // Look the device up once (a canvas or Web Audio stream never matches). + if (!askedAbout.has(device)) { + askedAbout.add(device) + ask = true + } + continue + } + if (!watchedTracks.has(t)) { + watchedTracks.add(t) + t.addEventListener('ended', () => bump()) + } + if (!out.includes(t)) out.push(t) + } + } + if (ask) refreshInputs() + return out + } + + // Playback of a media element, for the floating player's progress (MD-04). + function timeOf(m) { + if (!m) return null + const d = m.duration + const live = d === Infinity || (!!m.srcObject && !Number.isFinite(d)) + return { + position: Number.isFinite(m.currentTime) ? m.currentTime : 0, + duration: Number.isFinite(d) && d > 0 ? d : 0, + rate: m.playbackRate || 1, + paused: m.paused || m.ended, + live, + at: Date.now(), + } + } + + function state() { + const m = main() + let playing = false + let active = false + for (const x of list()) { + if (!running(x)) continue + active = true + if (unmuted(x)) playing = true + } + let title = '' + let artist = '' + try { + const md = navigator.mediaSession && navigator.mediaSession.metadata + if (md) { + title = String(md.title || '').slice(0, 200) + artist = String(md.artist || '').slice(0, 200) + } + } catch {} + const tracks = localTracks() + const mics = tracks.filter((t) => t.kind === 'audio') + const cams = tracks.filter((t) => t.kind === 'video') + return { + v: version, + playing, + active, + controllable: !!m, + video: !!(m && frames(m)), + pip: !!document.pictureInPictureElement || !!lift, + player: !!lift, + aspect: lift && frames(lift.v) ? lift.v.videoWidth / lift.v.videoHeight : 0, + pipCandidate: !!pipVideo(), + title, + artist, + seekable: !!(m && seekable(m)), + ended: !!(m && m.ended), + time: timeOf(m), + pipEnd, + mic: mics.length > 0, + micMuted: mics.length > 0 && mics.every((t) => !t.enabled), + camera: cams.length > 0, + cameraOff: cams.length > 0 && cams.every((t) => !t.enabled), + } + } + + function bump(now) { + version++ + if (!waiter) return + if (now) { + clearTimeout(timer) + flush() + } else if (!timer) timer = setTimeout(flush, 40) + } + function flush() { + timer = 0 + const resolve = waiter + waiter = null + if (resolve) resolve(state()) + } + + const EVENTS = ['play', 'playing', 'pause', 'ended', 'emptied', 'loadedmetadata', 'durationchange', 'volumechange', + 'resize', 'enterpictureinpicture', 'seeked', 'ratechange', 'waiting'] + for (const type of EVENTS) { + document.addEventListener( + type, + (e) => { + if (type === 'play' && e.target instanceof HTMLMediaElement) lastPlayed = e.target + bump() + // Track details (Media Session) often follow a track change by a moment. + if (type === 'loadedmetadata' || type === 'play') setTimeout(bump, 1000) + }, + true, + ) + } + + // A finished video leaves picture-in-picture and doesn't replay (MD-07). + document.addEventListener( + 'ended', + (e) => { + if (e.target === document.pictureInPictureElement && !e.target.loop) exitPip() + // Orbit's player closes first (main hides the window, then puts the video back). + if (lift && e.target === lift.v && !lift.v.loop) endPip('ended') + }, + true, + ) + + function endPip(reason) { + pipEnd = { n: ++pipEnds, reason } + bump(true) + } + + // How the engine's own picture-in-picture ended (MD-07). Its × pauses a file-backed video right + // after leaving and its "Back to tab" doesn't, which tells the two apart. × keeps the media + // playing as audio (it then shows in the sidebar). A live stream can't tell them apart: treated as ×. + document.addEventListener( + 'leavepictureinpicture', + (e) => { + const v = e.target + if (orbitExit) { + orbitExit = false + endPip('orbit') + return + } + if (v.ended) return endPip('ended') + if (v.paused || v.srcObject) return endPip('close') + let done = false + const onPause = () => { + if (done) return + done = true + clearTimeout(timeout) + v.play().catch(() => {}) + endPip('close') + } + const timeout = setTimeout(() => { + if (done) return + done = true + v.removeEventListener('pause', onPause) + endPip('back') + }, 300) + v.addEventListener('pause', onPause, { once: true }) + }, + true, + ) + + async function exitPip() { + if (!document.pictureInPictureElement) return false + orbitExit = true + try { + await document.exitPictureInPicture() + } catch { + orbitExit = false + } + return false + } + + // The engine's own picture-in-picture window, used while the tab stays on screen (MD-02). + // auto: only the page's main playing, unmuted video (MD-01); otherwise any video with frames. + async function pip(on, auto) { + if (!on) return exitPip() + const m = main() + const v = pipVideo() || (!auto && m && frames(m) && !m.disablePictureInPicture ? m : null) + if (!v) return false + if (document.pictureInPictureElement === v) return true + try { + await v.requestPictureInPicture() + return true + } catch { + return false + } + } + + // ---- Orbit's floating player (MD-03, MD-04, MD-06) ---- + + const LIFT = 'data-orbit-pip' + // The lift's rules weigh three ids more than the attribute: a page's own !important rules on its + // video (say '#player video{width:100%!important}') must not win over them. + const LIFTED = 'video[' + LIFT + ']:not(#_):not(#_):not(#_)' + let lift = null + let chosen = null + let removal = null + + // The video to float: the one under a point (a context menu), the page's main playing video + // (automatic), or else the main video with frames. + function choose(o) { + if (o && Number.isFinite(o.x) && Number.isFinite(o.y)) { + const el = document.elementFromPoint(o.x, o.y) + const v = el && (el.tagName === 'VIDEO' ? el : el.closest('video')) + if (v && frames(v)) return v + } + const v = pipVideo() + if (v || (o && o.auto)) return v + const m = main() + return m && frames(m) && !m.disablePictureInPicture ? m : null + } + + // Picks the video and says where it is in the page (CSS px) and its shape; nothing changes yet. + // A video in the engine's own picture-in-picture window moves over to Orbit's player. + async function probe(o) { + const was = document.pictureInPictureElement + if (was) await exitPip() + const v = lift ? lift.v : was && was.isConnected && frames(was) ? was : choose(o) + chosen = v + if (!v) return { ok: false } + const r = v.getBoundingClientRect() + const visible = r.width > 0 && r.height > 0 && r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth + return { + ok: true, + aspect: v.videoWidth / v.videoHeight, + rect: visible ? { x: r.left, y: r.top, width: r.width, height: r.height } : null, + } + } + + function css() { + const l = lift + const t = l.ms > 0 ? 'width ' + l.ms + 'ms cubic-bezier(0.2,0.9,0.1,1),height ' + l.ms + 'ms cubic-bezier(0.2,0.9,0.1,1)' : 'none' + const zoom = l.scale > 1 ? 'translate(' + l.tx + 'px,' + l.ty + 'px) scale(' + l.scale + ')' : 'none' + return LIFTED + '{position:fixed!important;inset:0 auto auto 0!important;left:0!important;top:0!important;' + + 'width:' + l.w + 'px!important;height:' + l.h + 'px!important;min-width:0!important;min-height:0!important;' + + 'max-width:none!important;max-height:none!important;margin:0!important;padding:0!important;border:0!important;' + + 'border-radius:0!important;box-sizing:border-box!important;background:#000!important;object-fit:contain!important;' + + 'object-position:50% 50%!important;opacity:1!important;visibility:visible!important;display:block!important;' + + 'clip-path:none!important;filter:none!important;mask:none!important;overflow:hidden!important;' + + 'transform-origin:0 0!important;transform:' + zoom + '!important;transition:' + t + '!important;' + + 'z-index:2147483647!important;pointer-events:none!important}' + + LIFTED + '::backdrop{background:#000!important}' + + LIFTED + '::-webkit-media-controls{display:none!important}' + } + + function paint() { + if (!lift) return + const text = css() + if (lift.sheet) lift.sheet.replaceSync(text) + else if (lift.style) lift.style.textContent = text + } + + // The video goes to the top layer at size 'from', then grows or shrinks to 'to' over ms + // (the player window does the same, MD-16). arm: the page's size while it is in the player. + // Nothing changes on screen until the page has that size: the tab may still be drawn in its + // window for a frame or two after leaving it, and must look the same there. + function liftVideo(o) { + const v = lift ? lift.v : chosen + if (!v || !v.isConnected) return false + if (!lift) lift = { v, w: o.from.w, h: o.from.h, ms: 0, scale: 1, tx: 0, ty: 0, armW: 0, armH: 0, shown: false } + if (o.arm) { + lift.armW = o.arm.w + lift.armH = o.arm.h + } + lift.w = o.from.w + lift.h = o.from.h + lift.ms = 0 + if (!lift.shown && (!lift.armW || armed())) show() + paint() + if (o.ms > 0 && (o.to.w !== o.from.w || o.to.h !== o.from.h)) { + if (lift.shown) v.getBoundingClientRect() + lift.ms = o.ms + } + lift.w = o.to.w + lift.h = o.to.h + paint() + bump(true) + return true + } + + const armed = () => Math.abs(innerWidth - lift.armW) <= 2 && Math.abs(innerHeight - lift.armH) <= 2 + + function show() { + const l = lift + const v = l.v + l.shown = true + l.popover = v.getAttribute('popover') + try { + l.sheet = new CSSStyleSheet() + document.adoptedStyleSheets = [...document.adoptedStyleSheets, l.sheet] + } catch { + l.sheet = null + l.style = document.createElement('style') + document.documentElement.appendChild(l.style) + } + v.setAttribute(LIFT, '') + paint() + if (v.popover !== 'manual') v.popover = 'manual' + try { + if (!v.matches(':popover-open')) v.showPopover() + } catch {} + watchRemoval(v) + } + + // The player was resized (MD-06): the video follows at once; a zoomed crop keeps its place. + function sizeVideo(wd, ht, ms) { + if (!lift) return false + const k = lift.w > 0 ? wd / lift.w : 1 + lift.tx *= k + lift.ty *= k + lift.w = wd + lift.h = ht + lift.ms = ms || 0 + clampZoom() + paint() + return true + } + + function clampZoom() { + const l = lift + l.scale = clamp(l.scale, 1, 4) + l.tx = clamp(l.tx, l.w * (1 - l.scale), 0) + l.ty = clamp(l.ty, l.h * (1 - l.scale), 0) + } + + // Zoom into the video around a point of the player (CSS px), cropping inside the same window (MD-06). + function zoomVideo(factor, px, py) { + if (!lift || !(factor > 0)) return 0 + const next = clamp(lift.scale * factor, 1, 4) + const f = next / lift.scale + lift.tx = px + f * (lift.tx - px) + lift.ty = py + f * (lift.ty - py) + lift.scale = next + lift.ms = 0 + clampZoom() + paint() + return lift.scale + } + + // Puts the video back in the page. + function drop() { + if (!lift) return false + const l = lift + lift = null + chosen = null + if (removal) removal.disconnect() + removal = null + if (!l.shown) { + bump(true) + return true + } + try { + if (l.v.matches(':popover-open')) l.v.hidePopover() + } catch {} + if (l.popover === null) l.v.removeAttribute('popover') + else l.v.setAttribute('popover', l.popover) + l.v.removeAttribute(LIFT) + if (l.sheet) document.adoptedStyleSheets = document.adoptedStyleSheets.filter((s) => s !== l.sheet) + if (l.style) l.style.remove() + bump(true) + return true + } + + // A video removed from the page leaves the top layer at once: the player has to close (MD-07). + function watchRemoval(v) { + if (removal) removal.disconnect() + removal = new MutationObserver(() => { + if (lift && lift.v === v && !v.isConnected) endPip('gone') + }) + removal.observe(document, { childList: true, subtree: true }) + } + + // In the player, the page takes the size it was armed with and the video lifts in that frame. + // Back on screen, the size changes again and the video goes back into the page before that + // frame is drawn (MD-01: no blank frame). Until the lift shows, other sizes are late news from + // the tab's own window (a split's panes still settling) and leave it armed; the player drops + // it if it gives up. + window.addEventListener('resize', () => { + if (!lift || !lift.armW) return + if (armed()) { + if (!lift.shown) show() + } else if (lift.shown) drop() + }, true) + + // ---- Controls ---- + + function control(action, value) { + if (lift) return controlOne(lift.v, action, value) + const all = list() + if (action === 'pause' || (action === 'toggle' && all.some(running))) { + pausedByOrbit = all.filter(running) + for (const x of pausedByOrbit) x.pause() + return pausedByOrbit.length > 0 + } + const m = main() + if (action === 'play' || action === 'toggle') { + const again = pausedByOrbit.filter((x) => x.isConnected) + pausedByOrbit = [] + const targets = again.length > 0 ? again : m ? [m] : [] + for (const x of targets) x.play().catch(() => {}) + return targets.length > 0 + } + return m ? controlOne(m, action, value) : false + } + + function controlOne(m, action, value) { + const n = Number(value) || 0 + if (action === 'toggle') action = running(m) ? 'pause' : 'play' + if (action === 'pause') { + m.pause() + return true + } + if (action === 'play') { + if (m.ended) m.currentTime = 0 + m.play().catch(() => {}) + return true + } + if (action === 'rate') { + if (!(n >= 0.25 && n <= 4)) return false + m.playbackRate = n + return true + } + if (!seekable(m)) return false + if (action === 'seek') { + m.currentTime = clamp(m.currentTime + n, 0, seekEnd(m)) + return true + } + if (action === 'seekTo') { + m.currentTime = clamp(n, 0, seekEnd(m)) + return true + } + // Next: play to the end of the track, so the page moves on to its next one. + if (action === 'next') { + m.currentTime = seekEnd(m) + return true + } + if (action === 'previous') { + m.currentTime = 0 + if (m.paused) m.play().catch(() => {}) + return true + } + return false + } + + // Call controls (MD-14): turn the page's own camera or microphone tracks off or on again. + function call(action) { + const kind = action === 'mic' ? 'audio' : action === 'camera' ? 'video' : '' + const tracks = localTracks().filter((t) => t.kind === kind) + if (tracks.length === 0) return false + const enable = tracks.every((t) => !t.enabled) + for (const t of tracks) t.enabled = enable + bump() + return true + } + + w.__orbitMedia = { + state, + refresh: bump, + next(v) { + if (v !== version) return state() + // One waiting report per document: an older loop (say, from before a restart) ends. + if (waiter) waiter(null) + return new Promise((resolve) => { + waiter = resolve + }) + }, + control, + pip, + call, + probe, + lift: liftVideo, + size: sizeVideo, + zoom: zoomVideo, + drop, + lifted: () => !!lift, + } + return state() +})()` diff --git a/src/main/media/index.ts b/src/main/media/index.ts index 17cc8e2d..83cba3d6 100644 --- a/src/main/media/index.ts +++ b/src/main/media/index.ts @@ -1,4 +1,144 @@ +import { app, Menu, type MenuItemConstructorOptions } from 'electron' +import type { TabId } from '../../shared/state' import type { MainContext } from '../context' +import { activeTabOf } from '../windows' +import { MEDIA_WORLD } from './agent' +import { MediaManager } from './manager' -/** Media feature (main process). Owned by its build lane; see docs/ARCHITECTURE.md. */ -export function registerMedia(_ctx: MainContext): void {} +/** + * Hardware media keys (play/pause, next, previous) control the playing media and the PiP video, as + * in other browsers (MD-05, KS-11). Electron leaves Chromium's handling off; it has to be switched + * on before the app is ready, so this runs when the module loads. Merged into any features other + * modules enabled. + */ +function enableFeatures(...names: string[]): void { + const current = app.commandLine.getSwitchValue('enable-features') + const list = current ? current.split(',').filter(Boolean) : [] + for (const name of names) if (!list.includes(name)) list.push(name) + app.commandLine.appendSwitch('enable-features', list.join(',')) +} +enableFeatures('HardwareMediaKeyHandling') + +/** Test-only handle (ORBIT_E2E=1): globalThis.__orbitMedia. */ +export interface MediaTestHooks { + manager: MediaManager + lastMenu: () => string[] + clickMenu: (path: string) => boolean + /** The floating player's last "…" menu. */ + playerMenu: () => string[] + clickPlayerMenu: (path: string) => boolean +} + +/** Picture-in-picture, sidebar media controls and call controls (media lane; spec media.md). */ +export function registerMedia(ctx: MainContext): MediaManager { + const media = new MediaManager(ctx) + media.start() + const { commands, actions, store, windows } = ctx + const windowOf = (windowId: string | undefined) => windowId ?? windows.focusedWindowId() + + commands.register('media.control', ({ tabId, action, seconds }) => media.control(tabId, action, seconds)) + commands.register('media.pip', async ({ tabId, on = true, from }, c) => { + const windowId = windowOf(c.windowId) + const id = tabId ?? (windowId ? activeTabOf(store.state, windowId) : null) + return id ? media.setPip(id, on, from && windowId ? { windowId, rect: from } : undefined) : false + }) + commands.register('media.dismiss', ({ tabId }) => media.dismiss(tabId)) + commands.register('media.focus', ({ tabId }, c) => { + const windowId = windowOf(c.windowId) + if (windowId) media.focusTab(tabId, windowId) + }) + commands.register('media.menu', ({ tabId }, c) => { + const windowId = windowOf(c.windowId) + const items = media.menu(tabId, windowId) + if (items.length === 0) return + media.lastMenu = items + if (ctx.e2e) return + const win = windowId ? windows.get(windowId) : undefined + Menu.buildFromTemplate(items).popup(win ? { window: win } : undefined) + }) + commands.register('media.sitePip', ({ url }) => media.sitePip(url)) + commands.register('media.setSitePip', ({ url, mode }) => media.setSitePip(url, mode)) + commands.register('media.call', ({ tabId, action }) => media.callControl(tabId, action)) + + // The floating player's own controls (MD-04 to MD-07). + const player = media.player + commands.register('media.pipBack', () => player.back()) + commands.register('media.pipClose', () => player.close('close')) + commands.register('media.pipMenu', ({ x, y }) => player.menu(x, y)) + commands.register('media.pipZoom', ({ factor, x, y }) => player.zoom(factor, x, y)) + ctx.events.on('media.pipGesture', (e) => player.onGesture(e)) + + // Picture in Picture for the selected tab's video (MD-02), and the global switch (MD-08, ST-10). + actions.register('media.togglePip', ({ windowId }) => { + const id = windowId ? activeTabOf(store.state, windowId) : null + if (!id) return + void media.setPip(id, !store.state.tabs[id]?.media?.pip) + }) + actions.register('media.toggleAutoPip', ({ windowId }) => { + const on = !store.state.settings.autoPip + store.update((d) => { + d.settings.autoPip = on + }) + ctx.toast(windowId, on ? 'Automatic Picture in Picture is on' : 'Automatic Picture in Picture is off') + }) + + // The page's right-click menu on a video offers Picture in Picture (MD-02). + ctx.hooks.pageContextMenu ??= [] + ctx.hooks.pageContextMenu.push((tabId, contents, params): MenuItemConstructorOptions[] => { + if (params.mediaType !== 'video' || !params.mediaFlags?.canShowPictureInPicture) return [] + const showing = params.mediaFlags.isShowingPictureInPicture + const zoom = contents.getZoomFactor() || 1 + const x = Math.round(params.x / zoom) + const y = Math.round(params.y / zoom) + // The video under the pointer, not the page's main one. + const code = showing + ? 'document.exitPictureInPicture().then(() => true, () => false)' + : `(() => { const el = document.elementFromPoint(${x}, ${y}); const v = el && (el.tagName === 'VIDEO' ? el : el.closest('video')); return v ? v.requestPictureInPicture().then(() => true, () => false) : false })()` + return [ + { + label: 'Picture in Picture', + type: 'checkbox', + checked: showing, + click: () => { + void contents + .executeJavaScriptInIsolatedWorld(MEDIA_WORLD, [{ code }], true) + .catch(() => false) + // The page script follows picture-in-picture from now on (tray, return to the tab). + .then(() => media.watchTab(tabId as TabId)) + }, + }, + { type: 'separator' }, + ] + }) + + if (ctx.e2e) { + const hooks: MediaTestHooks = { + manager: media, + lastMenu: () => flatLabels(media.lastMenu), + clickMenu: (path) => clickMenuPath(media.lastMenu, path), + playerMenu: () => flatLabels(player.lastMenu), + clickPlayerMenu: (path) => clickMenuPath(player.lastMenu, path), + } + ;(globalThis as unknown as { __orbitMedia: MediaTestHooks }).__orbitMedia = hooks + } + return media +} + +function flatLabels(items: MenuItemConstructorOptions[], prefix = ''): string[] { + const out: string[] = [] + for (const item of items) { + if (!item.label) continue + out.push(`${prefix}${item.label}`) + if (Array.isArray(item.submenu)) out.push(...flatLabels(item.submenu, `${prefix}${item.label} > `)) + } + return out +} + +function clickMenuPath(items: MenuItemConstructorOptions[], path: string): boolean { + const [head, ...rest] = path.split(' > ') + const item = items.find((i) => i.label === head) + if (!item) return false + if (rest.length > 0) return Array.isArray(item.submenu) ? clickMenuPath(item.submenu, rest.join(' > ')) : false + ;(item.click as (() => void) | undefined)?.() + return !!item.click +} diff --git a/src/main/media/manager.ts b/src/main/media/manager.ts new file mode 100644 index 00000000..f16b2e05 --- /dev/null +++ b/src/main/media/manager.ts @@ -0,0 +1,417 @@ +/** + * Media in the main process (media lane): picture-in-picture, the sidebar media tray and call + * controls. Event driven, no polling: + * - a tab gets Orbit's page script (agent.ts) only once it plays media (`media-started-playing`, + * or it turns audible); the script reports changes through a long-poll; + * - tab switches arrive as store changes: a tab leaving the screen with a playing video goes to + * Orbit's floating player (player.ts, MD-01), and coming back ends it; + * - each tab's `media` record (shared/state.ts) holds what the sidebar and the player need, and + * `tray` says whether its controller shows (shared/media.ts settleTray). + * + * The engine's own picture-in-picture window is still used while the tab stays on screen (the + * page's own PiP button, "Picture in Picture" on the selected tab): Orbit's player borrows the + * tab's view, which can't show in two places at once. Leaving the tab hands such a video over to + * Orbit's player. + */ +import type { MenuItemConstructorOptions, WebContents } from 'electron' +import { + type AgentSnapshot, + applySitePip, + type MediaAction, + mediaFromSnapshot, + type PipEndReason, + type SitePipMode, + sameMedia, + settleTray, + shouldAutoPip, + siteOf, + sitePipMode, +} from '../../shared/media' +import type { Rect } from '../../shared/rpc' +import type { OrbitState, TabId, TabMedia, WindowId } from '../../shared/state' +import type { MainContext } from '../context' +import { displayedTabs } from '../tabs/model' +import { AGENT_SOURCE, MEDIA_WORLD } from './agent' +import { PipPlayer } from './player' + +interface Agent { + contents: WebContents + /** The last report. */ + snap?: AgentSnapshot + /** The last picture-in-picture ending already handled (reports repeat it). */ + pipEndSeen: number + /** A long-poll is running for this document. */ + looping: boolean +} + +const call = (fn: string, ...args: unknown[]) => + `window.__orbitMedia ? window.__orbitMedia.${fn}(${args.map((a) => JSON.stringify(a ?? null)).join(', ')}) : null` + +export class MediaManager { + private agents = new Map() + /** Tabs on screen in any window at the last state change. */ + private shown = new Set() + /** Last menu template (tests read it instead of a native popup). */ + lastMenu: MenuItemConstructorOptions[] = [] + readonly player: PipPlayer + + constructor( + private ctx: MainContext, + private now: () => number = Date.now, + ) { + this.player = new PipPlayer(ctx, { + call: (tabId, fn, ...args) => this.run(tabId, call(fn, ...args)), + changed: (tabId, open, auto) => this.markPip(tabId, open, open, auto), + focusTab: (tabId, windowId) => this.focusTab(tabId, windowId), + turnOffForSite: (tabId, mode) => { + const url = this.ctx.store.state.tabs[tabId]?.url + if (url) this.setSitePip(url, mode) + // Turning PiP off never stops playback: the media shows in the sidebar instead (MD-08). + this.player.closeFor(tabId, 'off') + }, + }) + } + + start(): void { + this.shown = displayedTabs(this.ctx.store.state) + this.ctx.hooks.tabCreated.push((tabId, contents) => this.watch(tabId, contents)) + this.ctx.store.subscribe((next, prev) => this.onState(next, prev)) + } + + /** The page script's last report for a tab (tests and diagnostics). */ + snapshot(tabId: TabId): AgentSnapshot | undefined { + return this.agents.get(tabId)?.snap + } + + // ---- Page script lifecycle ---- + + private watch(tabId: TabId, contents: WebContents): void { + contents.on('media-started-playing', () => void this.install(tabId, contents)) + contents.on('audio-state-changed', (e) => { + if (e.audible) void this.install(tabId, contents) + // Media removed from the page pauses without an event the script can see: ask again. + else void this.run(tabId, call('refresh')) + }) + // A new document: its media (and picture-in-picture) went with the old one. + contents.on('did-navigate', () => this.drop(tabId, contents, 'navigated')) + contents.once('destroyed', () => this.drop(tabId, contents, 'gone')) + } + + /** Installs the page script in a tab that plays media (once per document) and follows its reports. */ + private async install(tabId: TabId, contents: WebContents): Promise { + const existing = this.agents.get(tabId) + if (existing?.contents === contents && existing.looping) return + const agent: Agent = { contents, pipEndSeen: 0, looping: true } + this.agents.set(tabId, agent) + let snap: AgentSnapshot | null = null + try { + snap = await contents.executeJavaScriptInIsolatedWorld(MEDIA_WORLD, [{ code: AGENT_SOURCE }]) + } catch { + // The page went away meanwhile. + } + if (this.agents.get(tabId) !== agent) return + if (!snap) { + agent.looping = false + return + } + this.apply(tabId, agent, snap) + void this.follow(tabId, agent) + } + + private async follow(tabId: TabId, agent: Agent): Promise { + while (this.agents.get(tabId) === agent && !agent.contents.isDestroyed()) { + let snap: AgentSnapshot | null = null + try { + snap = await agent.contents.executeJavaScriptInIsolatedWorld(MEDIA_WORLD, [ + { code: call('next', agent.snap?.v ?? 0) }, + ]) + } catch { + break + } + if (this.agents.get(tabId) !== agent) return + if (!snap) break + this.apply(tabId, agent, snap) + } + if (this.agents.get(tabId) === agent) agent.looping = false + } + + /** Forgets a tab's media when its document or contents go away (MD-07, MD-12). */ + private drop(tabId: TabId, contents: WebContents, why: 'navigated' | 'gone'): void { + const agent = this.agents.get(tabId) + if (agent && agent.contents !== contents) return + this.player.closeFor(tabId, why) + this.agents.delete(tabId) + this.ctx.store.update((d) => { + const tab = d.tabs[tabId] + if (tab?.media) delete tab.media + }) + } + + /** Runs a script function in the tab's page script; undefined if the tab has none. */ + private async run(tabId: TabId, code: string, gesture = false): Promise { + const agent = this.agents.get(tabId) + if (!agent || agent.contents.isDestroyed()) return undefined + try { + return (await agent.contents.executeJavaScriptInIsolatedWorld(MEDIA_WORLD, [{ code }], gesture)) as T + } catch { + return undefined + } + } + + /** Makes sure the tab has the page script (e.g. picture-in-picture for a video that never played). */ + watchTab(tabId: TabId): Promise { + return this.ensureAgent(tabId) + } + + private async ensureAgent(tabId: TabId): Promise { + if (this.agents.get(tabId)?.snap) return true + const contents = this.ctx.tabs.contents(tabId) + if (!contents) return false + await this.install(tabId, contents) + return !!this.agents.get(tabId)?.snap + } + + // ---- State ---- + + private apply(tabId: TabId, agent: Agent, snap: AgentSnapshot): void { + const first = !agent.snap + agent.snap = snap + if (first) agent.pipEndSeen = snap.pipEnd?.n ?? 0 + else if (snap.pipEnd && snap.pipEnd.n > agent.pipEndSeen) { + agent.pipEndSeen = snap.pipEnd.n + this.onPipEnd(tabId, snap.pipEnd.reason) + } + // A video plays somewhere: get the player ready, so leaving its tab shows it at once (MD-01). + if (snap.video) this.player.prewarm() + this.patch(tabId, (prev) => { + const next = mediaFromSnapshot(prev, snap) + // Keep the "opened automatically" mark while the same picture-in-picture lasts. + if (next.pip && prev?.autoPip) next.autoPip = true + // Orbit's player decides whether it shows this tab (the page's report may lag behind). + if (this.player.tabId === tabId) { + next.pip = true + next.player = true + } else if (snap.player) { + next.pip = false + delete next.player + delete next.autoPip + } + return next + }) + } + + /** Updates a tab's media record and settles its tray membership; writes only real changes. */ + private patch(tabId: TabId, fn: (prev: TabMedia | undefined) => TabMedia | undefined): void { + const state = this.ctx.store.state + const tab = state.tabs[tabId] + if (!tab) return + const changed = fn(tab.media ? { ...tab.media } : undefined) + const settled = changed && settleTray(changed, tab, this.shown.has(tabId), this.now()) + if (sameMedia(tab.media, settled)) return + this.ctx.store.update((d) => { + const t = d.tabs[tabId] + if (!t) return + if (settled) t.media = settled + else delete t.media + }) + } + + private onState(next: OrbitState, prev: OrbitState): void { + if ( + next.tabs === prev.tabs && + next.windows === prev.windows && + next.splits === prev.splits && + next.spaces === prev.spaces && + next.settings === prev.settings + ) { + return + } + // A closed or archived tab takes its player with it (MD-07, MD-12). + const playerTab = this.player.tabId + if (playerTab && !next.tabs[playerTab]) this.player.close('gone') + const before = this.shown + const shown = displayedTabs(next) + this.shown = shown + for (const id of shown) if (!before.has(id)) this.returned(id, next) + for (const id of before) if (!shown.has(id) && next.tabs[id]) this.left(id, next) + // Tray membership follows the screen, mute and audibility (MD-08, MD-09). + const now = this.now() + const updates: [TabId, TabMedia][] = [] + for (const id of this.agents.keys()) { + const tab = next.tabs[id] + if (!tab?.media) continue + let media = tab.media + if (shown.has(id) && media.dismissed) media = { ...media, dismissed: undefined } + const settled = settleTray(media, tab, shown.has(id), now) + if (settled !== tab.media) updates.push([id, settled]) + } + if (updates.length === 0) return + this.ctx.store.update((d) => { + for (const [id, media] of updates) { + const t = d.tabs[id] + if (!t?.media) continue + if (media.dismissed === undefined) delete media.dismissed + t.media = media + } + }) + } + + // ---- Picture-in-picture ---- + + /** A tab left the screen: a playing video continues in the floating player (MD-01, MD-13). */ + private left(tabId: TabId, state: OrbitState): void { + const agent = this.agents.get(tabId) + const tab = state.tabs[tabId] + if (!agent?.snap || !tab?.media || agent.contents.isDestroyed()) return + // The engine's own window (opened on the tab) hands its video over to Orbit's player, with + // Orbit's controls. The user asked for it, so the automatic-PiP choices don't apply. The + // tab's state knows first (the page's report of it may still be on its way). + if ((agent.snap.pip || tab.media.pip) && !agent.snap.player && !tab.media.player) { + void this.player.open(tabId, { auto: true, keep: true }) + return + } + // The page script checks again (the report may be a moment old): only a playing video goes. + const candidate = agent.snap.pipCandidate || agent.snap.video + if (!shouldAutoPip(state.settings, tab, candidate, this.now())) return + void this.player.open(tabId, { auto: true, from: 'page' }) + } + + /** A tab came back on screen: its picture-in-picture ends and the video plays in the page (MD-01, MD-07). */ + private returned(tabId: TabId, state: OrbitState): void { + if (this.player.tabId === tabId) { + this.player.close('returned') + return + } + if (state.tabs[tabId]?.media?.pip) void this.run(tabId, call('pip', false, false)) + } + + private markPip(tabId: TabId, pip: boolean, player = false, auto = false): void { + this.patch(tabId, (m) => { + if (!m) return m + const next = { ...m, pip } + if (player) next.player = true + else delete next.player + if (pip && auto) next.autoPip = true + else if (!pip) delete next.autoPip + return next + }) + } + + /** Picture-in-picture ended in the page (MD-04, MD-07). */ + private onPipEnd(tabId: TabId, reason: PipEndReason): void { + // Orbit's player: its video ended or left the page. + if (reason === 'ended' || reason === 'gone') this.player.closeFor(tabId, reason) + if (reason !== 'back') return + // The engine window's "Back to tab": select the source tab in its window (and Space). + const windowId = this.ctx.windows.windowOfTab(tabId) ?? this.ctx.windows.focusedWindowId() + if (windowId) this.focusTab(tabId, windowId) + } + + /** + * Opens or closes picture-in-picture for a tab (MD-02, MD-10 "Back to PiP"). A tab in the + * background goes to Orbit's player, growing from `from` (a rect in a window, MD-16); the + * selected tab uses the engine's window, as its page stays on screen. + */ + async setPip(tabId: TabId, on: boolean, from?: { windowId: WindowId; rect: Rect }): Promise { + if (!on && this.player.tabId === tabId) { + this.player.close('close') + return true + } + if (!(await this.ensureAgent(tabId))) return false + if (!on) return !!(await this.run(tabId, call('pip', false, false), true)) + if (this.player.tabId === tabId) return true + if (!this.shown.has(tabId)) return this.player.open(tabId, { auto: false, from }) + const ok = await this.run(tabId, call('pip', true, false), true) + if (ok) this.markPip(tabId, true) + return !!ok + } + + // ---- Controls ---- + + /** Play, pause, seek, skip or change speed (MD-04, MD-09). `value`: seconds, a position or a speed. */ + async control(tabId: TabId, action: MediaAction, value?: number): Promise { + const gesture = action === 'play' || action === 'toggle' || action === 'previous' + const ok = await this.run(tabId, call('control', action, value ?? 0), gesture) + return !!ok + } + + /** The controller's ×: pause and hide until it plays again (MD-09). */ + async dismiss(tabId: TabId): Promise { + this.patch(tabId, (m) => (m ? { ...m, dismissed: true } : m)) + await this.run(tabId, call('control', 'pause', 0)) + } + + async callControl(tabId: TabId, action: 'mic' | 'camera'): Promise { + return !!(await this.run(tabId, call('call', action))) + } + + /** Selects a tab in a window (switching Space if needed) and brings that window forward (MD-09). */ + focusTab(tabId: TabId, windowId: WindowId): void { + void this.ctx.commands.run('tabs.activate', { tabId }, { windowId }).then(() => { + const win = this.ctx.windows.get(windowId) + if (!win || win.isDestroyed()) return + if (win.isMinimized()) win.restore() + win.focus() + }) + } + + // ---- Per-site choices (MD-08) ---- + + sitePip(url: string): SitePipMode { + return sitePipMode(this.ctx.store.state.settings, url, this.now()) + } + + setSitePip(url: string, mode: SitePipMode): void { + if (!siteOf(url)) return + const now = this.now() + this.ctx.store.update((d) => applySitePip(d.settings, url, mode, now)) + } + + // ---- Menus ---- + + /** The controller's "…" menu (MD-08, MD-10). */ + menu(tabId: TabId, windowId: WindowId | undefined): MenuItemConstructorOptions[] { + const tab = this.ctx.store.state.tabs[tabId] + if (!tab) return [] + const media = tab.media + const site = siteOf(tab.url) + const mode = this.sitePip(tab.url) + const items: MenuItemConstructorOptions[] = [ + { label: 'Go to Tab', click: () => windowId && this.focusTab(tabId, windowId) }, + ] + if (media?.video) { + items.push({ + label: media.pip ? 'Close Picture in Picture' : 'Picture in Picture', + click: () => void this.setPip(tabId, !media.pip), + }) + } + items.push({ + label: tab.muted ? 'Unmute Tab' : 'Mute Tab', + click: () => void this.ctx.commands.run('tabs.setMuted', { tabId, muted: !tab.muted }, { windowId }), + }) + if (site) { + items.push( + { type: 'separator' }, + { + label: `Automatic Picture in Picture on ${site}`, + submenu: [ + { label: 'On', type: 'radio', checked: mode === 'on', click: () => this.setSitePip(tab.url, 'on') }, + { + label: 'Turn Off for Today', + type: 'radio', + checked: mode === 'today', + click: () => this.setSitePip(tab.url, 'today'), + }, + { + label: 'Always Turn Off for This Site', + type: 'radio', + checked: mode === 'always', + click: () => this.setSitePip(tab.url, 'always'), + }, + ], + }, + ) + } + return items + } +} diff --git a/src/main/media/player.ts b/src/main/media/player.ts new file mode 100644 index 00000000..77f0632a --- /dev/null +++ b/src/main/media/player.ts @@ -0,0 +1,909 @@ +/** + * Orbit's floating video player (media lane; media.md MD-03 to MD-07, MD-13, MD-16; feel.md FL-32). + * + * One small always-on-top window that borrows the source tab's own view (WebContentsView), so the + * page keeps running exactly as it was. The page script (agent.ts) lifts the chosen video into the + * page's top layer at the player's size at the top-left of the page; the window shows just that + * corner. Orbit's controls (renderer view "pip") sit in a transparent view on top. + * + * Returning to the tab needs nothing from here: the window manager attaches the tab's view to its + * window again (taking it out of this one), and the page puts the video back on that very frame + * (see agent.ts). The player then just hides. Closing the player (×) hides it and leaves the view + * detached, like any background tab, so the media plays on as audio (MD-07). + */ +import { + app, + BaseWindow, + BrowserWindow, + Menu, + type MenuItemConstructorOptions, + screen, + systemPreferences, + type WebContents, + WebContentsView, +} from 'electron' +import type { MediaEvents } from '../../shared/commands/media' +import { PLAYBACK_RATES } from '../../shared/media' +import { + anchoredResize, + clampPipSize, + cornerRect, + cubicBezier, + draggedSize, + EASE_OUT_CURVE, + keepInside, + maxPipSize, + mixRect, + nearestCorner, + type PipCorner, + pipAspect, + pipDimensions, + resizeWithResistance, + roundedShape, + STANDARD_CURVE, + settleCorner, + springStep, +} from '../../shared/pip' +import type { Rect } from '../../shared/rpc' +import type { TabId, WindowId } from '../../shared/state' +import type { MainContext } from '../context' +import { displayedTabs } from '../tabs/model' +import { loadRenderer, rendererEntry, uiWebPreferences } from '../windows' + +const isMac = process.platform === 'darwin' +/** Corner radius of the player where Orbit rounds it itself (macOS rounds its windows natively). */ +const RADIUS = 10 +/** + * The borrowed view is this much taller than the page area it came from, so the page can tell + * where it is from its size alone (agent.ts): at the player's size the video lifts, at any other + * size (back in its window) it goes back into the page, each before the frame is drawn. 24 DIP is + * still more than the page's 2 px tolerance at 500% page zoom. + */ +const EXTRA_HEIGHT = 24 +const PLAYER_BACKGROUND = '#000000' +/** Tab views' own background (tabs/runtime.ts), given back when the view leaves the player. */ +const TAB_BACKGROUND = '#ffffffff' +/** Enter motion (MD-16: about 300 ms, smooth, no overshoot; feel.md "standard" curve). */ +const ENTER_MS = 300 +/** Exits are quicker than entrances (FL-01: about 0.7×). */ +const EXIT_MS = 140 +/** "Back to tab": the reverse of the enter motion, a little quicker (MD-16, FL-01). */ +const BACK_MS = 220 +/** With reduced motion, a released player settles with a short ease-out instead of a spring (FL-28). */ +const RELEASE_MS = 120 +const FRAME_MS = 16 +/** A pause in wheel scrolling ends a hover-scroll move (MD-06). */ +const WHEEL_IDLE_MS = 160 + +export type PlayerEnd = 'close' | 'returned' | 'gone' | 'navigated' | 'ended' | 'replaced' | 'off' | 'quit' + +export interface PlayerHost { + /** Runs a function of the tab's page script; undefined when the tab has none. */ + call(tabId: TabId, fn: string, ...args: unknown[]): Promise + /** The player opened or closed for a tab. */ + changed(tabId: TabId, open: boolean, auto: boolean): void + /** Selects a tab in a window and brings the window forward. */ + focusTab(tabId: TabId, windowId: WindowId): void + /** Turns automatic picture-in-picture off for the tab's site (MD-08 from the player's menu). */ + turnOffForSite(tabId: TabId, mode: 'today' | 'always'): void +} + +interface Session { + tabId: TabId + /** The window the tab was in: "Back to tab" selects it there (MD-04). */ + windowId: WindowId | undefined + view: WebContentsView + contents: WebContents + /** The video's shape (width / height). */ + aspect: number + auto: boolean + /** Page zoom: CSS px = DIP / zoom. */ + zoom: number + /** Size of the borrowed view (the video can grow up to it). */ + viewSize: { width: number; height: number } + /** The video's place in its window's content (DIP) when it left: "Back to tab" flies there (MD-16). */ + home: Rect | undefined + dispose: () => void +} + +interface Gesture { + kind: 'move' | PipCorner + startX: number + startY: number + start: Rect + samples: { x: number; y: number; t: number }[] +} + +export type PipGestureEvent = MediaEvents['media.pipGesture'] + +export interface OpenOptions { + /** Opened because the tab was left (it closes when the tab comes back, like any player). */ + auto: boolean + /** + * Where the motion starts: the video's place in the page ('page', when leaving the tab), a rect + * in a window (the sidebar controller's "Back to PiP"), or nothing (it appears in its corner). + */ + from?: 'page' | { windowId: WindowId; rect: Rect } + /** A point in the page (CSS px): the video under it is the one to float. */ + point?: { x: number; y: number } + /** Take the video from the engine's own picture-in-picture window. */ + keep?: boolean +} + +/** Diagnostics for tests. */ +export interface PlayerInfo { + tabId: TabId | null + visible: boolean + bounds: Rect | null + workArea: Rect | null + holdsView: boolean + alwaysOnTop: boolean + animating: boolean + /** The last enter motion: where the player started and where it went (MD-16). */ + lastEnter: { from: Rect; to: Rect } | null + /** The last "Back to tab" motion: from the player's place to the video's place in the page (MD-16). */ + lastBack: { from: Rect; to: Rect } | null + /** + * The last settle after a move or resize (MD-06): where it went, and how long it takes when it is + * a timed ease-out (reduced motion, FL-28); null for the spring. + */ + lastSettle: { from: Rect; to: Rect; ms: number | null } | null + /** How long the last opening took, from the request to the window showing (ms; MD-01). */ + openMs: number | null + /** An opening is under way (the page is being asked for its video). */ + opening: boolean +} + +export class PipPlayer { + private win: BaseWindow | undefined + private ui: WebContentsView | undefined + /** The window has its rounded shape for its current size (not macOS). */ + private shaped = false + private session: Session | undefined + /** Increments on every open/close: a slow open that lost the race gives up. */ + private seq = 0 + private anim: NodeJS.Timeout | undefined + private gesture: Gesture | undefined + private wheelTimer: NodeJS.Timeout | undefined + private wheelSamples: { x: number; y: number; t: number }[] = [] + private lastEnter: { from: Rect; to: Rect } | null = null + private lastBack: { from: Rect; to: Rect } | null = null + private lastSettle: { from: Rect; to: Rect; ms: number | null } | null = null + /** The window the controls act for (app menu and shortcuts while the player has focus). */ + private uiWindowId: WindowId | undefined + /** "Back to tab" is under way: the player flies back into the page once the tab is selected. */ + private backing: { tabId: TabId; to: Rect | undefined } | undefined + /** The controls view shows a still of the last frame (while flying back, MD-16). */ + private still = false + private openMs: number | null = null + private hiding: NodeJS.Timeout | undefined + private opening: { tabId: TabId; done: Promise } | undefined + /** Finishes a closing fade at once (a new video is about to show). */ + private finishFade: (() => void) | undefined + /** Last menu template (tests read it instead of a native popup). */ + lastMenu: MenuItemConstructorOptions[] = [] + + constructor( + private ctx: MainContext, + private host: PlayerHost, + ) { + // Closing the tab's window puts its tabs to sleep, the player's too; and a hidden player + // window must not keep the app alive once every Orbit window is closed. + app.on('browser-window-created', (_e, win) => { + win.once('closed', () => setImmediate(() => this.onWindowClosed())) + }) + + screen.on('display-removed', () => this.refit()) + screen.on('display-metrics-changed', () => this.refit()) + } + + /** The tab whose video is in the player. */ + get tabId(): TabId | undefined { + return this.session?.tabId + } + + /** Creates the window and its controls ahead of time, so the player appears at once (MD-01). */ + prewarm(): void { + if (this.ctx.windows.ids().length > 0) this.ensureWindow() + } + + // ---- Opening ---- + + /** Shows a tab's video in the player. */ + open(tabId: TabId, opts: OpenOptions): Promise { + if (this.session?.tabId === tabId) return Promise.resolve(true) + // Already on its way for this tab (leaving it twice in quick succession): one opening. + if (this.opening?.tabId === tabId) return this.opening.done + const done = this.start(tabId, opts).finally(() => { + if (this.opening?.done === done) this.opening = undefined + }) + this.opening = { tabId, done } + return done + } + + private async start(tabId: TabId, opts: OpenOptions): Promise { + const began = Date.now() + const view = this.ctx.tabs.get(tabId) + const contents = view?.webContents + if (!view || !contents || contents.isDestroyed()) return false + if (this.session) this.close('replaced') + const seq = ++this.seq + const probe = await this.host.call<{ ok: boolean; aspect?: number; rect?: Rect | null }>(tabId, 'probe', { + // Leaving the tab takes only its main playing video; asking for PiP takes any video. + auto: opts.auto && !opts.keep, + x: opts.point?.x, + y: opts.point?.y, + }) + if (!probe?.ok || seq !== this.seq || contents.isDestroyed()) return false + // Back on screen meanwhile (a quick switch away and back): nothing to do. + if (this.isShown(tabId)) return false + const windowId = this.ctx.windows.windowOfTab(tabId) ?? this.ctx.windows.focusedWindowId() + const source = windowId ? this.ctx.windows.get(windowId) : undefined + const zoom = contents.getZoomFactor() || 1 + const pane = view.getBounds() + const aspect = pipAspect(probe.aspect ?? 0, 1) + const anchor = source && !source.isDestroyed() ? source.getBounds() : undefined + const display = anchor ? screen.getDisplayMatching(anchor) : screen.getPrimaryDisplay() + const area = display.workArea + const placement = this.ctx.store.state.settings.pipPlacement ?? { corner: 'bottom-right', size: 320 } + const dims = pipDimensions(aspect, clampPipSize(placement.size, area, aspect)) + const target = cornerRect(area, placement.corner, dims.width, dims.height) + + // Where the video sits in its window: the enter motion starts there when leaving the tab, and + // "Back to tab" flies back there (MD-16). + const home = probe.rect + ? { + x: Math.round(pane.x + probe.rect.x * zoom), + y: Math.round(pane.y + probe.rect.y * zoom), + width: Math.round(probe.rect.width * zoom), + height: Math.round(probe.rect.height * zoom), + } + : undefined + + // Where the motion starts (MD-16). + let from: Rect | undefined + if (opts.from === 'page' && home && source && !source.isDestroyed()) { + const content = source.getContentBounds() + from = { ...home, x: content.x + home.x, y: content.y + home.y } + } else if (opts.from && opts.from !== 'page') { + const win = this.ctx.windows.get(opts.from.windowId) + if (win && !win.isDestroyed()) { + const content = win.getContentBounds() + const r = opts.from.rect + // The video's shape, centred on the controller, as tall as it. + const h = Math.max(24, Math.round(r.height)) + const w = Math.round(h * aspect) + from = { + x: Math.round(content.x + r.x + r.width / 2 - w / 2), + y: Math.round(content.y + r.y + r.height / 2 - h / 2), + width: w, + height: h, + } + } + } + if (from && (from.width < 8 || from.height < 8 || !richMotion())) from = undefined + const start = from ?? target + const ms = ENTER_MS + + // The borrowed view: at least the page area, and big enough for the largest player. + const max = pipDimensions(aspect, maxPipSize(area, aspect)) + const viewSize = { + width: Math.max(pane.width, max.width, start.width), + height: Math.max(pane.height, max.height, start.height) + EXTRA_HEIGHT, + } + // The page draws the video at the window's size; during the motion it follows frame by frame. + const css = { w: start.width / zoom, h: start.height / zoom } + const lifted = await this.host.call(tabId, 'lift', { + from: css, + to: css, + ms: 0, + arm: { w: Math.round(viewSize.width / zoom), h: Math.round(viewSize.height / zoom) }, + }) + if (!lifted || seq !== this.seq || contents.isDestroyed() || this.isShown(tabId)) { + if (lifted) void this.host.call(tabId, 'drop') + return false + } + + const { win, ui } = this.ensureWindow() + // The controls belong to the tab's window from now on, not to the window they were made in + // (which may have closed since). + if (windowId && windowId !== this.uiWindowId) { + this.ctx.windows.registerUi(ui.webContents, windowId) + this.uiWindowId = windowId + } + this.finishFade?.() + this.backing = undefined + void this.showStill(null) + this.ctx.windows.detachView(tabId, view) + win.contentView.addChildView(view, 0) + view.setBounds({ x: 0, y: 0, ...viewSize }) + view.setBorderRadius(0) + // Until the page draws at its new size, the view shows its background: black, like the video. + view.setBackgroundColor(PLAYER_BACKGROUND) + view.setVisible(true) + ui.setVisible(true) + + const onGone = () => this.closeFor(tabId, 'gone') + contents.once('destroyed', onGone) + this.session = { + tabId, + windowId, + view, + contents, + aspect, + auto: opts.auto, + zoom, + viewSize, + home, + dispose: () => { + if (!contents.isDestroyed()) contents.off('destroyed', onGone) + }, + } + this.place(start) + if (!win.isVisible()) win.showInactive() + this.openMs = Date.now() - began + this.lastEnter = from ? { from: start, to: target } : null + if (from) this.animateTimed(start, target, ms, () => this.pointerCheck()) + this.host.changed(tabId, true, opts.auto) + return true + } + + private isShown(tabId: TabId): boolean { + return displayedTabs(this.ctx.store.state).has(tabId) + } + + // ---- Closing ---- + + /** Ends the player for this tab, if it shows it. */ + closeFor(tabId: TabId, why: PlayerEnd): void { + if (this.session?.tabId === tabId) this.close(why) + } + + /** + * Ends the player. The window hides first, so the page never shows in it while the video goes + * back into the page; then the view is let go (unless its window already took it back). + */ + close(why: PlayerEnd): void { + const s = this.session + if (!s) return + this.session = undefined + this.seq++ + this.stopMotion() + s.dispose() + const win = this.win + const release = () => { + if (win && !win.isDestroyed() && win.contentView.children.includes(s.view)) + win.contentView.removeChildView(s.view) + try { + s.view.setBackgroundColor(TAB_BACKGROUND) + } catch {} + if (!s.contents.isDestroyed()) void this.host.call(s.tabId, 'drop') + } + // A still taken for "Back to tab" that won't fly (the player was closed while it was taken) goes + // as the player hides, so the next video never shows it. + const released = () => { + release() + void this.showStill(null) + } + const fade = isMac && richMotion() && (why === 'close' || why === 'off' || why === 'ended') + const back = this.backing?.tabId === s.tabId ? this.backing : undefined + this.backing = undefined + if (win && !win.isDestroyed() && win.isVisible() && why === 'returned' && back?.to && this.still) { + // "Back to tab" (MD-16): the tab is already back in its window, playing; a still of the last + // frame flies from the player to the video's place in the page, then the player hides. + release() + const from = win.getBounds() + this.lastBack = { from, to: back.to } + this.flyBack(win, from, back.to) + } else if (win && !win.isDestroyed() && win.isVisible()) { + if (fade) { + this.fadeOut(win, () => { + win.hide() + released() + }) + } else { + win.hide() + released() + } + } else released() + this.host.changed(s.tabId, false, false) + } + + /** + * "Back to tab" and double-click (MD-04, MD-07): select the tab; the player closes as it returns. + * With motion on, the player first takes a still of the video, then flies it back to the video's + * place in the page while the page already plays it there (MD-16). The tab is selected at once + * (FL-01: state first, motion second). + */ + async back(): Promise { + const s = this.session + if (!s || this.backing?.tabId === s.tabId) return + const windowId = + s.windowId && this.ctx.windows.get(s.windowId) && !this.ctx.windows.get(s.windowId)?.isDestroyed() + ? s.windowId + : (this.ctx.windows.focusedWindowId() ?? this.ctx.windows.ids()[0]) + if (!windowId) return + const to = windowId === s.windowId ? this.homeOnScreen(s, windowId) : undefined + this.backing = { tabId: s.tabId, to } + if (to) await this.takeStill(s) + if (this.session !== s) return + this.host.focusTab(s.tabId, windowId) + } + + /** + * After the enter motion: if the pointer isn't over the player, its controls hide (it may have + * appeared under a resting pointer and moved away, with no pointer event to say so; MD-03). + */ + private pointerCheck(): void { + const win = this.win + const contents = this.ui?.webContents + if (!win || win.isDestroyed() || !contents || contents.isDestroyed()) return + const p = screen.getCursorScreenPoint() + const b = win.getBounds() + if (p.x >= b.x && p.x < b.x + b.width && p.y >= b.y && p.y < b.y + b.height) return + contents.executeJavaScript("window.dispatchEvent(new Event('orbit-pip-pointer-away'))").catch(() => {}) + } + + /** The video's place in the page on screen, if the motion back there can be seen (MD-16). */ + private homeOnScreen(s: Session, windowId: WindowId): Rect | undefined { + const source = this.ctx.windows.get(windowId) + if (!s.home || !richMotion() || !source || source.isDestroyed() || !source.isVisible() || source.isMinimized()) + return undefined + const content = source.getContentBounds() + const to = { ...s.home, x: content.x + s.home.x, y: content.y + s.home.y } + // Only when the page area still holds it (the window may have shrunk meanwhile). + const inside = + s.home.x >= 0 && + s.home.y >= 0 && + s.home.x + s.home.width <= content.width + 1 && + s.home.y + s.home.height <= content.height + 1 + return inside && to.width >= 8 && to.height >= 8 ? to : undefined + } + + /** Puts a still of the player's current frame in the controls view, over the video. */ + private async takeStill(s: Session): Promise { + const win = this.win + if (!win || win.isDestroyed()) return + const b = win.getBounds() + try { + const image = await s.contents.capturePage({ x: 0, y: 0, width: b.width, height: b.height }) + // A blank capture (no frame to show) would fly a black box: no motion then. + if (this.session !== s || image.isEmpty() || blank(image.toBitmap())) return + await this.showStill(`data:image/jpeg;base64,${image.toJPEG(90).toString('base64')}`) + } catch { + // No still (the page went away): the player just closes. + } + } + + /** Shows (a data URL) or clears (null) the still in the controls view. */ + private async showStill(url: string | null): Promise { + const contents = this.ui?.webContents + if (!contents || contents.isDestroyed() || (!url && !this.still)) return + this.still = !!url + const css = url ? `#000 0 0 / 100% 100% no-repeat url("${url}")` : '' + try { + // The controls hide while the still shows; resolves once it has been drawn. + await contents.executeJavaScript( + `document.documentElement.style.background = ${JSON.stringify(css)};` + + `document.documentElement.toggleAttribute('data-still', ${!!url});` + + 'new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))', + ) + } catch { + this.still = false + } + } + + /** The still flies from the player's place to the video's place in the page, then hides. */ + private flyBack(win: BaseWindow, from: Rect, to: Rect): void { + const done = () => { + this.finishFade = undefined + this.stopAnimation() + if (win.isDestroyed()) return + win.hide() + void this.showStill(null) + } + this.finishFade = done + this.animateTimed(from, to, BACK_MS, done) + } + + private onWindowClosed(): void { + const s = this.session + if (s?.windowId && !this.ctx.windows.get(s.windowId)) { + // Like the other tabs of the closed window, the player's tab goes to sleep. + this.close('gone') + this.ctx.tabs.sleep(s.tabId) + } + if (BrowserWindow.getAllWindows().some((w) => !w.isDestroyed())) return + this.close('quit') + if (this.win && !this.win.isDestroyed()) this.win.destroy() + this.win = undefined + this.ui = undefined + } + + // ---- Controls from the player's view ---- + + /** The player's "…" menu (MD-04, MD-08): playback speed and turning automatic PiP off for the site. */ + menu(x: number, y: number): void { + const s = this.session + if (!s) return + const items = this.menuItems(s) + this.lastMenu = items + if (this.ctx.e2e || !this.win) return + Menu.buildFromTemplate(items).popup({ window: this.win, x: Math.round(x), y: Math.round(y) }) + } + + private menuItems(s: Session): MenuItemConstructorOptions[] { + const media = this.ctx.store.state.tabs[s.tabId]?.media + const items: MenuItemConstructorOptions[] = [] + if (media && !media.live && media.seekable && !media.mic && !media.camera) { + const rate = media.rate ?? 1 + items.push( + { + label: 'Playback Speed', + submenu: PLAYBACK_RATES.map((r) => ({ + label: r === 1 ? 'Normal' : `${r}×`, + type: 'radio' as const, + checked: Math.abs(rate - r) < 0.01, + click: () => void this.host.call(s.tabId, 'control', 'rate', r), + })), + }, + { type: 'separator' }, + ) + } + items.push( + { label: 'Turn Off for Today', click: () => this.host.turnOffForSite(s.tabId, 'today') }, + { label: 'Always Turn Off for This Site', click: () => this.host.turnOffForSite(s.tabId, 'always') }, + ) + return items + } + + /** ⌘/Ctrl + scroll or pinch zooms into the video, cropping it in the same window (MD-06). */ + async zoom(factor: number, x: number, y: number): Promise { + const s = this.session + if (!s) return 1 + return (await this.host.call(s.tabId, 'zoom', factor, x / s.zoom, y / s.zoom)) || 1 + } + + /** Moving and resizing by pointer, and moving by hover-scroll (MD-06, FL-32). */ + onGesture(e: PipGestureEvent): void { + const s = this.session + const win = this.win + if (!s || !win || win.isDestroyed()) return + if (e.phase === 'wheel') { + this.wheel(e.x, e.y) + return + } + const t = e.t ?? Date.now() + if (e.phase === 'start') { + this.stopMotion() + this.gesture = { + kind: e.kind ?? 'move', + startX: e.x, + startY: e.y, + start: win.getBounds(), + samples: [{ x: e.x, y: e.y, t }], + } + return + } + const g = this.gesture + if (!g) return + g.samples.push({ x: e.x, y: e.y, t }) + while (g.samples.length > 2 && t - (g.samples[0]?.t ?? t) > 100) g.samples.shift() + const dx = e.x - g.startX + const dy = e.y - g.startY + if (g.kind === 'move') { + this.place({ ...g.start, x: g.start.x + dx, y: g.start.y + dy }) + } else { + const area = this.areaFor(g.start) + const size = resizeWithResistance(draggedSize(g.start, g.kind, dx, dy), area, s.aspect) + const dims = pipDimensions(s.aspect, Math.min(size, Math.max(s.viewSize.width, s.viewSize.height))) + this.place(anchoredResize(g.start, g.kind, dims.width, dims.height), true) + } + if (e.phase === 'end') { + this.gesture = undefined + this.settle(velocityOf(g.samples)) + } + } + + private wheel(dx: number, dy: number): void { + const win = this.win + if (!win || win.isDestroyed() || !(Number.isFinite(dx) && Number.isFinite(dy)) || (!dx && !dy)) return + this.stopAnimation() + const b = win.getBounds() + // The player follows the fingers (a two-finger scroll moves content the other way). + this.place({ ...b, x: Math.round(b.x - dx), y: Math.round(b.y - dy) }) + const t = Date.now() + this.wheelSamples.push({ x: b.x - dx, y: b.y - dy, t }) + while (this.wheelSamples.length > 2 && t - (this.wheelSamples[0]?.t ?? t) > 100) this.wheelSamples.shift() + clearTimeout(this.wheelTimer) + this.wheelTimer = setTimeout(() => { + const samples = this.wheelSamples + this.wheelSamples = [] + this.settle(velocityOf(samples)) + }, WHEEL_IDLE_MS) + } + + /** + * After a move or resize: the player glides to the corner the release heads for, at its size + * within the limits (a rubber-banded size springs back), and Orbit remembers both (MD-06). + */ + private settle(velocity: { x: number; y: number }): void { + const s = this.session + const win = this.win + if (!s || !win || win.isDestroyed()) return + const now = win.getBounds() + const area = this.areaFor(now) + const size = clampPipSize(Math.max(now.width, now.height), area, s.aspect) + const dims = pipDimensions(s.aspect, Math.min(size, Math.max(s.viewSize.width, s.viewSize.height))) + const corner = settleCorner(area, now, velocity) + const target = cornerRect(area, corner, dims.width, dims.height) + this.ctx.store.update((d) => { + const p = d.settings.pipPlacement + if (p?.corner === corner && p.size === size) return + d.settings.pipPlacement = { corner, size } + }) + const rich = richMotion() + this.lastSettle = { from: now, to: target, ms: rich ? null : RELEASE_MS } + if (rich) this.animateSpring(now, target, velocity) + else this.animateTimed(now, target, RELEASE_MS, undefined, EASE_OUT_CURVE) + } + + // ---- Geometry and motion ---- + + /** Sets the window's bounds; the controls fill it and the video follows a size change. */ + private place(rect: Rect, resizeVideo = false): void { + const win = this.win + const s = this.session + if (!win || win.isDestroyed()) return + const r = { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.max(1, Math.round(rect.width)), + height: Math.max(1, Math.round(rect.height)), + } + const before = win.getBounds() + win.setBounds(r) + this.ui?.setBounds({ x: 0, y: 0, width: r.width, height: r.height }) + // macOS rounds the window itself; elsewhere the window takes a rounded shape (MD-03). + if (!isMac && (before.width !== r.width || before.height !== r.height || !this.shaped)) { + win.setShape(roundedShape(r.width, r.height, RADIUS)) + this.shaped = true + } + if (resizeVideo && s && (before.width !== r.width || before.height !== r.height)) { + void this.host.call(s.tabId, 'size', r.width / s.zoom, r.height / s.zoom, 0) + } + } + + /** + * A timed motion where window and video change together: the enter motion and "Back to tab" on + * the "standard" curve (MD-16), or a release's ease-out with reduced motion (FL-28). + */ + private animateTimed( + from: Rect, + to: Rect, + ms: number, + done?: () => void, + curve: [number, number, number, number] = STANDARD_CURVE, + ): void { + this.stopAnimation() + const ease = cubicBezier(...curve) + const started = Date.now() + const step = () => { + const p = Math.min(1, (Date.now() - started) / ms) + this.place(mixRect(from, to, ease(p)), true) + if (p >= 1) { + this.stopAnimation() + done?.() + } + } + this.anim = setInterval(step, FRAME_MS) + step() + } + + /** Glide with a spring that starts at the release velocity (FL-01, FL-32); never past the target. */ + private animateSpring(from: Rect, to: Rect, velocity: { x: number; y: number }): void { + this.stopAnimation() + const dx = to.x - from.x + const dy = to.y - from.y + const dist2 = dx * dx + dy * dy + // Progress 0 → 1, with the release velocity along the way as its starting speed. + let p = 0 + let v = dist2 > 1 ? (velocity.x * dx + velocity.y * dy) / dist2 : 0 + v = Math.max(-20, Math.min(20, v)) + const resize = from.width !== to.width || from.height !== to.height + let last = Date.now() + const step = () => { + const now = Date.now() + const dt = Math.min(0.05, (now - last) / 1000) + last = now + const next = springStep(p - 1, v, dt) + p = next.offset + 1 + v = next.velocity + const settled = Math.abs(p - 1) < 0.002 && Math.abs(v) < 0.02 + if (p > 1) { + p = 1 + v = Math.min(0, v) + } + this.place(settled ? to : mixRect(from, to, p), resize) + if (settled) this.stopAnimation() + } + this.anim = setInterval(step, FRAME_MS) + } + + private fadeOut(win: BaseWindow, done: () => void): void { + this.finishFade?.() + const started = Date.now() + const finish = () => { + clearTimeout(this.hiding) + this.finishFade = undefined + if (!win.isDestroyed()) win.setOpacity(1) + done() + } + const tick = () => { + if (win.isDestroyed()) return finish() + const p = Math.min(1, (Date.now() - started) / EXIT_MS) + if (p >= 1) return finish() + win.setOpacity(1 - p) + this.hiding = setTimeout(tick, FRAME_MS) + } + this.finishFade = finish + tick() + } + + private stopAnimation(): void { + if (this.anim) clearInterval(this.anim) + this.anim = undefined + } + + private stopMotion(): void { + this.stopAnimation() + clearTimeout(this.wheelTimer) + this.wheelSamples = [] + this.gesture = undefined + } + + private areaFor(rect: Rect): Rect { + return screen.getDisplayNearestPoint({ + x: Math.round(rect.x + rect.width / 2), + y: Math.round(rect.y + rect.height / 2), + }).workArea + } + + /** Screens changed: keep the player in a corner of a screen that still exists. */ + private refit(): void { + const win = this.win + if (!this.session || !win || win.isDestroyed() || this.gesture) return + const b = win.getBounds() + const area = this.areaFor(b) + const inside = keepInside(area, b) + if (inside.x === b.x && inside.y === b.y) return + const corner = nearestCorner(area, b.x + b.width / 2, b.y + b.height / 2) + this.place(cornerRect(area, corner, b.width, b.height)) + } + + // ---- Window ---- + + private ensureWindow(): { win: BaseWindow; ui: WebContentsView } { + if (this.win && !this.win.isDestroyed() && this.ui) return { win: this.win, ui: this.ui } + const windowId = this.ctx.windows.ids()[0] ?? 'pip' + const win = new BaseWindow({ + width: 320, + height: 180, + show: false, + frame: false, + title: 'Picture in Picture', + // Linux window managers may refuse to resize a non-resizable window from code. + resizable: process.platform === 'linux', + minimizable: false, + maximizable: false, + fullscreenable: false, + alwaysOnTop: true, + skipTaskbar: true, + hasShadow: true, + roundedCorners: true, + backgroundColor: PLAYER_BACKGROUND, + }) + if (isMac) { + win.setAlwaysOnTop(true, 'floating') + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true, skipTransformProcessType: true }) + } + const ui = new WebContentsView({ webPreferences: uiWebPreferences(windowId, 'pip') }) + ui.setBackgroundColor('#00000000') + win.contentView.addChildView(ui) + ui.setBounds({ x: 0, y: 0, width: 320, height: 180 }) + this.ctx.windows.registerUi(ui.webContents, windowId) + this.uiWindowId = windowId + const contents = ui.webContents + contents.on('will-navigate', (event, url) => { + const entry = rendererEntry() + if (!(entry.url && url.startsWith(entry.url))) event.preventDefault() + }) + contents.setWindowOpenHandler(() => ({ action: 'deny' })) + // ⌘W / Ctrl+W closes the player, not a tab behind it. + contents.on('before-input-event', (event, input) => { + if (input.type !== 'keyDown' || input.key.toLowerCase() !== 'w') return + if (!(isMac ? input.meta : input.control) || input.shift || input.alt) return + event.preventDefault() + this.close('close') + }) + contents.on('render-process-gone', (_e, details) => { + if (details.reason !== 'clean-exit' && !contents.isDestroyed()) loadRenderer(contents, 'pip') + }) + loadRenderer(contents, 'pip') + win.on('closed', () => { + if (this.win !== win) return + const s = this.session + this.session = undefined + this.stopMotion() + if (s) { + s.dispose() + if (!s.contents.isDestroyed()) void this.host.call(s.tabId, 'drop') + this.host.changed(s.tabId, false, false) + } + if (!contents.isDestroyed()) contents.close() + this.win = undefined + this.ui = undefined + }) + this.win = win + this.ui = ui + this.shaped = false + return { win, ui } + } + + info(): PlayerInfo { + const win = this.win && !this.win.isDestroyed() ? this.win : undefined + const bounds = win ? win.getBounds() : null + return { + tabId: this.session?.tabId ?? null, + visible: !!win?.isVisible(), + bounds, + workArea: bounds ? this.areaFor(bounds) : null, + holdsView: !!(win && this.session && win.contentView.children.includes(this.session.view)), + alwaysOnTop: !!win?.isAlwaysOnTop(), + animating: !!this.anim, + lastEnter: this.lastEnter, + lastBack: this.lastBack, + lastSettle: this.lastSettle, + openMs: this.openMs, + opening: !!this.opening, + } + } + + /** The controls' web contents (tests). */ + uiContents(): WebContents | undefined { + return this.ui?.webContents + } +} + +/** True when a captured frame (BGRA) is all black or empty: nothing worth showing. */ +function blank(bitmap: Buffer): boolean { + const pixels = Math.floor(bitmap.length / 4) + const step = Math.max(1, Math.floor(pixels / 4000)) + for (let i = 0; i < pixels; i += step) { + const o = i * 4 + if ((bitmap[o] ?? 0) > 8 || (bitmap[o + 1] ?? 0) > 8 || (bitmap[o + 2] ?? 0) > 8) return false + } + return true +} + +/** Release velocity in px/s from the last ~100 ms of pointer samples. */ +function velocityOf(samples: { x: number; y: number; t: number }[]): { x: number; y: number } { + const first = samples[0] + const last = samples[samples.length - 1] + if (!first || !last || last.t - first.t < 8) return { x: 0, y: 0 } + const dt = (last.t - first.t) / 1000 + return { x: (last.x - first.x) / dt, y: (last.y - first.y) / dt } +} + +/** + * No reduced motion asked for (FL-28): macOS Reduce Motion, Windows animation effects, the GTK + * animation setting, or Chromium's force switches. The same signal as the pages' + * prefers-reduced-motion (`shouldRenderRichAnimation` ignores the switches). + */ +function richMotion(): boolean { + try { + return !systemPreferences.getAnimationSettings().prefersReducedMotion + } catch { + return true + } +} diff --git a/src/main/persist.ts b/src/main/persist.ts index 8c346b1c..65ee207b 100644 --- a/src/main/persist.ts +++ b/src/main/persist.ts @@ -13,6 +13,7 @@ import { MIN_SIDEBAR_WIDTH, } from '../shared/defaults' import { isFolderId, isSplitId, isTabId } from '../shared/ids' +import { normalizeMediaSettings } from '../shared/media' import type { ItemId, OrbitState, ProfileId, SpaceId, SplitLayout, TabId } from '../shared/state' import { STATE_SCHEMA } from '../shared/state' import { normalizeSpaceTheme } from '../shared/theme' @@ -102,6 +103,7 @@ export function normalizeState(input: Partial): OrbitState { tweaks: record(input.tweaks), } if (!['system', 'light', 'dark'].includes(state.settings.appearance)) state.settings.appearance = 'system' + normalizeMediaSettings(state.settings, Date.now()) seedAppearanceFromOldThemes(state) normalizeEntities(state, base) // Site searches (CB-11): keep well-formed http(s) entries; a missing list gets the defaults. @@ -133,6 +135,7 @@ export function normalizeState(input: Partial): OrbitState { tab.canGoBack = tab.canGoBack ?? false tab.canGoForward = tab.canGoForward ?? false tab.muted = tab.muted ?? false + delete tab.media } for (const win of Object.values(state.windows)) { win.focused = false @@ -409,7 +412,7 @@ function repairReferences(state: OrbitState): void { * touch nothing else (loading flips, focus, audio, download progress…) don't cause a write. */ const RUNTIME_ONLY: Record> = { - tabs: new Set(['loading', 'audible', 'asleep', 'progress', 'error', 'canGoBack', 'canGoForward']), + tabs: new Set(['loading', 'audible', 'asleep', 'progress', 'error', 'canGoBack', 'canGoForward', 'media']), windows: new Set(['focused', 'fullscreen', 'overlay']), downloads: new Set(['receivedBytes']), } diff --git a/src/main/split/index.ts b/src/main/split/index.ts index 9b2bf09c..88a4df58 100644 --- a/src/main/split/index.ts +++ b/src/main/split/index.ts @@ -1,4 +1,5 @@ import { clipboard, Menu, type MenuItemConstructorOptions, nativeTheme } from 'electron' +import { FAVORITES_FULL_MESSAGE } from '../../shared/defaults' import { newId } from '../../shared/ids' import { equalizeLayout, @@ -32,9 +33,11 @@ import { detachTab, dropBlankPanes, focusInDraft, + growsFavoritesPastLimit, isBlankPane, isFailure, leavesOf, + overflowsFavorites, popOutInDraft, separateInDraft, sidebarSorted, @@ -138,6 +141,15 @@ export function registerSplit(ctx: MainContext): void { ctx.toast(windowId, SPLIT_FULL_MESSAGE) return null } + // A Favorite split's pane made into a new split leaves its own split as a new tile (FV-02). + if ( + growsFavoritesPastLimit(state, (d) => { + createSplitInDraft(d, ordered, { axis: orientation, at: ordered[0] }) + }) + ) { + ctx.toast(windowId, FAVORITES_FULL_MESSAGE) + return null + } const active = windowId ? activeTabOf(state, windowId) : null let result: ReturnType = 'invalid' updateMoving(ordered, (d) => { @@ -428,18 +440,29 @@ export function registerSplit(ctx: MainContext): void { if (rest.length > 0) void commands.run('tabs.close', { tabIds: rest }, { windowId: windowOf(c) }) }) - commands.register('split.separate', ({ splitId }) => { + commands.register('split.separate', ({ splitId }, c) => { + // A Favorite split becomes one tile per pane: refused when that passes 12 Favorites (FV-02). + const tiles = leavesOf(store.state, splitId).filter((id) => !isPendingPane(store.state, id)).length + if (overflowsFavorites(store.state, splitId, tiles)) { + ctx.toast(windowOf(c), FAVORITES_FULL_MESSAGE) + return + } for (const id of leavesOf(store.state, splitId)) if (isPendingPane(store.state, id)) dropPending(id) store.update((d) => { separateInDraft(d, splitId) }) }) - commands.register('split.popOut', ({ tabId }) => { + commands.register('split.popOut', ({ tabId }, c) => { const state = store.state const splitId = splitOfTab(state, tabId) const split = splitId ? state.splits[splitId] : undefined if (!split) return + // The pane becomes a tile of its own beside a Favorite split (FV-02). + if (overflowsFavorites(state, split.id, 2)) { + ctx.toast(windowOf(c), FAVORITES_FULL_MESSAGE) + return + } const sibling = siblingOf(split.layout, tabId) store.update((d) => { for (const { windowId, spaceId } of windowsShowing(d, [tabId])) { @@ -481,11 +504,16 @@ export function registerSplit(ctx: MainContext): void { if (other) updateLayout(splitId, (layout) => swapPanes(layout, tabId, other)) }) - commands.register('split.duplicate', ({ splitId }) => { + commands.register('split.duplicate', ({ splitId }, c) => { const state = store.state const split = state.splits[splitId] const loc = locate(state, splitId) if (!split || !loc) return null + // A Favorite split's copy is one more tile beside it (FV-02). + if (overflowsFavorites(state, splitId, 2)) { + ctx.toast(windowOf(c), FAVORITES_FULL_MESSAGE) + return null + } const now = Date.now() const copies = new Map() const tabs = layoutLeaves(split.layout) diff --git a/src/main/split/model.ts b/src/main/split/model.ts index a654717b..4ec96ef4 100644 --- a/src/main/split/model.ts +++ b/src/main/split/model.ts @@ -4,6 +4,8 @@ * themselves. Every helper keeps the invariants: 2–4 panes per split, canonical layout, * a tab in at most one place. */ +import { produce } from 'immer' +import { MAX_FAVORITES } from '../../shared/defaults' import { newId } from '../../shared/ids' import { anchorOf, @@ -219,6 +221,33 @@ export function popOutInDraft(d: OrbitState, tabId: TabId, opts: { toToday?: boo adoptPlacement(d, [tabId], today) } +/** + * Whether a Favorite split coming apart into `tiles` tiles would take its profile past the + * Favorites limit (FV-02). A split anywhere else never counts. + */ +export function overflowsFavorites(d: OrbitState, splitId: SplitId, tiles: number): boolean { + const loc = locate(d, splitId) + if (loc?.container.kind !== 'favorites') return false + const count = d.profiles[loc.container.profileId]?.favorites.length ?? 0 + return count - 1 + tiles > MAX_FAVORITES +} + +/** Whether `container` is a Favorites grid with no room for one more tile (FV-02). */ +export function favoritesFull(d: OrbitState, container: ContainerRef): boolean { + return container.kind === 'favorites' && (d.profiles[container.profileId]?.favorites.length ?? 0) >= MAX_FAVORITES +} + +/** + * Whether `recipe` would take a profile's Favorites past the limit (FV-02). It runs on a + * throwaway copy of the state; only a grid that grows counts. + */ +export function growsFavoritesPastLimit(d: OrbitState, recipe: (draft: OrbitState) => void): boolean { + const after = produce(d, recipe) + return Object.values(after.profiles).some( + (p) => p.favorites.length > MAX_FAVORITES && p.favorites.length > (d.profiles[p.id]?.favorites.length ?? 0), + ) +} + /** Replaces a pane's tab with another open tab (which leaves its own place). The old tab goes to Today. */ export function swapIntoPaneInDraft(d: OrbitState, paneTab: TabId, incoming: TabId): boolean { const splitId = splitOfTab(d, paneTab) @@ -402,8 +431,9 @@ export function restoreUnitInDraft(d: OrbitState, unit: SplitUnit, now = Date.no const canonical = normalizeLayout(layout) if (!canonical || canonical.type === 'leaf') return undefined const first = present[0] as TabId + // Its old place, unless that is a full Favorites grid (FV-02): then Today. const where = - unit.location && containerItems(d, unit.location.container) + unit.location && containerItems(d, unit.location.container) && !favoritesFull(d, unit.location.container) ? unit.location : (() => { const today = todayOf(d, first) diff --git a/src/main/split/strip.ts b/src/main/split/strip.ts index 876f2603..8fb50210 100644 --- a/src/main/split/strip.ts +++ b/src/main/split/strip.ts @@ -30,6 +30,10 @@ interface StripState { /** The strip fades out over this long before its view hides (SV-12). */ const FADE_OUT_MS = 120 +/** The view still has its page. Once the page is destroyed, `view.webContents` is undefined. */ +const live = (view: WebContentsView | undefined): view is WebContentsView => + !!view?.webContents && !view.webContents.isDestroyed() + /** * The floating pane strip (SV-12): a small transparent view centred at the top of a split * pane with the pane's close button and its split-controls menu. Pages are native views @@ -46,7 +50,7 @@ export class PaneStrip { // The strip follows its pane when the panes move (window resize, divider drag; review F7). ctx.events.on('layout.panes', ({ panes }, { windowId }) => { const s = windowId ? this.windows.get(windowId) : undefined - if (!windowId || !s?.tabId || !s.view || s.view.webContents.isDestroyed()) return + if (!windowId || !s?.tabId || !live(s.view)) return const pane = panes.find((p) => p.tabId === s.tabId) if (pane) s.view.setBounds(this.boundsOver(pane.rect)) else this.hide(windowId, true) @@ -75,7 +79,7 @@ export class PaneStrip { const view = s?.view return { tabId: s?.tabId ?? null, - visible: !!view && !view.webContents.isDestroyed() && view.getVisible(), + visible: live(view) && view.getVisible(), bounds: view?.getBounds(), } } @@ -158,14 +162,14 @@ export class PaneStrip { s.hovered = false s.dragging = false const view = s.view - if (!view || view.webContents.isDestroyed()) return + if (!live(view)) return if (now || !wasShown) { view.setVisible(false) return } view.webContents.send('orbit:strip:hide', {}) s.fadeTimer = setTimeout(() => { - if (!s.tabId && !view.webContents.isDestroyed()) view.setVisible(false) + if (!s.tabId && live(view)) view.setVisible(false) }, FADE_OUT_MS) } @@ -208,6 +212,9 @@ export class PaneStrip { loadRenderer(contents, 'strip') const win = this.ctx.windows.get(windowId) win?.once('closed', () => { + // A timer left running would act on a view that is gone: a throw there kept Orbit from quitting (D28). + const s = this.windows.get(windowId) + if (s) for (const timer of [s.showTimer, s.hideTimer, s.fadeTimer]) clearTimeout(timer) this.windows.delete(windowId) if (!contents.isDestroyed()) contents.close() }) diff --git a/src/main/tabs/context-menu.ts b/src/main/tabs/context-menu.ts index d10d59fc..c7c4f082 100644 --- a/src/main/tabs/context-menu.ts +++ b/src/main/tabs/context-menu.ts @@ -6,7 +6,10 @@ import { linkSplitMenuItem } from '../split/menu' /** Native right-click menu for web pages. */ export function showPageContextMenu(ctx: MainContext, tabId: TabId, contents: WebContents, params: ContextMenuParams) { - const items: MenuItemConstructorOptions[] = [] + // Items other features add (media: Picture in Picture on a video). + const items: MenuItemConstructorOptions[] = (ctx.hooks.pageContextMenu ?? []).flatMap((hook) => + hook(tabId, contents, params), + ) const openInNewTab = (url: string, activate: boolean) => { const tab = ctx.store.state.tabs[tabId] void ctx.commands.run('tabs.create', { url, spaceId: tab?.spaceId, activate }, {}) diff --git a/src/main/tabs/model.ts b/src/main/tabs/model.ts index f875def1..635eff7e 100644 --- a/src/main/tabs/model.ts +++ b/src/main/tabs/model.ts @@ -3,6 +3,7 @@ */ import { DEFAULT_PROFILE_ID } from '../../shared/defaults' import { newId } from '../../shared/ids' +import { isMediaBusy } from '../../shared/media' import { noteSplitFocus, removePane } from '../../shared/split' import type { ItemId, OrbitState, Space, SpaceId, Tab, TabId, WindowId } from '../../shared/state' import { @@ -257,7 +258,8 @@ export function autoArchiveCandidates(state: OrbitState, now: number): ItemId[] .map((t) => state.tabs[t]) .filter((t): t is Tab => !!t) if (tabs.length === 0) continue - if (tabs.some((t) => shown.has(t.id) || t.audible)) continue + // Tabs playing media, in picture-in-picture or using camera/microphone stay (MD-12). + if (tabs.some((t) => shown.has(t.id) || isMediaBusy(t))) continue const last = Math.max(...tabs.map((t) => t.lastActiveAt)) if (now - last >= after) out.push(id) } @@ -279,7 +281,7 @@ export function sleepCandidates(state: OrbitState, now: number, awake: Iterable< const out: TabId[] = [] for (const id of awake) { const tab = state.tabs[id] - if (!tab || shown.has(id) || tab.audible) continue + if (!tab || shown.has(id) || isMediaBusy(tab)) continue const limit = sectionOf(state, id) === 'favorite' ? after * FAVORITE_WARM_FACTOR : after if (now - tab.lastActiveAt >= limit) out.push(id) } diff --git a/src/main/tabs/ops.ts b/src/main/tabs/ops.ts index 9c9efb27..e96b6f7a 100644 --- a/src/main/tabs/ops.ts +++ b/src/main/tabs/ops.ts @@ -3,8 +3,9 @@ * drag-and-drop and the maintenance timers all go through these so the rules * (selection after close, archive, undo, pinned URL bookkeeping) live in one place. */ -import { createWindowState, MAX_FAVORITES } from '../../shared/defaults' +import { createWindowState, FAVORITES_FULL_MESSAGE, MAX_FAVORITES } from '../../shared/defaults' import { isFolderId, isTabId, newId } from '../../shared/ids' +import { isMediaBusy } from '../../shared/media' import { isPendingPane, layoutLeaves, paneAfterClose, SPLIT_FULL_MESSAGE } from '../../shared/split' import type { ArchivedTab, @@ -39,6 +40,7 @@ import { import type { MainContext } from '../context' import { addPaneInDraft, + favoritesFull, isFailure, type PaneAnchor, restorePaneInDraft, @@ -151,7 +153,7 @@ export class TabOps { if (!spaceObj) throw new Error(`Unknown space ${space}`) const target: ContainerRef = opts.container ?? { kind: 'today', spaceId: space } if (target.kind === 'favorites' && this.favoriteCount(target.profileId) >= MAX_FAVORITES) { - this.ctx.toast(opts.windowId, `Favorites are full (${MAX_FAVORITES} max)`) + this.ctx.toast(opts.windowId, FAVORITES_FULL_MESSAGE) throw new Error('Favorites are full') } const profileId = profileOfContainer(state, target) ?? spaceObj.profileId @@ -366,7 +368,11 @@ export class TabOps { } if (!d.spaces[tab.spaceId]) tab.spaceId = d.spaceOrder[0] as string d.tabs[tab.id] = tab - const where = rec.location && containerItems(d, rec.location.container) ? rec.location : undefined + // Its old place, unless that is a full Favorites grid (FV-02): then Today. + const where = + rec.location && containerItems(d, rec.location.container) && !favoritesFull(d, rec.location.container) + ? rec.location + : undefined let landed: ContainerRef | undefined // A pane goes back into its split, in this same update (no frame shows it on its own). const intoSplit = rec.pane ? restorePaneInDraft(d, tab.id, rec.pane) : undefined @@ -441,7 +447,9 @@ export class TabOps { clearToday(spaceId: SpaceId): void { const space = this.state.spaces[spaceId] if (!space) return - const tabs = space.today.flatMap((id) => tabsInItem(this.state, id)).filter((id) => !this.state.tabs[id]?.audible) + const tabs = space.today + .flatMap((id) => tabsInItem(this.state, id)) + .filter((id) => !isMediaBusy(this.state.tabs[id])) this.closeTabs(tabs, 'cleared', { reselect: 'pinned', undoLabel: 'Clear Today' }) } @@ -456,7 +464,7 @@ export class TabOps { const tabs = space.today .slice(loc.index + 1) .flatMap((id) => tabsInItem(state, id)) - .filter((id) => !state.tabs[id]?.audible) + .filter((id) => !isMediaBusy(state.tabs[id])) this.closeTabs(tabs, 'cleared', { undoLabel: 'Archive Tabs Below' }) } @@ -774,7 +782,7 @@ export class TabOps { return !from || containerKey(from.container) !== target }).length if (incoming > 0 && this.favoriteCount(to.profileId) + incoming > MAX_FAVORITES) { - this.ctx.toast(opts.windowId, `Favorites are full (${MAX_FAVORITES} max)`) + this.ctx.toast(opts.windowId, FAVORITES_FULL_MESSAGE) return false } } diff --git a/src/main/test-hooks.ts b/src/main/test-hooks.ts index 12e6abac..1532b428 100644 --- a/src/main/test-hooks.ts +++ b/src/main/test-hooks.ts @@ -7,11 +7,16 @@ import type { Persistence } from './persist' * ORBIT_E2E=1. Playwright reaches it with electronApp.evaluate(). */ export function exposeTestHooks(ctx: MainContext, persistence: Persistence): void { + // Playwright's evaluate can run in the middle of other main-process code (the inspector + // dispatches it by interrupt), even inside a store update whose result would then drop the + // command's change. The e2e helpers defer every evaluate; this covers the perf and capture scripts. + const later = (fn: () => T | Promise): Promise => new Promise((r) => setImmediate(r)).then(fn) const hooks = { ctx, state: () => ctx.store.state, - run: (name: CommandName, args: unknown, windowId?: string) => ctx.commands.run(name, args as never, { windowId }), - action: (id: string, windowId?: string) => ctx.actions.run(id, { windowId }), + run: (name: CommandName, args: unknown, windowId?: string) => + later(() => ctx.commands.run(name, args as never, { windowId })), + action: (id: string, windowId?: string) => later(() => ctx.actions.run(id, { windowId })), flush: () => persistence.flush(), marks: () => ctx.perf.marks, windowIds: () => ctx.windows.ids(), diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index d8283e97..5a784846 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -7,6 +7,20 @@ import './styles/global.css' async function boot(): Promise { const view = window.orbit.view + // The floating player's controls (media lane): a small view of its own, without the app's shortcuts. + if (view === 'pip') { + const app = import('./pip/PipApp') + await initStore() + document.documentElement.dataset.view = view + document.documentElement.dataset.platform = window.orbit.platform + const { PipApp } = await app + createRoot(document.getElementById('root') as HTMLElement).render( + + + , + ) + return + } // Load the view's code while the state snapshot arrives. const app = view === 'overlay' diff --git a/src/renderer/src/pip/PipApp.tsx b/src/renderer/src/pip/PipApp.tsx new file mode 100644 index 00000000..cc0ea763 --- /dev/null +++ b/src/renderer/src/pip/PipApp.tsx @@ -0,0 +1,385 @@ +import { + Ellipsis, + Mic, + MicOff, + Pause, + Play, + RotateCcw, + RotateCw, + SkipBack, + SkipForward, + SquareArrowOutUpLeft, + Video, + VideoOff, + X, +} from 'lucide-react' +import { type ReactNode, useEffect, useRef, useState } from 'react' +import { currentPosition, formatClock, inCall } from '../../../shared/media' +import type { OrbitState, PipCorner, TabId, TabMedia } from '../../../shared/state' +import { invoke, orbit } from '../api' +import { useOrbit } from '../store' +import './pip.css' + +/** Seconds per ← / → press when the player has focus (MD-05); the buttons skip 15 s (MD-04). */ +const KEY_STEP = 5 +const BUTTON_STEP = 15 +/** A press becomes a move once the pointer travels this far (px). */ +const DRAG_SLOP = 3 +const CORNERS: PipCorner[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'] + +function playerTab(s: OrbitState): TabId | null { + for (const id in s.tabs) if (s.tabs[id]?.media?.player) return id + return null +} + +/** + * The floating player's controls (media.md MD-03 to MD-06, MD-13), in a transparent view over the + * borrowed page. With the pointer away only the video shows; hovering reveals the controls over a + * soft gradient. Moving, resizing and hover-scrolling are reported to the main process, which + * moves the window (it snaps to a corner on release). + */ +export function PipApp() { + const tabId = useOrbit(playerTab) + const tab = useOrbit((s) => (tabId ? s.tabs[tabId] : undefined)) + const media = tab?.media + const [hover, setHover] = useState(false) + // Play/pause answers at once; the page's next report (a new `at`) has the final say. + const [optimistic, setOptimistic] = useState(null) + const reported = media?.at + // biome-ignore lint/correctness/useExhaustiveDependencies: reset on every new report from the page + useEffect(() => setOptimistic(null), [reported]) + const drag = useRef<{ kind: 'move' | PipCorner; x: number; y: number; started: boolean } | null>(null) + + // The player appeared under a resting pointer and moved away from it (the enter motion): the + // main process says so, as no pointer event may come (MD-03: video only until hovered). + useEffect(() => { + const away = () => { + if (!drag.current) setHover(false) + } + window.addEventListener('orbit-pip-pointer-away', away) + return () => window.removeEventListener('orbit-pip-pointer-away', away) + }, []) + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!tabId || e.metaKey || e.ctrlKey || e.altKey) return + if (e.key === ' ' || e.key === 'k') { + e.preventDefault() + void invoke('media.control', { tabId, action: 'toggle' }) + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + e.preventDefault() + void invoke('media.control', { + tabId, + action: 'seek', + seconds: e.key === 'ArrowLeft' ? -KEY_STEP : KEY_STEP, + }) + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [tabId]) + + if (!tabId || !tab || !media) return
+ + const call = inCall(tab) + const playing = optimistic ?? !media.paused + const control = (action: 'play' | 'pause' | 'seek' | 'next' | 'previous', seconds?: number) => + void invoke('media.control', { tabId, action, seconds }) + const toggle = () => { + setOptimistic(!playing) + control(playing ? 'pause' : 'play') + } + + const send = (phase: 'start' | 'move' | 'end', kind: 'move' | PipCorner, x: number, y: number) => + orbit.send('media.pipGesture', { phase, kind, x, y, t: Date.now() }) + + const onPointerDown = (e: React.PointerEvent, kind: 'move' | PipCorner) => { + if (e.button !== 0) return + e.currentTarget.setPointerCapture(e.pointerId) + drag.current = { kind, x: e.screenX, y: e.screenY, started: kind !== 'move' } + if (kind !== 'move') send('start', kind, e.screenX, e.screenY) + } + const onPointerMove = (e: React.PointerEvent) => { + const d = drag.current + if (!d) return + if (!d.started) { + if (Math.hypot(e.screenX - d.x, e.screenY - d.y) < DRAG_SLOP) return + d.started = true + send('start', d.kind, d.x, d.y) + } + send('move', d.kind, e.screenX, e.screenY) + } + const onPointerUp = (e: React.PointerEvent) => { + const d = drag.current + drag.current = null + if (d?.started) send('end', d.kind, e.screenX, e.screenY) + } + + const onWheel = (e: React.WheelEvent) => { + // The engine may end a scroll with an empty wheel event: nothing to move (or to interrupt). + if (!e.deltaX && !e.deltaY) return + const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 160 : 1 + // ⌘ + scroll, or a pinch (which arrives as a wheel with Ctrl), zooms into the video (MD-06). + if (e.ctrlKey || e.metaKey) { + void invoke('media.pipZoom', { factor: Math.exp((-e.deltaY * unit) / 200), x: e.clientX, y: e.clientY }) + return + } + // A two-finger scroll over the player slides it around (MD-06). + orbit.send('media.pipGesture', { phase: 'wheel', x: e.deltaX * unit, y: e.deltaY * unit }) + } + + const live = !!media.live + const duration = media.duration ?? 0 + + return ( +
setHover(true)} + onPointerLeave={() => { + if (!drag.current) setHover(false) + }} + onPointerDown={(e) => onPointerDown(e, 'move')} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerCancel={onPointerUp} + onLostPointerCapture={onPointerUp} + onWheel={onWheel} + onDoubleClick={(e) => { + if ((e.target as HTMLElement).closest('button, .pip__progress')) return + void invoke('media.pipBack', {}) + }} + onContextMenu={(e) => { + e.preventDefault() + void invoke('media.pipMenu', { x: e.clientX, y: e.clientY }) + }} + > +
+
+ void invoke('media.pipBack', {})} + > + + + + { + const r = e.currentTarget.getBoundingClientRect() + void invoke('media.pipMenu', { x: r.left, y: r.bottom + 4 }) + }} + > + + + void invoke('media.pipClose', {})}> + + +
+
+ {call ? ( + <> + {media.mic && ( + void invoke('media.call', { tabId, action: 'mic' })} + > + {media.micMuted ? : } + + )} + {media.camera && ( + void invoke('media.call', { tabId, action: 'camera' })} + > + {media.cameraOff ? : + )} + + ) : ( + <> + {media.title && media.seekable && !live && ( + control('previous')}> + + + )} + {media.seekable && !live && ( + control('seek', -BUTTON_STEP)} + > + + + )} + + {playing ? : } + + {media.seekable && !live && ( + control('seek', BUTTON_STEP)} + > + + + )} + {media.title && media.seekable && !live && ( + control('next')}> + + + )} + + )} +
+ {!call && ( +
+ {live ? ( + + + LIVE + + ) : ( + + )} +
+ )} + {CORNERS.map((corner) => ( +
{ + e.stopPropagation() + onPointerDown(e, corner) + }} + /> + ))} +
+ ) +} + +/** ±15 s: a circular arrow with the step inside. */ +function Skip({ back }: { back?: boolean }) { + return ( + + {back ? : } + {BUTTON_STEP} + + ) +} + +/** Elapsed time, a seekable bar and the length (MD-04). The time runs on while playing. */ +function Progress({ + tabId, + media, + duration, + visible, +}: { + tabId: TabId + media: TabMedia + duration: number + visible: boolean +}) { + const [now, setNow] = useState(() => Date.now()) + const [scrub, setScrub] = useState(null) + const barRef = useRef(null) + const running = visible && !media.paused + useEffect(() => { + if (!running) return + const timer = setInterval(() => setNow(Date.now()), 250) + return () => clearInterval(timer) + }, [running]) + const position = scrub ?? currentPosition(media, now) + const fraction = duration > 0 ? Math.min(1, Math.max(0, position / duration)) : 0 + const at = (clientX: number) => { + const r = barRef.current?.getBoundingClientRect() + if (!r || r.width <= 0 || duration <= 0) return 0 + return Math.min(1, Math.max(0, (clientX - r.left) / r.width)) * duration + } + return ( +
+ + {formatClock(position)} + +
0 ? '' : ' is-unknown'}`} + data-testid="pip-progress" + role="slider" + aria-label="Position" + aria-valuemin={0} + aria-valuemax={Math.round(duration)} + aria-valuenow={Math.round(position)} + tabIndex={-1} + onPointerDown={(e) => { + if (duration <= 0 || e.button !== 0) return + e.stopPropagation() + e.currentTarget.setPointerCapture(e.pointerId) + setScrub(at(e.clientX)) + }} + onPointerMove={(e) => { + if (scrub === null) return + e.stopPropagation() + setScrub(at(e.clientX)) + }} + onPointerUp={(e) => { + if (scrub === null) return + e.stopPropagation() + const to = at(e.clientX) + setScrub(null) + setNow(Date.now()) + void invoke('media.control', { tabId, action: 'seekTo', seconds: to }) + }} + > +
+
+ + {duration > 0 ? formatClock(duration) : ''} + +
+ ) +} + +function PipButton({ + label, + testId, + big, + small, + active, + onClick, + children, +}: { + label: string + testId: string + big?: boolean + small?: boolean + active?: boolean + onClick: (e: React.MouseEvent) => void + children: ReactNode +}) { + return ( + + ) +} diff --git a/src/renderer/src/pip/pip.css b/src/renderer/src/pip/pip.css new file mode 100644 index 00000000..e651d6ac --- /dev/null +++ b/src/renderer/src/pip/pip.css @@ -0,0 +1,271 @@ +/* Orbit's floating player controls (media lane; MD-03, MD-04, MD-06). The page's video shows + through: this view is transparent and only draws the controls, on hover. */ + +html[data-view="pip"], +html[data-view="pip"] body { + background: transparent; + overflow: hidden; +} + +.pip { + position: fixed; + inset: 0; + color: #fff; + font-size: 12px; + cursor: grab; + touch-action: none; +} + +.pip:active { + cursor: grabbing; +} + +/* Controls fade in quickly on hover (feel.md "instant") and out a little slower ("quick"). */ +.pip__shade, +.pip__top, +.pip__middle, +.pip__bottom { + opacity: 0; + transition: opacity 140ms cubic-bezier(0.2, 0, 0, 1); +} + +.pip.is-hover .pip__shade, +.pip.is-hover .pip__top, +.pip.is-hover .pip__middle, +.pip.is-hover .pip__bottom, +.pip:focus-within .pip__top, +.pip:focus-within .pip__middle, +.pip:focus-within .pip__bottom, +.pip:focus-within .pip__shade { + opacity: 1; + transition-duration: 90ms; + transition-timing-function: ease-out; +} + +.pip__shade { + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(to bottom, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0) 38%), + linear-gradient(to top, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0) 42%), rgba(0, 0, 0, 0.12); +} + +.pip__top { + position: absolute; + top: 6px; + left: 6px; + right: 6px; + display: flex; + align-items: center; + gap: 2px; +} + +.pip__spacer { + flex: 1; +} + +.pip__middle { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + pointer-events: none; +} + +.pip__middle > * { + pointer-events: auto; +} + +.pip__bottom { + position: absolute; + left: 10px; + right: 10px; + bottom: 6px; + display: flex; + align-items: center; + min-height: 18px; +} + +.pip__btn { + position: relative; + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 50%; + color: #fff; + cursor: default; + filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.45)); + transition: + background 90ms ease-out, + transform 90ms ease; +} + +.pip__btn:hover { + background: rgba(255, 255, 255, 0.18); +} + +.pip__btn:active { + transform: scale(0.9); +} + +.pip__btn:focus-visible { + outline: 2px solid rgba(255, 255, 255, 0.85); + outline-offset: -2px; +} + +.pip__btn--big { + width: 44px; + height: 44px; +} + +.pip__btn--small { + width: 26px; + height: 26px; +} + +.pip__btn.is-on { + background: rgba(229, 72, 77, 0.9); +} + +.pip__skip { + position: relative; + display: grid; + place-items: center; +} + +.pip__skip-n { + position: absolute; + font-size: 7.5px; + font-weight: 700; + letter-spacing: -0.2px; + line-height: 1; + padding-top: 1px; +} + +.pip__progress-row { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.pip__time { + flex: none; + min-width: 26px; + font-size: 11px; + font-variant-numeric: tabular-nums; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); +} + +.pip__time:last-child { + text-align: right; +} + +.pip__progress { + position: relative; + flex: 1; + height: 14px; + cursor: pointer; +} + +.pip__progress::before, +.pip__progress-fill { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 5px; + height: 4px; + border-radius: 2px; + transition: + height 90ms ease-out, + top 90ms ease-out; +} + +.pip__progress::before { + background: rgba(255, 255, 255, 0.3); +} + +.pip__progress-fill { + background: #fff; + transform-origin: 0 50%; +} + +.pip__progress:hover::before, +.pip__progress:hover .pip__progress-fill { + top: 4px; + height: 6px; +} + +.pip__progress.is-unknown { + cursor: default; +} + +.pip__live { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 7px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.45); + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.4px; +} + +.pip__live-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #e5484d; +} + +/* "Back to tab" (MD-16): a still of the last frame flies back into the page, without controls. */ +html[data-still] .pip { + visibility: hidden; +} + +/* Corner handles to resize (MD-06); invisible, with the matching cursor. */ +.pip__handle { + position: absolute; + width: 16px; + height: 16px; + z-index: 2; +} + +.pip__handle--top-left { + top: 0; + left: 0; + cursor: nwse-resize; +} + +.pip__handle--top-right { + top: 0; + right: 0; + cursor: nesw-resize; +} + +.pip__handle--bottom-left { + bottom: 0; + left: 0; + cursor: nesw-resize; +} + +.pip__handle--bottom-right { + bottom: 0; + right: 0; + cursor: nwse-resize; +} + +@media (prefers-reduced-motion: reduce) { + .pip__btn, + .pip__progress::before, + .pip__progress-fill { + transition: none; + } +} diff --git a/src/renderer/src/shell/media/CaptureIndicator.tsx b/src/renderer/src/shell/media/CaptureIndicator.tsx new file mode 100644 index 00000000..126eca3f --- /dev/null +++ b/src/renderer/src/shell/media/CaptureIndicator.tsx @@ -0,0 +1,18 @@ +import { Mic, Video } from 'lucide-react' +import type { TabMedia } from '../../../../shared/state' +import './media.css' + +/** + * A tab using the camera or microphone shows a pulsing red indicator on its row (TB-15, MD-11). + * Media lane; drawn by the tab row next to the speaker icon. + */ +export function CaptureIndicator({ media }: { media: TabMedia | undefined }) { + if (!media?.mic && !media?.camera) return null + const label = + media.camera && media.mic ? 'Using camera and microphone' : media.camera ? 'Using camera' : 'Using microphone' + return ( + + {media.camera ? + ) +} diff --git a/src/renderer/src/shell/media/MediaTray.tsx b/src/renderer/src/shell/media/MediaTray.tsx new file mode 100644 index 00000000..61c3d3d7 --- /dev/null +++ b/src/renderer/src/shell/media/MediaTray.tsx @@ -0,0 +1,254 @@ +import { Mic, MicOff, Pause, PictureInPicture2, Play, SkipBack, SkipForward, Video, VideoOff, X } from 'lucide-react' +import { AnimatePresence, motion, useReducedMotion } from 'motion/react' +import { + type CSSProperties, + memo, + type MouseEvent as ReactMouseEvent, + type ReactNode, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react' +import { inCall, mediaSubtitle, mediaTitle, trayTabIds } from '../../../../shared/media' +import type { TabId } from '../../../../shared/state' +import { invoke } from '../../api' +import { useOrbit, useOrbitShallow } from '../../store' +import { Favicon } from '../../ui/Favicon' +import { QUICK_EASE, SNAPPY } from '../sidebar/motion-tokens' +import './media.css' + +/** + * Controllers whose video was just sent to the floating player: the player grows out of the + * controller (MD-10, MD-16), so the controller gives way at once instead of sliding down. + */ +const toPlayer = new Set() + +const FADE = { duration: 0.12, ease: QUICK_EASE } + +/** + * The sidebar media tray (MD-09, MD-10, MD-14; sidebar.md SB-10): one controller per tab playing + * in the background, newest on top, just above the bottom bar. It slides up when a playing tab is + * left and down when it goes (FL-32, MD-16); with reduced motion it only fades. + */ +export const MediaTray = memo(function MediaTray() { + const ids = useOrbitShallow(trayTabIds) + const reduce = useReducedMotion() + const variants = reduce + ? { + hidden: { opacity: 0, transition: FADE }, + shown: { opacity: 1, transition: FADE }, + exit: { opacity: 0, transition: FADE }, + } + : { + hidden: { opacity: 0, height: 0, y: 16 }, + shown: { opacity: 1, height: 'auto', y: 0, transition: SNAPPY }, + exit: (id: TabId) => + toPlayer.has(id) + ? // Gone at once where the player takes over, then the tray closes the gap. + { opacity: 0, height: 0, transition: { opacity: { duration: 0.06 }, height: { ...SNAPPY, delay: 0.08 } } } + : { opacity: 0, height: 0, y: 16, transition: SNAPPY }, + } + return ( +
+ + {ids.map((id) => ( + + + + ))} + +
+ ) +}) + +/** One background tab's controls: source (click to go there), play/pause, next, PiP, close. */ +const MediaController = memo(function MediaController({ tabId }: { tabId: TabId }) { + const tab = useOrbit((s) => s.tabs[tabId]) + const media = tab?.media + // Controls answer at once: the icon flips on click, before the page confirms (MD-16). + const [optimistic, setOptimistic] = useState(null) + const confirmed = media?.playing + useEffect(() => { + if (confirmed !== undefined) setOptimistic(null) + }, [confirmed]) + if (!tab || !media) return null + const playing = optimistic ?? media.playing + const call = inCall(tab) + const toggle = () => { + setOptimistic(!playing) + void invoke('media.control', { tabId, action: playing ? 'pause' : 'play' }) + } + return ( +
{ + e.preventDefault() + void invoke('media.menu', { tabId }) + }} + > +