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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ EQ with saveable presets.
draws a chart under the timeline.

**Keeping your place.** Settings are stored per track against a normalized URL,
so reopening a video brings back its markers, loops and snippets. Favorites and
so reopening a video brings back its pitch, speed, markers, loops and snippets —
however you open it, not just from the library. Favorites and
recents live in a library tab, and optional cross-device sync pushes a snapshot
to a small Cloudflare Worker.

Expand Down
16 changes: 16 additions & 0 deletions src/core/engine/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,15 @@ export class Controller {
}

#attach(el: HTMLMediaElement) {
// Carry the live settings across the swap. Sites replace the element mid-page
// (YouTube does it for pre-rolls and quality changes), and a fresh engine on
// DEFAULT_PARAMS would silently reset the user's pitch/speed — and broadcast
// that reset over whatever the panel had just applied. Whether a *new track*
// should start clean is the panel's call (auto reset / remember / carry over),
// not something an element swap gets to decide.
const carried = this.engine
? { params: structuredClone(this.engine.params), volume: this.engine.volume }
: null;
this.#detach();
const engine = new MediaEngine(el, {
onTime: (t, playing) => {
Expand All @@ -225,6 +234,13 @@ export class Controller {
this.broadcast({ type: 'params', params: structuredClone(params) }),
onVolume: (volume) => this.broadcast({ type: 'volume', volume }),
});
if (carried) {
engine.volume = carried.volume;
// Through patchParams, not a field write: it syncs the element's rate and
// preservesPitch flags to the carried values (the chain picks them up in
// #attachChain) and echoes them, so the panel mirror can't drift.
engine.patchParams(carried.params);
}
this.engine = engine;
this.metronome = new Metronome();
this.countIn = new CountIn(
Expand Down
8 changes: 8 additions & 0 deletions src/core/model/track-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ function hash(text: string): string {
return (h >>> 0).toString(16);
}

/** Whether two library rows describe the same song. Deliberately not a `key`
* comparison: the duration baked into `key` drifts (pre-roll ads, metadata that
* settles late), which would split one song across several Recent rows. The
* title is what keeps local files apart — they all share the local-player URL. */
export function isSameTrack(a: TrackIdentity, b: TrackIdentity): boolean {
return a.normalizedUrl === b.normalizedUrl && a.title === b.title;
}

export function makeTrackIdentity(
pageUrl: string,
title: string,
Expand Down
53 changes: 45 additions & 8 deletions src/core/persist/storage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { storage } from '#imports';
import { storage, type StorageItemKey, type WxtStorageItem } from '#imports';
import { DEFAULT_SETTINGS, DEFAULT_UI_PREFS } from '../model/defaults';
import type {
EqPreset,
Expand All @@ -9,33 +9,70 @@ import type {
UiPrefs,
} from '../model/types';

export const settingsItem = storage.defineItem<Settings>('local:settings', {
/** Rebuild a value as plain arrays/objects, stripping any Svelte `$state`
* proxies on the way.
*
* Chrome serializes storage writes to JSON and reads straight through a proxy;
* Firefox structured-clones them and throws DataCloneError, rejecting the write
* with nothing persisted. Panel stores are expected to `$state.snapshot` before
* writing, but one missed call is an invisible, browser-specific data-loss bug —
* so every write goes through here as well.
*
* A rebuild, not `structuredClone`: that throws on a proxy, which is the very
* case being defended against. Safe because this schema is JSON-shaped
* throughout (numbers, strings, booleans, arrays, plain objects); a Date, Map or
* typed array added later would need handling here first.
*
* Plain function, not the `$state.snapshot` rune: background.ts imports this
* module and runes only compile inside Svelte files. */
function toPlain<T>(value: T): T {
if (Array.isArray(value)) return value.map(toPlain) as T;
if (value === null || typeof value !== 'object') return value;
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) out[k] = toPlain(v);
return out as T;
}

/** `storage.defineItem` with proxy-stripping on every write. Annotated rather
* than inferred: `storage.defineItem` is overloaded five ways, so deriving this
* signature from it makes the inference circular. */
function defineItem<T>(
key: StorageItemKey,
options: { fallback: T },
): WxtStorageItem<T, Record<string, unknown>> {
const item = storage.defineItem<T>(key, options);
const setValue = item.setValue.bind(item);
item.setValue = (value: T) => setValue(toPlain(value));
return item;
}

export const settingsItem = defineItem<Settings>('local:settings', {
fallback: DEFAULT_SETTINGS,
});

export const uiPrefsItem = storage.defineItem<UiPrefs>('local:uiPrefs', {
export const uiPrefsItem = defineItem<UiPrefs>('local:uiPrefs', {
fallback: DEFAULT_UI_PREFS,
});

/** Recent history (Auto Save), newest first. */
export const historyItem = storage.defineItem<HistoryEntry[]>('local:history', {
export const historyItem = defineItem<HistoryEntry[]>('local:history', {
fallback: [],
});

/** Starred songs (History → Favorites). Array order = manual sort order. */
export const favoritesItem = storage.defineItem<FavoriteEntry[]>('local:favorites', {
export const favoritesItem = defineItem<FavoriteEntry[]>('local:favorites', {
fallback: [],
});

/** EQ curves the user saved (Equalizer → preset row). Array order = save order.
* Kept out of `settings` so Reset Settings can't wipe them. */
export const eqPresetsItem = storage.defineItem<EqPreset[]>('local:eqPresets', {
export const eqPresetsItem = defineItem<EqPreset[]>('local:eqPresets', {
fallback: [],
});

/** Origins the user has granted host permission for (mirrors permissions API,
* used to show/revoke the list without a permissions query round-trip). */
export const grantedOriginsItem = storage.defineItem<string[]>('local:grantedOrigins', {
export const grantedOriginsItem = defineItem<string[]>('local:grantedOrigins', {
fallback: [],
});

Expand All @@ -49,7 +86,7 @@ export async function loadTrackData(key: string): Promise<TrackData | null> {
}

export async function saveTrackData(data: TrackData): Promise<void> {
await storage.setItem(trackDataKey(data.identity.key), data);
await storage.setItem(trackDataKey(data.identity.key), toPlain(data));
}

export async function removeAllTrackData(): Promise<void> {
Expand Down
4 changes: 4 additions & 0 deletions src/core/state/session.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ class SessionStore {
/** Persistence hooks (set by track-sync). */
onMediaEvent: ((media: MediaInfo | null) => void) | null = null;
onUserParamsChange: (() => void) | null = null;
/** The engine link dropped. Whatever reconnects starts on the default preset,
* so the track's saved settings have to go back on even if it never changed. */
onEngineDetached: (() => void) | null = null;
/** Routes params/volume to the offscreen pipeline while capturing. */
captureRelay: {
params(patch: Partial<EffectParams>): void;
Expand All @@ -87,6 +90,7 @@ class SessionStore {
this.bpmNoResult = false;
this.#dspBlocked = false;
clearTimeout(this.#bpmHintTimer);
this.onEngineDetached?.();
}

get connected(): boolean {
Expand Down
Loading
Loading