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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ The tree is organized by **feature**, not by layer:
- **`sidepanel/`** — the Svelte UI. Holds no engine state of its own; mirrors the active tab's engine.
- **`content.ts` → `src/core/engine/`** — the engine. Media detection, connection state machine, transport, and the Web Audio DSP chain; the loop/sequence/count-in schedulers it drives live in `src/features/*/engine/`. Guards against double-boot via `window.__noteByNote`; tears down on `ctx.onInvalidated` (extension reload) to avoid orphaned instances fighting the page.
- **`offscreen/`** — hosts the tab-capture DSP pipeline (one `AudioContext` per captured tab). Same shared pipeline as direct mode.
- **`background.ts`** — service worker broker: per-origin permission grants, content-script registration/injection, tab-capture stream brokering, offscreen document lifecycle. Reconciles the persistent content-script registration from the actual `permissions` API (single source of truth) on every permission change or worker restart.
- **`background.ts`** — service worker broker: per-origin permission grants, content-script registration/injection, tab-capture stream brokering, offscreen document lifecycle. Reconciles the persistent content-script registration from the actual `permissions` API (single source of truth) on every permission change or worker restart. Also owns tab-scoped panel visibility (Chromium): the side panel is disabled by default and the icon click toggles it per tab, so Chrome hides it on other tabs and re-shows it on return. Chrome gives each tab its own panel document, opened at `sidepanel.html?tabId=N` so the panel pins itself to that tab and the worker can recognise the document in `runtime.getContexts` (which reports `tabId: -1` for side panels). Measured gotchas, all documented in [core/side-panel.ts](src/core/side-panel.ts): the toolbar click's gesture dies at the first `await` in the worker, so the toggle snapshots "is it showing" via `getContexts`, fires `setOptions` + `open` synchronously (a no-op when already open), and disables the tab afterwards if the snapshot said it was showing; enabling a tab does not show the panel there, so panel-initiated new tabs (local player, Songs list) go through `openTabWithPanel` **from the panel document** (whose click activation survives awaits), not via the background. Firefox's sidebar is window-global — no per-tab behavior.
- **`local-player/`** — a full extension page that plays local files and speaks the same engine protocol via its own tab.

### Three connection modes (the fallback chain)
Expand Down
55 changes: 0 additions & 55 deletions promo/reddit/01-main-guitarlessons.md

This file was deleted.

35 changes: 0 additions & 35 deletions promo/reddit/03-chrome-extensions.md

This file was deleted.

38 changes: 0 additions & 38 deletions promo/reddit/04-instrument-subs.md

This file was deleted.

1 change: 0 additions & 1 deletion src/core/messaging/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,5 +131,4 @@ export interface ProtocolMap {
captureVolume(data: { tabId: number; volume: number }): Promise<void>;
/** Revoke Permissions. */
revokeAllPermissions(): Promise<void>;
openLocalPlayer(): Promise<{ tabId: number }>;
}
101 changes: 101 additions & 0 deletions src/core/side-panel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { HAS_SIDE_PANEL_API } from '@/core/platform';

/** Tab-scoped side panel (Chromium).
*
* The manifest's `side_panel.default_path` enables the panel on every tab, so
* once opened it followed the user to every tab they switched to. Instead the
* background disables the panel by default and it is enabled per tab, only
* where the user summoned it — Chrome then hides the panel while a tab without
* it is active and brings it back when the user returns to one that has it.
* Per-tab options die with their tab, so nothing accumulates.
*
* Facts that shape the call sites (all measured against Chrome 151):
* - `sidePanel.open()` needs a user gesture, and the gesture of a toolbar
* click in the service worker dies at the first `await` — the background
* must call it synchronously from `action.onClicked`. A click inside an
* extension *page* instead grants transient activation that survives awaits.
* - Enabling the panel on a tab does not show it there: a freshly enabled tab
* that becomes active still needs an explicit `open()`.
* - Every tab gets its own panel document, which lives while the panel is
* open for that tab (it survives being hidden behind another tab) and dies
* when the panel is closed there — by the ✕, by `sidePanel.close()`, or by
* disabling the tab. Disabling is the close primitive used here: unlike
* `sidePanel.close()` it exists on every Chrome we support, and it tears
* the document down at once (~4 ms), so an immediate re-click reopens.
* After the ✕ the document lingers ~360 ms — too short for a hand to reach
* the toolbar, so the icon-click toggle below can treat "document exists"
* as "showing".
* - `runtime.getContexts` lists those documents but reports `tabId: -1` /
* `windowId: -1` for all of them, so the tab is encoded in the path
* instead (`?tabId=`) and read back from `documentUrl`. The panel honors
* that parameter by pinning itself to the tab — the right thing for a
* per-tab document, which must not follow tab activation while hidden.
*
* Firefox has no per-tab sidebar (`sidebar_action` is window-global), so these
* helpers reduce to plain tab handling there.
*/

/** The built sidepanel entrypoint — must match the `side_panel.default_path`
* WXT emits into the manifest. */
const PANEL_PATH = 'sidepanel.html';

