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