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