/** The per-tab panel URL: the tab is carried in the query so the panel pins
* itself to it and `isPanelShowing` can recognise its document. */
function panelPath(tabId: number): string {
return `${PANEL_PATH}?tabId=${tabId}`;
}

function tabOfPanelDocument(documentUrl: string | undefined): number | null {
if (!documentUrl) return null;
const raw = new URL(documentUrl).searchParams.get('tabId');
return raw == null ? null : Number(raw);
}

/** Enable the panel on one tab. No-op on Firefox. Returns the API promise
* without awaiting anything first, so a caller inside a user-gesture callback
* can fire this and `sidePanel.open()` back to back. */
export function enablePanelForTab(tabId: number): Promise<void> {
if (!HAS_SIDE_PANEL_API) return Promise.resolve();
return browser.sidePanel.setOptions({ tabId, path: panelPath(tabId), enabled: true });
}

/** Close the panel on one tab by disabling it there. No-op on Firefox. */
export function disablePanelForTab(tabId: number): Promise<void> {
if (!HAS_SIDE_PANEL_API) return Promise.resolve();
return browser.sidePanel.setOptions({ tabId, enabled: false });
}

/** Whether the panel is open for `tabId`: its document exists. For the
* active tab that means showing. The read is dispatched immediately, so a
* caller in a click handler can fire this ahead of the synchronous enable +
* open and still get the pre-click state — the browser handles extension API
* calls in dispatch order. */
export async function isPanelShowing(tabId: number): Promise<boolean> {
if (!HAS_SIDE_PANEL_API) return false;
const contexts = await browser.runtime.getContexts({
contextTypes: ['SIDE_PANEL' as Browser.runtime.ContextType],
});
return contexts.some((c) => tabOfPanelDocument(c.documentUrl) === tabId);
}

/** Open `url` in a new foreground tab that the side panel follows. For flows
* started from a click inside the open panel (local player, Songs list): a
* plain `tabs.create` would activate a tab the panel isn't enabled on and hide
* it. Must be called from the panel document itself, inside the click's
* transient activation — not routed through the background, where
* `sidePanel.open()` has no gesture to run on. */
export async function openTabWithPanel(url: string): Promise<Browser.tabs.Tab> {
if (!HAS_SIDE_PANEL_API) return browser.tabs.create({ url });
// The tab starts inactive: activating it before the panel is enabled and
// opened there would hide the panel — the very document running this code.
const tab = await browser.tabs.create({ url, active: false });
const tabId = tab.id!;
await enablePanelForTab(tabId);
// The activation below must run either way; without the open the panel is
// merely hidden on the new tab and the icon brings it back.
await browser.sidePanel
.open({ tabId })
.catch((err: unknown) => console.error('sidePanel open for new tab', err));
await browser.tabs.update(tabId, { active: true });
return tab;
}
22 changes: 11 additions & 11 deletions src/core/state/connect.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,22 @@ class ConnectionManager {
async init() {
settings.onChange = pushSettings;

// Test/debug override: pin the panel to one tab (?tabId=N).
// Chromium opens one panel document per tab at `sidepanel.html?tabId=N`
// (see core/side-panel.ts): pin to that tab. Hidden behind another tab
// this document stays alive, and following activation there would leave
// it mirroring the wrong tab when Chrome shows it again. The E2E harness
// uses the same parameter to pin a panel opened as a plain tab. Firefox's
// window-global sidebar has no such parameter and follows the active tab.
const pinned = new URLSearchParams(location.search).get('tabId');
if (pinned) {
await this.bindTab(Number(pinned));
browser.tabs.onUpdated.addListener((tabId, info) => {
if (tabId === this.tabId && info.status === 'complete') void this.bindTab(tabId);
} else {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
if (tab?.id != null) await this.bindTab(tab.id);
browser.tabs.onActivated.addListener(({ tabId }) => {
void this.bindTab(tabId);
});
return;
}

const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
if (tab?.id != null) await this.bindTab(tab.id);

browser.tabs.onActivated.addListener(({ tabId }) => {
void this.bindTab(tabId);
});
browser.tabs.onUpdated.addListener((tabId, info) => {
if (tabId !== this.tabId) return;
// Navigation completed: the engine needs re-injection + reconnect.
Expand Down
5 changes: 4 additions & 1 deletion src/core/state/track-sync.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { touchFavorite } from '../../features/library/persist/favorites';
import { removeHistoryEntry, upsertHistory } from '../../features/library/persist/history';
import { findSavedEntry } from '../../features/library/panel/saved-settings';
import { loadTrackData, saveTrackData } from '../persist/storage';
import { openTabWithPanel } from '../side-panel';
import { trackDataDescriptors } from '../persist/track-data';
import { session } from './session.svelte';
import { settings } from '../../features/settings/panel/settings.svelte';
Expand Down Expand Up @@ -253,7 +254,9 @@ class TrackSync {
if (tabId != null) {
await browser.tabs.update(tabId, { url: entry.pageUrl });
} else {
await browser.tabs.create({ url: entry.pageUrl });
// No engine tab to reuse — a fresh one the panel follows (a plain create
// would activate a tab the panel isn't enabled on and hide it).
await openTabWithPanel(entry.pageUrl);
}
}
}
Expand Down
Loading
Loading