From 80a87ef9209b69c6288c7052e63e3024bc2eb1f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 15:25:43 +0000 Subject: [PATCH] feat: put the video back when the tab comes back, undoing auto-PiP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New setting `autoRestoreOnTabReturn`: when the tab becomes visible again, a video that *this extension* floated on the last tab hide leaves PiP and goes back where it came from. **On by default, while `autoPipOnTabHide` stays off** — coherent only because nothing is ever remembered while the parent is off, so this can do nothing until the user switches that on. It is a shape rather than a behaviour: it says what auto-PiP means for whoever turns it on without reading further. The rule the two defaults satisfy together is that no window moves before the user has asked for anything. The control is `disabled` while the parent is off, in `options.html` as well as in `render` — not unchecked, which would misreport what is stored, and not hidden, which would make the feature undiscoverable exactly where somebody is reading about it. RRR §4.6 previously ruled restoring on return out, and its objection — the user may have deliberately kept the window floating — is answered inside the setting's own definition rather than waived. Two conditions, each with its own test: the video must be one we floated, and it must have been in PiP continuously since, judged by the browser's presentation reports (`onModeChange`) and not by the mode read on return. Those differ exactly when the user takes the window out and puts it back by hand: same mode, opposite intent. Deleting the subscription leaves 50 of 52 auto-PiP tests green. Exit is `togglePiP`, the call the hotkey makes, so "back where it came from" includes BF19's document fullscreen — which Safari will usually refuse for want of a user gesture. Accepted and documented in the options page and the README rather than suppressed: inline is where the user lands either way. 491 tests (was 471); `dist/content.js` 8050 → 8259 B of 12288 (RRR §5.2). Decisions 318–325. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MruXVD9WM1YTATWgftepZW --- ARCHITECTURE.md | 2 +- DECISIONS.md | 60 ++++ PLAN.md | 5 + PiPOSS Extension/Resources/options.html | 33 ++- .../Resources/src/core/autopip.ts | 100 ++++++- .../Resources/src/core/settings.ts | 11 + PiPOSS Extension/Resources/src/options.ts | 24 ++ .../Resources/test/autopip.test.ts | 265 +++++++++++++++++- .../Resources/test/options.test.ts | 84 +++++- .../Resources/test/settings.test.ts | 90 +++++- README.md | 7 + RRR.md | 30 +- 12 files changed, 680 insertions(+), 31 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6ffcc6c..e1d716a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -215,7 +215,7 @@ it. "Must not know" is the boundary that breaks if you ignore it. | [R/src/core/presentation.ts](PiPOSS%20Extension/Resources/src/core/presentation.ts) | the `PresentationController` seam, both implementations, `togglePiP`, the restore record | which site, which trigger, any setting | | [R/src/core/video.ts](PiPOSS%20Extension/Resources/src/core/video.ts) | `getVideos` / `pickVideo` — which video a *user-initiated* toggle means | WebKit; presentation modes | | [R/src/core/hotkey.ts](PiPOSS%20Extension/Resources/src/core/hotkey.ts) | key matching (three ways to recognise one key), the Cmd/Ctrl/Alt veto, editable-target suppression, `isSinglePrintableKey` | what the key *does*; storage layout | -| [R/src/core/autopip.ts](PiPOSS%20Extension/Resources/src/core/autopip.ts) | RRR §4.6's three exclusions (paused, muted, < 5 s), "any video already in PiP means do nothing", `visibilitychange` | WebKit; any site | +| [R/src/core/autopip.ts](PiPOSS%20Extension/Resources/src/core/autopip.ts) | RRR §4.6's three exclusions (paused, muted, < 5 s), "any video already in PiP means do nothing", `visibilitychange`, and the claim the opt-in return half rests on — which video *we* floated, and whether it has been in PiP ever since | WebKit; any site | | [R/src/core/settings.ts](PiPOSS%20Extension/Resources/src/core/settings.ts) | the one `browser.storage.local` key, merge-over-defaults per field, unknown-field survival, the write queue | who reads a setting; the DOM | | [R/src/core/messages.ts](PiPOSS%20Extension/Resources/src/core/messages.ts) | every string on the wire (`TOGGLE_PIP`, `OPEN_OPTIONS_PAGE`) and the send/receive helpers | why a message was sent; videos | | [R/src/core/inject.ts](PiPOSS%20Extension/Resources/src/core/inject.ts) | both halves of one contract: `scripting.executeScript` under the `activeTab` grant, and `claimFrame()` which stops the second copy | what the content script does | diff --git a/DECISIONS.md b/DECISIONS.md index 8d81364..5c20e93 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2254,6 +2254,66 @@ BF19 — "fullscreen" is not a presentation mode of the video, and BF02 assumed member's real implementation plus the record's two fields; the budget is RRR §5.2's and this stays comfortably inside it. +## 2026-07-31 · T22 The return half of auto-PiP + +318. **Restoring on return became a setting rather than a behaviour, and RRR §4.6 was amended + rather than contradicted.** §4.6 said "do not restore automatically on return — the user + may have deliberately kept it floating", and that objection is still correct. What makes + an option legitimate is not that the owner asked for it but that the objection can be + answered inside the option's own definition: the feature acts only on a window *we* + opened and that has never been touched since. A setting that could close a window the + user chose to keep would have been the same mistake with a checkbox in front of it. +319. **The claim is a video plus a live mode subscription, not a video plus a mode read on + return.** Reading `getMode` on return refuses a video that has left PiP, but not one the + user took out and put back by hand while the tab was hidden — same mode, opposite intent. + `PresentationController.onModeChange`, BF02's fourth member, is what tells them apart, so + the claim expires the moment the browser reports anything other than PiP. Mutation: + deleting the subscription and keeping the live-mode check leaves 50 of 52 auto-PiP tests + green, and the one it breaks is exactly the out-and-back-in case. The seventh time in + this project that mutation was the only instrument that could see a difference. +320. **Exit is `togglePiP`, the same call the hotkey makes** — not `setMode(INLINE)`. The + restore record already holds where the video came from, including BF19's document + fullscreen, so "put it back" is one existing function and not a second answer that can + drift from it. Symmetric with DECISIONS 174, which routed the *entry* the same way. +321. **The fullscreen half will usually be refused, and that is accepted rather than worked + around.** `requestFullscreen` needs a user gesture; returning to a tab is not one, so + BF19's request goes out and is dropped — `enterFullscreen` already swallows the + rejection. The alternative was to suppress the request, which buys nothing: inline is + where the user lands either way, and a Safari that ever allows it would then be excluded + by our own code. The options page and the README say plainly that a fullscreen player + usually comes back inline; a promise the platform will not keep is worse than a caveat. +322. **The setting is read at the moment of return, not latched when the tab hid.** Turning + this on *is* a tab switch: the user leaves the video for the options page, which floats + the video, then comes back. Latching would answer their very first try with nothing + happening. The claim is dropped on return either way, so a return with the setting off + cannot leave a video that some later, unrelated return would restore. +323. **`autoRestoreOnTabReturn` is stored independently of `autoPipOnTabHide`, and is not + cleared when the parent goes off.** They are one feature to the user and two answers + here; clearing the child behind their back would lose it every time the parent was + turned off for an afternoon. Nothing is ever remembered while the parent is off, so a + stored `true` is inert until it means something again. +323a. **Which is exactly why the child defaults to `true` and the parent to `false`.** A + default that cannot act until the user switches something else on is a *shape* rather + than a behaviour: it says what auto-PiP means for anyone who turns it on without reading + further, and RRR §4.6's caution — the thing that keeps the parent off — is about floating + a window nobody asked for, not about putting our own back. The rule the two defaults have + to satisfy together is that no window is ever moved before the user has asked for + anything, and it holds: the return half is consulted only for a video this binding + floated, and floating one requires the parent. +323b. **The control is `disabled` while its parent is off, not hidden and not unchecked.** + Unchecked would be a lie about what is stored; hidden would make the feature undiscoverable + at the only moment somebody is reading that section. Greyed out says both true things at + once — this is your answer, and it is waiting on the switch above. It is written into + `options.html` as well as into `render`, because the markup is the page for the moment + before the bundle runs and it must not offer a control that has no effect. That pairing + is the same principle as RRR §4.3's access read-out, which is a marker and not a button + for exactly this reason. +324. **`visibilityState === 'visible'` and `=== 'hidden'` as named states, not `!== 'hidden'`.** + A prerendered document is neither of the two moments this feature is about, and the + existing hide half already made that distinction (RRR §4.6 names `'hidden'` for the same + reason). The return half inherits it rather than treating "not hidden" as "back". +325. Content bundle 8050 → 8259 B, 67% of the 12 KB budget (RRR §5.2). 491 tests, up from 471. + Predictions for acceptance, with their treatment ready: 8. **Prediction for T03.** Safari's MV3 `service_worker` background is supported diff --git a/PLAN.md b/PLAN.md index 2a08b34..175b069 100644 --- a/PLAN.md +++ b/PLAN.md @@ -92,6 +92,11 @@ the app, which is the only reason they were found at all. ## Open +- [x] **T22. `autoRestoreOnTabReturn`** — the return half of auto-PiP, off by default: a video + *this extension* floated on tab hide, and that has been in PiP continuously since, leaves + PiP again when the tab comes back. RRR §4.6 previously ruled restoring out; the owner + asked for it as an option, which answers the objection it was ruled out for. *Owner-asked.* + - [x] **T21. The methodology metrics file** — `metrics/artginzburg-piposs.json`, submitted as [autodev-methodology#1](https://github.com/vvginzburg/autodev-methodology/pull/1). Everything else T21 asked for was already done: RRR §10.1 is confirmed by `make check`, and REPORT.md diff --git a/PiPOSS Extension/Resources/options.html b/PiPOSS Extension/Resources/options.html index 51c5b46..d848c05 100644 --- a/PiPOSS Extension/Resources/options.html +++ b/PiPOSS Extension/Resources/options.html @@ -108,7 +108,38 @@

