Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
60 changes: 60 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 32 additions & 1 deletion PiPOSS Extension/Resources/options.html
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,38 @@ <h2 id="behaviour-heading">Behaviour</h2>
<p class="note">
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.
</p>

<!--
`checked disabled` is the RRR §3 default *pair*, and the markup has to show both: the
switch is on, and the switch above it is off, so it can do nothing yet. Rendering it
enabled for the moment before `dist/options.js` runs would offer a control that has no
effect — the one thing the access section further up refuses to do.
-->
<p class="row">
<input
type="checkbox"
id="auto-restore"
data-testid="toggle-auto-restore"
checked
disabled
/>
<label for="auto-restore">Put it back when you return to the tab</label>
</p>
<!--
"floated out by itself" is the whole promise: only a window this extension opened on a
tab switch is closed again, and only if it has stayed open since. A floating window you
opened — or closed and reopened — is yours, which is the objection RRR §4.6 raises
against restoring on return, and what lets this one default to on.
The fullscreen sentence is not a hedge: `requestFullscreen` needs a keypress or a click,
and returning to a tab is neither, so Safari usually refuses it.
-->
<p class="note">
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.
</p>

<p class="row">
Expand Down
100 changes: 97 additions & 3 deletions PiPOSS Extension/Resources/src/core/autopip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void>;

Expand All @@ -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;
Expand All @@ -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
Expand All @@ -164,6 +253,7 @@ export function enableAutoPip({

const { ready, unsubscribe } = followSettings((settings: Settings) => {
enabled = settings.autoPipOnTabHide;
restoreEnabled = settings.autoRestoreOnTabReturn;
});

let attached = true;
Expand All @@ -172,13 +262,17 @@ export function enableAutoPip({
get enabled(): boolean {
return enabled;
},
get restoreEnabled(): boolean {
return restoreEnabled;
},
ready,
disable(): void {
if (!attached) return;
attached = false;

target.removeEventListener('visibilitychange', handleVisibilityChange);
unsubscribe();
forget();
},
};
}
11 changes: 11 additions & 0 deletions PiPOSS Extension/Resources/src/core/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -37,6 +46,7 @@ export const SETTINGS_KEY = 'settings';
export const DEFAULTS: Readonly<Settings> = Object.freeze({
hotkey: 'p',
autoPipOnTabHide: false,
autoRestoreOnTabReturn: true,
youtubeButton: true,
});

Expand Down Expand Up @@ -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),
};
}
Expand Down
Loading
Loading