Behaviour

A video that is playing and not muted floats out when you leave its tab. Muted and very short videos are left alone, so a silent background loop cannot hijack the floating - window. Coming back does not put it away again. + window. Coming back does not put it away again, unless you ask for it below. +

+ + +

+ + +

+ +

+ Only for a window that floated out by itself on the way out, and that you left floating. + It goes back to where it was — though a player that was fullscreen usually comes back + inline, because only Safari can put a page back into fullscreen and only right after you + press something.

diff --git a/PiPOSS Extension/Resources/src/core/autopip.ts b/PiPOSS Extension/Resources/src/core/autopip.ts index ebc4c85..db9fa27 100644 --- a/PiPOSS Extension/Resources/src/core/autopip.ts +++ b/PiPOSS Extension/Resources/src/core/autopip.ts @@ -40,7 +40,12 @@ * a prerendered document nobody saw. * * Videos are looked up when the tab hides, so there is nothing to watch and no `MutationObserver` - * here. Coming back does **not** take the video out of PiP: the user may have kept it floating. + * here. Coming back does **not** take the video out of PiP unless `autoRestoreOnTabReturn` is on — + * off by default, and even then it acts only on a video *this binding* floated and that has been in + * PiP continuously since (see `claim` below). RRR §4.6's objection is "the user may have kept it + * floating", and that is what those two conditions answer: a window the user chose, entered or + * re-entered by hand is never ours to take away — which is also why that setting may default to + * on while the one that floats anything at all defaults to off. */ import { type PresentationController, PresentationMode, togglePiP } from './presentation'; import { DEFAULTS, type Settings, followSettings } from './settings'; @@ -123,6 +128,15 @@ export interface AutoPipBinding { */ readonly enabled: boolean; + /** + * Whether the return half is on right now — `autoRestoreOnTabReturn`, which starts at RRR §3's + * default of `true` rather than at `false` like {@link enabled}. The asymmetry is safe and not + * an oversight: this flag is consulted only for a video *this binding* floated, and floating + * one requires {@link enabled}, which is still `false` until the stored settings arrive. There + * is no window in which a `true` here can act on anything. + */ + readonly restoreEnabled: boolean; + /** `followSettings`'s barrier: resolves once the stored settings have been applied. */ readonly ready: Promise; @@ -143,10 +157,45 @@ export function enableAutoPip({ target = document, }: EnableAutoPipOptions): AutoPipBinding { let enabled = DEFAULTS.autoPipOnTabHide; + let restoreEnabled = DEFAULTS.autoRestoreOnTabReturn; - const handleVisibilityChange = (): void => { + /** + * The video *this binding* floated on the last tab hide, and nothing else — the whole claim + * the return half rests on. A video the user floated themselves is never in here, so the + * setting cannot take away a floating window they chose to keep, which is the objection RRR + * §4.6 raises against restoring on return at all. + */ + let floated: HTMLVideoElement | null = null; + + /** Undoes the mode subscription belonging to {@link floated}. */ + let releaseFloated: () => void = () => {}; + + /** Drop the claim: after acting on it, when it ends, and on `disable`. */ + const forget = (): void => { + releaseFloated(); + releaseFloated = () => {}; + floated = null; + }; + + /** + * Take the claim on a video we have just asked to float. + * + * The subscription is what makes the claim expire honestly. Reading the live mode on return + * would already refuse a video that is no longer in PiP, but not one the user took *out* and + * then put *back* by hand while the tab was hidden — same mode, entirely different intent. + * `onModeChange` is the browser's own report (DECISIONS 170), so any exit ends our claim + * whether it was the system PiP window's close button, the player's control or our hotkey. + */ + const claim = (video: HTMLVideoElement): void => { + forget(); + floated = video; + releaseFloated = controller.onModeChange(video, () => { + if (controller.getMode(video) !== PresentationMode.PIP) forget(); + }); + }; + + const handleTabHidden = (): void => { if (!enabled) return; - if (target.visibilityState !== 'hidden') return; const video = pickAutoPipVideo(getVideos(target), controller); if (!video) return; @@ -156,6 +205,46 @@ export function enableAutoPip({ // pointing at whatever mode the video was in the *last* time somebody toggled — // and the user's next hotkey press would put them into a mode they were never in. togglePiP(video, controller); + claim(video); + }; + + /** + * The return half, RRR §4.6's opt-in: put our own floating video back where it came from. + * + * `togglePiP` again rather than `setMode(INLINE)`, because "where it came from" is exactly + * what its restore record holds — fullscreen included, for the user who floated out of a + * fullscreen player. Two honest limits, both degrading to inline rather than to anything + * wedged: + * + * 1. **A fullscreen restore will usually be refused.** `requestFullscreen` wants a user + * gesture and a `visibilitychange` is not one, so the request that BF19 makes goes out and + * is dropped — `enterFullscreen` already swallows that rejection. The mode restore itself + * is a PiP *exit*, which carries no such requirement. + * 2. The setting is read **now**, not latched when the tab hid, because switching it on is + * itself a tab switch: the user leaves the video for the options page, which floats it, and + * the first return has to be the one that demonstrates the feature. The claim is dropped + * either way, so a return with the setting off does not leave a video that can be restored + * by a later, unrelated return. + */ + const handleTabVisible = (): void => { + const video = floated; + if (!video) return; + forget(); + + if (!restoreEnabled) return; + // A player the page has since rebuilt: the element still exists because we hold it, but + // nothing that happens to it is visible to the user any more. + if (!video.isConnected) return; + if (controller.getMode(video) !== PresentationMode.PIP) return; + + togglePiP(video, controller); + }; + + const handleVisibilityChange = (): void => { + // Named states, not `!== 'hidden'`: `prerender` is neither of the two moments this + // feature is about, and acting on it would float a video nobody has seen yet. + if (target.visibilityState === 'hidden') handleTabHidden(); + else if (target.visibilityState === 'visible') handleTabVisible(); }; // `passive`: this never calls `preventDefault`, and saying so lets the browser @@ -164,6 +253,7 @@ export function enableAutoPip({ const { ready, unsubscribe } = followSettings((settings: Settings) => { enabled = settings.autoPipOnTabHide; + restoreEnabled = settings.autoRestoreOnTabReturn; }); let attached = true; @@ -172,6 +262,9 @@ export function enableAutoPip({ get enabled(): boolean { return enabled; }, + get restoreEnabled(): boolean { + return restoreEnabled; + }, ready, disable(): void { if (!attached) return; @@ -179,6 +272,7 @@ export function enableAutoPip({ target.removeEventListener('visibilitychange', handleVisibilityChange); unsubscribe(); + forget(); }, }; } diff --git a/PiPOSS Extension/Resources/src/core/settings.ts b/PiPOSS Extension/Resources/src/core/settings.ts index 431ef6a..60d0d48 100644 --- a/PiPOSS Extension/Resources/src/core/settings.ts +++ b/PiPOSS Extension/Resources/src/core/settings.ts @@ -26,6 +26,15 @@ export interface Settings { hotkey: string; /** Enter PiP when the tab stops being visible while a video plays. */ autoPipOnTabHide: boolean; + /** + * Put a video that *this* extension floated on tab hide back where it came from when the tab + * becomes visible again. Meaningless on its own: nothing is ever remembered while + * {@link autoPipOnTabHide} is off, so this only ever undoes an entry we performed — which is + * why it defaults to `true` while its parent defaults to `false`. A default that can do + * nothing until the user switches something else on is a *shape*, not a behaviour: it says + * what auto-PiP means for anyone who turns it on without reading further. + */ + autoRestoreOnTabReturn: boolean; /** Show the PiP button in the YouTube player controls. */ youtubeButton: boolean; } @@ -37,6 +46,7 @@ export const SETTINGS_KEY = 'settings'; export const DEFAULTS: Readonly = Object.freeze({ hotkey: 'p', autoPipOnTabHide: false, + autoRestoreOnTabReturn: true, youtubeButton: true, }); @@ -227,6 +237,7 @@ function mergeOverDefaults(raw: BrowserStorageItems): Settings { return { hotkey: typeof raw.hotkey === 'string' ? raw.hotkey.toLowerCase() : DEFAULTS.hotkey, autoPipOnTabHide: asBoolean(raw.autoPipOnTabHide, DEFAULTS.autoPipOnTabHide), + autoRestoreOnTabReturn: asBoolean(raw.autoRestoreOnTabReturn, DEFAULTS.autoRestoreOnTabReturn), youtubeButton: asBoolean(raw.youtubeButton, DEFAULTS.youtubeButton), }; } diff --git a/PiPOSS Extension/Resources/src/options.ts b/PiPOSS Extension/Resources/src/options.ts index 8f344ca..6aca070 100644 --- a/PiPOSS Extension/Resources/src/options.ts +++ b/PiPOSS Extension/Resources/src/options.ts @@ -127,6 +127,7 @@ interface Controls { hotkeyReset: HTMLButtonElement; hotkeyDisable: HTMLButtonElement; autoPip: HTMLInputElement; + autoRestore: HTMLInputElement; youtubeButton: HTMLInputElement; saveError: HTMLElement; } @@ -168,6 +169,7 @@ export function initOptionsPage(doc: Document = document): OptionsPage { controls.hotkeyReset.addEventListener('click', handleResetClick); controls.hotkeyDisable.addEventListener('click', handleDisableClick); controls.autoPip.addEventListener('change', handleAutoPipChange); + controls.autoRestore.addEventListener('change', handleAutoRestoreChange); controls.youtubeButton.addEventListener('change', handleYouTubeButtonChange); // RRR §4.3: the state must live-update. Safari's access popover is a different surface entirely, @@ -201,6 +203,7 @@ export function initOptionsPage(doc: Document = document): OptionsPage { controls.hotkeyReset.removeEventListener('click', handleResetClick); controls.hotkeyDisable.removeEventListener('click', handleDisableClick); controls.autoPip.removeEventListener('change', handleAutoPipChange); + controls.autoRestore.removeEventListener('change', handleAutoRestoreChange); controls.youtubeButton.removeEventListener('change', handleYouTubeButtonChange); permissions?.onAdded.removeListener(handlePermissionsChanged); @@ -339,6 +342,21 @@ export function initOptionsPage(doc: Document = document): OptionsPage { commit({ autoPipOnTabHide: controls.autoPip.checked }); } + /** + * Written independently of `autoPipOnTabHide` rather than forced off with it: the two are one + * feature to the user but two answers, and clearing this one behind their back would lose it + * every time they turned the parent off for an afternoon. Nothing is ever remembered while the + * parent is off, so the stored value is inert until it means something again — which is also + * what lets this default to on while the parent defaults to off. + * + * The control is `disabled` in that state (see `render`), so this handler cannot fire from a + * click then. It can still fire from a test or a future layout that enables it, and writing + * the checkbox's own value is the right answer either way. + */ + function handleAutoRestoreChange(): void { + commit({ autoRestoreOnTabReturn: controls.autoRestore.checked }); + } + function handleYouTubeButtonChange(): void { commit({ youtubeButton: controls.youtubeButton.checked }); } @@ -392,6 +410,11 @@ export function initOptionsPage(doc: Document = document): OptionsPage { // fields, this project's README, the keycap on the desk. controls.hotkeyField.value = key.toUpperCase(); controls.autoPip.checked = settings.autoPipOnTabHide; + controls.autoRestore.checked = settings.autoRestoreOnTabReturn; + // Disabled, not hidden and not unchecked: with its parent off it can do nothing, and a live + // control that does nothing is what the access section above refuses to be. The stored value + // is still shown, because it is still the answer for the moment the parent comes back on. + controls.autoRestore.disabled = !settings.autoPipOnTabHide; controls.youtubeButton.checked = settings.youtubeButton; setHotkeyStatus( @@ -421,6 +444,7 @@ function findControls(doc: Document): Controls | null { hotkeyReset: byTestId(doc, 'hotkey-reset'), hotkeyDisable: byTestId(doc, 'hotkey-disable'), autoPip: byTestId(doc, 'toggle-autopip'), + autoRestore: byTestId(doc, 'toggle-auto-restore'), youtubeButton: byTestId(doc, 'toggle-youtube-button'), saveError: byTestId(doc, 'save-error'), }; diff --git a/PiPOSS Extension/Resources/test/autopip.test.ts b/PiPOSS Extension/Resources/test/autopip.test.ts index 0e98827..e1fa0e7 100644 --- a/PiPOSS Extension/Resources/test/autopip.test.ts +++ b/PiPOSS Extension/Resources/test/autopip.test.ts @@ -29,7 +29,7 @@ import { lastPresentationModeOf, togglePiP, } from '../src/core/presentation'; -import { SETTINGS_KEY, saveSettings } from '../src/core/settings'; +import { DEFAULTS, SETTINGS_KEY, saveSettings } from '../src/core/settings'; import { installFakeBrowser, uninstallFakeBrowser } from './helpers/fake-browser'; import { macrotask } from './helpers/timing'; import { @@ -229,23 +229,24 @@ describe('enableAutoPip', () => { expect(controller.setModeCalls).toEqual([]); }); - it('does not restore when the user comes back (RRR §4.6)', async () => { + it('does not restore when the user comes back, with autoRestoreOnTabReturn off (RRR §4.6)', async () => { const video = appendVideo(); - await start(); + await start({ autoPipOnTabHide: true, autoRestoreOnTabReturn: false }); setVisibility('hidden'); expect(controller.modeOf(video)).toBe(PresentationMode.PIP); setVisibility('visible'); - // The user may have kept it floating on purpose. One entry, no exit. + // The default, and the answer for anyone who has not asked otherwise: the user may have + // kept it floating on purpose. One entry, no exit. expect(controller.setModeCalls).toEqual([{ video, mode: PresentationMode.PIP }]); expect(controller.modeOf(video)).toBe(PresentationMode.PIP); }); it('does not fire again for a video it already floated', async () => { const video = appendVideo(); - await start(); + await start({ autoPipOnTabHide: true, autoRestoreOnTabReturn: false }); setVisibility('hidden'); setVisibility('visible'); @@ -495,6 +496,248 @@ describe('enableAutoPip', () => { }); }); +/** + * RRR §4.6's opt-in return half: `autoRestoreOnTabReturn` puts back what auto-PiP took, and + * nothing else. + * + * The shape of this file follows from what the setting is allowed to claim. "Nothing else" is + * two conditions, and each has its own test here because either one alone is a feature that + * closes a floating window the user chose to keep: + * + * - the video must be one **this binding floated** — never one already in PiP when the tab hid; + * - it must have been in PiP **continuously since**, which is why a claim expires on the + * browser's own mode report rather than on a mode read at return time. The + * out-and-back-in-by-hand test below is red against a live-mode check and green against the + * subscription: both end with the video in PiP, and only the report tells them apart. + */ +describe('coming back to the tab', () => { + /** Both halves on. The parent alone is the default `start()`. */ + const bothOn = { autoPipOnTabHide: true, autoRestoreOnTabReturn: true }; + + it('takes its own floating video back out of PiP', async () => { + const video = appendVideo(); + await start(bothOn); + + setVisibility('hidden'); + setVisibility('visible'); + + expect(controller.setModeCalls).toEqual([ + { video, mode: PresentationMode.PIP }, + { video, mode: PresentationMode.INLINE }, + ]); + }); + + it('restores the presentation the video came from, not merely inline', async () => { + const video = appendVideo(); + controller.setInitialMode(video, 'fullscreen'); + await start(bothOn); + + setVisibility('hidden'); + setVisibility('visible'); + + // Through `togglePiP`, whose record holds where the video was — the same answer the hotkey + // would give. A `setMode(INLINE)` written here would pass every other test in this block. + expect(controller.modeOf(video)).toBe('fullscreen'); + }); + + it('asks for the document fullscreen the user floated out of (BF19)', async () => { + const video = appendVideo(); + const player = document.createElement('div'); + document.body.appendChild(player); + player.appendChild(video); + await start(bothOn); + + controller.externalFullscreenChange(player); + setVisibility('hidden'); + setVisibility('visible'); + + // The request is made; whether Safari grants it is another matter, and the reason the + // options page says a fullscreen player usually comes back inline — `requestFullscreen` + // wants a user gesture, and returning to a tab is not one. + expect(controller.fullscreenRequests).toEqual([player]); + }); + + it('leaves alone a video that was already floating before the tab hid', async () => { + const video = appendVideo(); + controller.setInitialMode(video, PresentationMode.PIP); + await start(bothOn); + + setVisibility('hidden'); + setVisibility('visible'); + + // Nothing was floated by us, so there is nothing to put back. This is the user's own + // floating window, and the objection RRR §4.6 raises against the whole feature. + expect(controller.setModeCalls).toEqual([]); + }); + + it('leaves alone a video the user took out of PiP and floated again by hand', async () => { + const video = appendVideo(); + await start(bothOn); + + setVisibility('hidden'); + + // The system PiP window's close button, then the player's own PiP control — both reach us + // only as the browser's report. The video ends where it started, in PiP, but this one is + // the user's choice and not ours to undo. + controller.externalModeChange(video, PresentationMode.INLINE); + controller.externalModeChange(video, PresentationMode.PIP); + + setVisibility('visible'); + + expect(controller.setModeCalls).toEqual([{ video, mode: PresentationMode.PIP }]); + expect(controller.modeOf(video)).toBe(PresentationMode.PIP); + }); + + it('does nothing when the video is no longer in PiP at all', async () => { + const video = appendVideo(); + await start(bothOn); + + setVisibility('hidden'); + controller.externalModeChange(video, PresentationMode.INLINE); + setVisibility('visible'); + + expect(controller.setModeCalls).toEqual([{ video, mode: PresentationMode.PIP }]); + }); + + it('does nothing when the player has been rebuilt out of the document', async () => { + const video = appendVideo(); + await start(bothOn); + + setVisibility('hidden'); + video.remove(); + setVisibility('visible'); + + // We hold the element, so it still exists and still answers `getMode`; the user is looking + // at whatever the page put in its place. + expect(controller.setModeCalls).toEqual([{ video, mode: PresentationMode.PIP }]); + }); + + it('restores once, not on every later return', async () => { + const video = appendVideo(); + await start(bothOn); + + setVisibility('hidden'); + setVisibility('visible'); + setVisibility('visible'); + + expect(controller.setModeCalls).toEqual([ + { video, mode: PresentationMode.PIP }, + { video, mode: PresentationMode.INLINE }, + ]); + }); + + it('floats and restores again on the next round trip', async () => { + const video = appendVideo(); + await start(bothOn); + + setVisibility('hidden'); + setVisibility('visible'); + setVisibility('hidden'); + setVisibility('visible'); + + expect(controller.setModeCalls).toEqual([ + { video, mode: PresentationMode.PIP }, + { video, mode: PresentationMode.INLINE }, + { video, mode: PresentationMode.PIP }, + { video, mode: PresentationMode.INLINE }, + ]); + }); + + it('ignores a prerendered document, which is neither hidden nor visible', async () => { + const video = appendVideo(); + await start(bothOn); + + setVisibility('hidden'); + setVisibility('prerender' as DocumentVisibilityState); + + expect(controller.setModeCalls).toEqual([{ video, mode: PresentationMode.PIP }]); + }); + + it('reads the setting at the moment of return, which is how it is switched on', async () => { + const video = appendVideo(); + const started = await start({ autoPipOnTabHide: true, autoRestoreOnTabReturn: false }); + + setVisibility('hidden'); + await saveSettings({ autoRestoreOnTabReturn: true }); + await macrotask(); + expect(started.restoreEnabled).toBe(true); + + setVisibility('visible'); + + // Not a corner case but *the* first-use path: turning this on means leaving the video's tab + // for the options page, which is the tab switch that floats it. The live setting is what + // the user is looking at; a value latched when the tab hid would answer their first try + // with nothing happening. + expect(controller.setModeCalls).toEqual([ + { video, mode: PresentationMode.PIP }, + { video, mode: PresentationMode.INLINE }, + ]); + }); + + it('turns on without a page reload (RRR §3)', async () => { + const video = appendVideo(); + const started = await start({ autoPipOnTabHide: true, autoRestoreOnTabReturn: false }); + + await saveSettings({ autoRestoreOnTabReturn: true }); + await macrotask(); + expect(started.restoreEnabled).toBe(true); + + setVisibility('hidden'); + setVisibility('visible'); + + expect(controller.modeOf(video)).toBe(PresentationMode.INLINE); + }); + + it('turns off without a page reload', async () => { + const video = appendVideo(); + const started = await start(bothOn); + + await saveSettings({ autoRestoreOnTabReturn: false }); + await macrotask(); + expect(started.restoreEnabled).toBe(false); + + setVisibility('hidden'); + setVisibility('visible'); + + expect(controller.modeOf(video)).toBe(PresentationMode.PIP); + }); + + it('starts at the RRR §3 default, which is safe because its parent does not', async () => { + installFakeBrowser({ items: { [SETTINGS_KEY]: { autoRestoreOnTabReturn: false } } }); + const started = enableAutoPip({ controller }); + binding = started; + appendVideo(); + + // Unlike `enabled`, this one starts at the default rather than at `false` — and the window + // where it disagrees with storage cannot act, because floating a video at all needs + // `enabled`, which is still false. Both halves are asserted, since the first alone would + // pass against a binding that floats videos before it has been told to. + expect(started.enabled).toBe(false); + expect(started.restoreEnabled).toBe(DEFAULTS.autoRestoreOnTabReturn); + + setVisibility('hidden'); + setVisibility('visible'); + expect(controller.setModeCalls).toEqual([]); + + await started.ready; + expect(started.restoreEnabled).toBe(false); + }); + + it('drops its mode subscription on disable', async () => { + const video = appendVideo(); + const started = await start(bothOn); + + setVisibility('hidden'); + // Two: `togglePiP`'s own watch, and this binding's claim. + expect(controller.listenerCount(video)).toBe(2); + + started.disable(); + binding = null; + + expect(controller.listenerCount(video)).toBe(1); + }); +}); + /** * The composition root, end to end. Last in the file and imported once (DECISIONS 90): the * import attaches listeners to this file's single jsdom document and never detaches them. @@ -503,8 +746,10 @@ describe('enableAutoPip', () => { * the owner found the toggle in (DECISIONS 163). */ describe('the composition root wires auto-PiP', () => { - it('floats the playing video when the tab hides', async () => { - installFakeBrowser({ items: { [SETTINGS_KEY]: { autoPipOnTabHide: true } } }); + it('floats the playing video when the tab hides, and puts it back when it returns', async () => { + installFakeBrowser({ + items: { [SETTINGS_KEY]: { autoPipOnTabHide: true, autoRestoreOnTabReturn: true } }, + }); const { setArgs } = appendStubbedVideo({ paused: false, mode: PresentationMode.INLINE }); @@ -514,7 +759,11 @@ describe('the composition root wires auto-PiP', () => { await macrotask(); setVisibility('hidden'); + setVisibility('visible'); - expect(setArgs).toEqual([PresentationMode.PIP]); + // Both halves through the shipped wiring, because a feature that is correct and unreachable + // is the state the owner found the last toggle in (DECISIONS 163). The import happens once + // per file, so this is the only place the return half can be seen end to end. + expect(setArgs).toEqual([PresentationMode.PIP, PresentationMode.INLINE]); }); }); diff --git a/PiPOSS Extension/Resources/test/options.test.ts b/PiPOSS Extension/Resources/test/options.test.ts index b11627e..bcb335c 100644 --- a/PiPOSS Extension/Resources/test/options.test.ts +++ b/PiPOSS Extension/Resources/test/options.test.ts @@ -97,6 +97,7 @@ const hotkeyStatus = (): HTMLElement => control('hotkey-status'); const hotkeyReset = (): HTMLButtonElement => control('hotkey-reset'); const hotkeyDisable = (): HTMLButtonElement => control('hotkey-disable'); const autoPipToggle = (): HTMLInputElement => control('toggle-autopip'); +const autoRestoreToggle = (): HTMLInputElement => control('toggle-auto-restore'); const youtubeToggle = (): HTMLInputElement => control('toggle-youtube-button'); const saveError = (): HTMLElement => control('save-error'); @@ -188,6 +189,7 @@ describe('options.html — the file Safari actually loads', () => { 'hotkey-disable', 'command-note', 'toggle-autopip', + 'toggle-auto-restore', 'toggle-youtube-button', 'save-error', ]) { @@ -217,8 +219,14 @@ describe('options.html — the file Safari actually loads', () => { expect(hotkeyField().value.toLowerCase()).toBe(DEFAULTS.hotkey); expect(autoPipToggle().checked).toBe(DEFAULTS.autoPipOnTabHide); + expect(autoRestoreToggle().checked).toBe(DEFAULTS.autoRestoreOnTabReturn); expect(youtubeToggle().checked).toBe(DEFAULTS.youtubeButton); + // The return toggle's default is a *pair*: on, and greyed out because its parent is off. + // Written into the markup as well as into `render`, because this is the state of the page + // for the moment before the bundle runs — and the state it keeps if the bundle is missing. + expect(autoRestoreToggle().disabled).toBe(true); + // And nothing above wrote anything: no fake is installed, so a write would throw. await settle(1); }); @@ -746,9 +754,14 @@ describe('Hotkey — off, and back on', () => { describe('Toggles', () => { it('reflects what is stored rather than the markup’s defaults', async () => { - await openStored({ autoPipOnTabHide: true, youtubeButton: false }); + await openStored({ + autoPipOnTabHide: true, + autoRestoreOnTabReturn: false, + youtubeButton: false, + }); expect(autoPipToggle().checked).toBe(true); + expect(autoRestoreToggle().checked).toBe(false); expect(youtubeToggle().checked).toBe(false); }); @@ -769,6 +782,72 @@ describe('Toggles', () => { await expect(stored()).resolves.toMatchObject({ autoPipOnTabHide: false }); }); + it('round-trips autoRestoreOnTabReturn, which is on by default', async () => { + // Stored with the parent on, because that is the only state in which this control is + // enabled — see the two tests below. + await openStored({ autoPipOnTabHide: true }); + expect(autoRestoreToggle().checked).toBe(true); + + autoRestoreToggle().checked = false; + autoRestoreToggle().dispatchEvent(new Event('change', { bubbles: true })); + await settle(); + + await expect(stored()).resolves.toMatchObject({ autoRestoreOnTabReturn: false }); + + autoRestoreToggle().checked = true; + autoRestoreToggle().dispatchEvent(new Event('change', { bubbles: true })); + await settle(); + + await expect(stored()).resolves.toMatchObject({ autoRestoreOnTabReturn: true }); + }); + + it('enables and disables the return toggle as its parent is switched', async () => { + await openPage(); + + autoPipToggle().checked = true; + autoPipToggle().dispatchEvent(new Event('change', { bubbles: true })); + await settle(); + + expect(autoRestoreToggle().disabled).toBe(false); + + autoPipToggle().checked = false; + autoPipToggle().dispatchEvent(new Event('change', { bubbles: true })); + await settle(); + + expect(autoRestoreToggle().disabled).toBe(true); + // And the stored value survived the round trip untouched. + await expect(stored()).resolves.toMatchObject({ autoRestoreOnTabReturn: true }); + }); + + it('follows another context’s change of the parent, not only its own click', async () => { + const { fake } = await openStored({ autoPipOnTabHide: true }); + expect(autoRestoreToggle().disabled).toBe(false); + + fake.storage.onChanged.emit({ + [SETTINGS_KEY]: { newValue: { autoPipOnTabHide: false } }, + }); + await settle(); + + expect(autoRestoreToggle().disabled).toBe(true); + }); + + it('leaves autoRestoreOnTabReturn stored when its parent toggle goes off', async () => { + // The two are one feature to the user and two answers here: clearing this behind their + // back would lose it every time they turned auto-PiP off for an afternoon. Nothing is ever + // remembered while the parent is off, so the stored `true` is inert until it means + // something again — and the control is greyed out rather than rewritten. + await openStored({ autoPipOnTabHide: true, autoRestoreOnTabReturn: true }); + + autoPipToggle().checked = false; + autoPipToggle().dispatchEvent(new Event('change', { bubbles: true })); + await settle(); + + await expect(stored()).resolves.toMatchObject({ + autoPipOnTabHide: false, + autoRestoreOnTabReturn: true, + }); + }); + it('round-trips youtubeButton, which is on by default', async () => { await openPage(); expect(youtubeToggle().checked).toBe(true); @@ -786,7 +865,7 @@ describe('Toggles', () => { await expect(stored()).resolves.toMatchObject({ youtubeButton: true }); }); - it('writes only the field that changed, so the other two are never overwritten', async () => { + it('writes only the field that changed, so the others are never overwritten', async () => { const { fake } = await openStored({ hotkey: 'q', youtubeButton: false }); autoPipToggle().checked = true; @@ -796,6 +875,7 @@ describe('Toggles', () => { await expect(stored()).resolves.toEqual({ hotkey: 'q', autoPipOnTabHide: true, + autoRestoreOnTabReturn: true, youtubeButton: false, }); expect(fake.storage.local.setCalls).toHaveLength(1); diff --git a/PiPOSS Extension/Resources/test/settings.test.ts b/PiPOSS Extension/Resources/test/settings.test.ts index 8550513..90a7019 100644 --- a/PiPOSS Extension/Resources/test/settings.test.ts +++ b/PiPOSS Extension/Resources/test/settings.test.ts @@ -26,7 +26,12 @@ afterEach(() => { describe('DEFAULTS', () => { it('is exactly the table in RRR §3', () => { - expect(DEFAULTS).toEqual({ hotkey: 'p', autoPipOnTabHide: false, youtubeButton: true }); + expect(DEFAULTS).toEqual({ + hotkey: 'p', + autoPipOnTabHide: false, + autoRestoreOnTabReturn: true, + youtubeButton: true, + }); }); it('is frozen, so no consumer can mutate the fallback everyone else reads', () => { @@ -64,15 +69,22 @@ describe('loadSettings', () => { await expect(loadSettings()).resolves.toEqual({ hotkey: 'k', autoPipOnTabHide: false, + autoRestoreOnTabReturn: true, youtubeButton: true, }); }); it('returns a fully written object unchanged', async () => { - withStored({ hotkey: 'q', autoPipOnTabHide: true, youtubeButton: false }); + withStored({ + hotkey: 'q', + autoPipOnTabHide: true, + autoRestoreOnTabReturn: true, + youtubeButton: false, + }); await expect(loadSettings()).resolves.toEqual({ hotkey: 'q', autoPipOnTabHide: true, + autoRestoreOnTabReturn: true, youtubeButton: false, }); }); @@ -82,6 +94,17 @@ describe('loadSettings', () => { await expect(loadSettings()).resolves.toMatchObject({ hotkey: '' }); }); + it('keeps a stored autoRestoreOnTabReturn, and repairs a non-boolean to RRR §3’s true', async () => { + withStored({ autoRestoreOnTabReturn: false }); + await expect(loadSettings()).resolves.toMatchObject({ autoRestoreOnTabReturn: false }); + + // Not merely tidiness: a stored string is not an answer, and the field decides whether the + // user's floating window is taken away for them. Repaired to the default, in the one + // direction RRR §3 names. + withStored({ autoRestoreOnTabReturn: 'no' }); + await expect(loadSettings()).resolves.toMatchObject({ autoRestoreOnTabReturn: true }); + }); + it('keeps a false youtubeButton rather than falling back to the true default', async () => { withStored({ youtubeButton: false }); await expect(loadSettings()).resolves.toMatchObject({ youtubeButton: false }); @@ -103,6 +126,7 @@ describe('loadSettings', () => { await expect(loadSettings()).resolves.toEqual({ hotkey: 'k', autoPipOnTabHide: false, + autoRestoreOnTabReturn: true, youtubeButton: true, }); }); @@ -124,10 +148,16 @@ describe('loadSettings', () => { }); it('keeps the healthy siblings of a corrupt field', async () => { - withStored({ hotkey: null, autoPipOnTabHide: true, youtubeButton: false }); + withStored({ + hotkey: null, + autoPipOnTabHide: true, + autoRestoreOnTabReturn: true, + youtubeButton: false, + }); await expect(loadSettings()).resolves.toEqual({ hotkey: 'p', autoPipOnTabHide: true, + autoRestoreOnTabReturn: true, youtubeButton: false, }); }); @@ -196,7 +226,14 @@ describe('saveSettings', () => { await saveSettings({ hotkey: 'k' }); expect(fake.storage.local.setCalls).toEqual([ - { [SETTINGS_KEY]: { hotkey: 'k', autoPipOnTabHide: false, youtubeButton: true } }, + { + [SETTINGS_KEY]: { + hotkey: 'k', + autoPipOnTabHide: false, + autoRestoreOnTabReturn: true, + youtubeButton: true, + }, + }, ]); }); @@ -208,6 +245,7 @@ describe('saveSettings', () => { expect(fake.storage.local.snapshot()[SETTINGS_KEY]).toEqual({ hotkey: 'k', autoPipOnTabHide: true, + autoRestoreOnTabReturn: true, youtubeButton: false, }); }); @@ -217,6 +255,7 @@ describe('saveSettings', () => { await expect(saveSettings({ hotkey: 'k' })).resolves.toEqual({ hotkey: 'k', autoPipOnTabHide: true, + autoRestoreOnTabReturn: true, youtubeButton: true, }); }); @@ -225,6 +264,7 @@ describe('saveSettings', () => { const fake = withStored({ hotkey: 'k', autoPipOnTabHide: true, + autoRestoreOnTabReturn: true, youtubeButton: true, futureFeature: 'on', futureObject: { nested: [1, 2, 3] }, @@ -235,6 +275,7 @@ describe('saveSettings', () => { expect(fake.storage.local.snapshot()[SETTINGS_KEY]).toEqual({ hotkey: 'm', autoPipOnTabHide: true, + autoRestoreOnTabReturn: true, youtubeButton: true, futureFeature: 'on', futureObject: { nested: [1, 2, 3] }, @@ -251,6 +292,7 @@ describe('saveSettings', () => { expect(fake.storage.local.snapshot()[SETTINGS_KEY]).toEqual({ hotkey: 'k', autoPipOnTabHide: true, + autoRestoreOnTabReturn: true, youtubeButton: false, futureFeature: 'on', }); @@ -270,6 +312,7 @@ describe('saveSettings', () => { expect(fake.storage.local.snapshot()[SETTINGS_KEY]).toEqual({ hotkey: 'p', autoPipOnTabHide: true, + autoRestoreOnTabReturn: true, youtubeButton: true, }); }); @@ -280,12 +323,18 @@ describe('saveSettings', () => { await expect(saveSettings({ hotkey: 'm' })).resolves.toEqual({ hotkey: 'm', autoPipOnTabHide: false, + autoRestoreOnTabReturn: true, youtubeButton: true, }); const stored = fake.storage.local.snapshot()[SETTINGS_KEY]; expect(Array.isArray(stored)).toBe(false); - expect(stored).toEqual({ hotkey: 'm', autoPipOnTabHide: false, youtubeButton: true }); + expect(stored).toEqual({ + hotkey: 'm', + autoPipOnTabHide: false, + autoRestoreOnTabReturn: true, + youtubeButton: true, + }); }); it('treats an explicit undefined in the partial as "leave it alone"', async () => { @@ -295,6 +344,7 @@ describe('saveSettings', () => { expect(fake.storage.local.snapshot()[SETTINGS_KEY]).toMatchObject({ hotkey: 'k', + autoRestoreOnTabReturn: true, youtubeButton: false, }); }); @@ -327,6 +377,7 @@ describe('saveSettings', () => { expect(fake.storage.local.snapshot()[SETTINGS_KEY]).toEqual({ hotkey: 'm', autoPipOnTabHide: false, + autoRestoreOnTabReturn: true, youtubeButton: false, }); }); @@ -348,8 +399,18 @@ describe('saveSettings', () => { saveSettings({ youtubeButton: false }), ]); - expect(results[0]).toEqual({ hotkey: 'm', autoPipOnTabHide: false, youtubeButton: true }); - expect(results[1]).toEqual({ hotkey: 'm', autoPipOnTabHide: false, youtubeButton: false }); + expect(results[0]).toEqual({ + hotkey: 'm', + autoPipOnTabHide: false, + autoRestoreOnTabReturn: true, + youtubeButton: true, + }); + expect(results[1]).toEqual({ + hotkey: 'm', + autoPipOnTabHide: false, + autoRestoreOnTabReturn: true, + youtubeButton: false, + }); }); it('preserves unknown fields when three writes overlap', async () => { @@ -364,6 +425,7 @@ describe('saveSettings', () => { expect(fake.storage.local.snapshot()[SETTINGS_KEY]).toEqual({ hotkey: 'm', autoPipOnTabHide: true, + autoRestoreOnTabReturn: true, youtubeButton: false, futureFeature: 'on', }); @@ -419,6 +481,7 @@ describe('saveSettings', () => { expect(fake.storage.local.snapshot()[SETTINGS_KEY]).toEqual({ hotkey: 'q', autoPipOnTabHide: false, + autoRestoreOnTabReturn: true, youtubeButton: true, futureFeature: 'on', }); @@ -484,7 +547,9 @@ describe('onSettingsChanged', () => { await saveSettings({ hotkey: 'k' }); - expect(seen).toEqual([{ hotkey: 'k', autoPipOnTabHide: true, youtubeButton: true }]); + expect(seen).toEqual([ + { hotkey: 'k', autoPipOnTabHide: true, autoRestoreOnTabReturn: true, youtubeButton: true }, + ]); }); it('merges a partially written new value over the defaults', () => { @@ -494,7 +559,9 @@ describe('onSettingsChanged', () => { fake.storage.onChanged.emit({ [SETTINGS_KEY]: { newValue: { hotkey: 'k' } } }); - expect(seen).toEqual([{ hotkey: 'k', autoPipOnTabHide: false, youtubeButton: true }]); + expect(seen).toEqual([ + { hotkey: 'k', autoPipOnTabHide: false, autoRestoreOnTabReturn: true, youtubeButton: true }, + ]); }); it('reports the defaults when the settings key is removed', async () => { @@ -536,7 +603,9 @@ describe('onSettingsChanged', () => { fake.storage.onChanged.emit({ [SETTINGS_KEY]: { newValue: { hotkey: 'k' } } }, 'sync'); - expect(seen).toEqual([{ hotkey: 'k', autoPipOnTabHide: false, youtubeButton: true }]); + expect(seen).toEqual([ + { hotkey: 'k', autoPipOnTabHide: false, autoRestoreOnTabReturn: true, youtubeButton: true }, + ]); }); it('propagates every subsequent change, so no reload is needed (RRR §3)', async () => { @@ -647,6 +716,7 @@ describe('the shared browser fake', () => { expect(fake.storage.local.snapshot()[SETTINGS_KEY]).toEqual({ hotkey: 'm', autoPipOnTabHide: false, + autoRestoreOnTabReturn: true, youtubeButton: true, futureObject: { nested: [1, 2, 3] }, }); diff --git a/README.md b/README.md index d228d8d..8a60c60 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,13 @@ reopen it. Besides the hotkey it has: videos and clips of a few seconds are deliberately left alone, so that a page's silent background loop cannot hijack the floating window. It keeps floating when you come back, in case that is where you wanted it. +- **Put it back when you return to the tab** — on by default, and greyed out until + you switch the one above on, because it cannot do anything on its own. It undoes + exactly what that switch did: a window PiPOSS floated out by itself, and only if + you left it floating the whole time. Open or reopen the floating window yourself + and it stays — that one is yours. The video goes back to the presentation it came + from, though a player that was fullscreen usually comes back inline: only Safari + can put a page back into fullscreen, and only right after you press something. - **The YouTube button** — on by default. ## Development diff --git a/RRR.md b/RRR.md index 77dc817..1c9ebc4 100644 --- a/RRR.md +++ b/RRR.md @@ -67,6 +67,7 @@ One object in `browser.storage.local` under key `settings`: |---|---|---|---| | `hotkey` | `string` | `"p"` | Single printable key that toggles PiP. Stored lowercase. Empty string disables the hotkey. | | `autoPipOnTabHide` | `boolean` | `false` | Enter PiP automatically when the tab stops being visible while a video plays. | +| `autoRestoreOnTabReturn` | `boolean` | `true` | Leave PiP again when the tab becomes visible, for a video this extension floated and that has stayed floating. Inert while `autoPipOnTabHide` is off, which is what lets it default to on. | | `youtubeButton` | `boolean` | `true` | Show the PiP button in the YouTube player controls. | Hard rules that bite late: @@ -158,7 +159,9 @@ limit, not a bug — do not spend tasks trying to defeat it. 2. **Hotkey** — a single-key capture field, default `P`, with a "reset to default" affordance and a note pointing to Safari's own settings for the `⌘⇧P` command. -3. **Toggles** — `autoPipOnTabHide` (off by default), `youtubeButton` (on). +3. **Toggles** — `autoPipOnTabHide` (off by default), `autoRestoreOnTabReturn` (on, and + **disabled** in the UI while `autoPipOnTabHide` is off — it can do nothing then, and a live + control that does nothing is what §4.3 refuses elsewhere), `youtubeButton` (on). Native look: system font, `color-scheme: light dark`, no third-party CSS. Every control needs a stable `data-testid`. @@ -198,11 +201,26 @@ player with `closest('.html5-video-player')`, not `parentElement.parentElement`. ### 4.6 Auto-PiP on tab hide When `autoPipOnTabHide` is on and `document.visibilityState` becomes `hidden` -while a video is playing, put that video into PiP. Do not restore -automatically on return — the user may have deliberately kept it floating. -Never fire for muted or paused videos, and never for videos shorter than 5 -seconds (those are decorative loops, and flinging them into PiP is the failure -mode that would make people uninstall). Off by default. +while a video is playing, put that video into PiP. Never fire for muted or paused +videos, and never for videos shorter than 5 seconds (those are decorative loops, +and flinging them into PiP is the failure mode that would make people uninstall). +Off by default. + +Restoring on return is the separate `autoRestoreOnTabReturn`. It is **on by +default and its parent is not**, which is only coherent because nothing is ever +remembered while `autoPipOnTabHide` is off: the field says what auto-PiP *means* +for whoever switches it on without reading further, and can do nothing until they +do. The objection to restoring unconditionally still stands — the user may have +deliberately kept the window floating — and is answered by the two conditions +below rather than by the default. Two conditions keep the setting inside its +promise, and neither may be dropped — the video must be one **this extension +floated on the last tab hide**, and it must have been in PiP **continuously +since**, judged by the browser's own presentation reports rather than by the mode +read on return. A window the user opened, or closed and reopened by hand, is +theirs. Restoring means the same `togglePiP` a hotkey press performs, so the +video returns to the presentation it came from; the document-fullscreen half of +that will usually be refused for want of a user gesture, and that is accepted — +inline is the honest fallback, and the settings page says so. ### 4.7 Container app