diff --git a/README.md b/README.md index 5f6c349..2aea238 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/core/engine/controller.ts b/src/core/engine/controller.ts index e52aae3..aa6ac0a 100644 --- a/src/core/engine/controller.ts +++ b/src/core/engine/controller.ts @@ -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) => { @@ -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( diff --git a/src/core/model/track-identity.ts b/src/core/model/track-identity.ts index e65cc01..559df16 100644 --- a/src/core/model/track-identity.ts +++ b/src/core/model/track-identity.ts @@ -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, diff --git a/src/core/persist/storage.ts b/src/core/persist/storage.ts index 8bbd3c0..8557bfb 100644 --- a/src/core/persist/storage.ts +++ b/src/core/persist/storage.ts @@ -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, @@ -9,33 +9,70 @@ import type { UiPrefs, } from '../model/types'; -export const settingsItem = storage.defineItem('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(value: T): T { + if (Array.isArray(value)) return value.map(toPlain) as T; + if (value === null || typeof value !== 'object') return value; + const out: Record = {}; + 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( + key: StorageItemKey, + options: { fallback: T }, +): WxtStorageItem> { + const item = storage.defineItem(key, options); + const setValue = item.setValue.bind(item); + item.setValue = (value: T) => setValue(toPlain(value)); + return item; +} + +export const settingsItem = defineItem('local:settings', { fallback: DEFAULT_SETTINGS, }); -export const uiPrefsItem = storage.defineItem('local:uiPrefs', { +export const uiPrefsItem = defineItem('local:uiPrefs', { fallback: DEFAULT_UI_PREFS, }); /** Recent history (Auto Save), newest first. */ -export const historyItem = storage.defineItem('local:history', { +export const historyItem = defineItem('local:history', { fallback: [], }); /** Starred songs (History → Favorites). Array order = manual sort order. */ -export const favoritesItem = storage.defineItem('local:favorites', { +export const favoritesItem = defineItem('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('local:eqPresets', { +export const eqPresetsItem = defineItem('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('local:grantedOrigins', { +export const grantedOriginsItem = defineItem('local:grantedOrigins', { fallback: [], }); @@ -49,7 +86,7 @@ export async function loadTrackData(key: string): Promise { } export async function saveTrackData(data: TrackData): Promise { - await storage.setItem(trackDataKey(data.identity.key), data); + await storage.setItem(trackDataKey(data.identity.key), toPlain(data)); } export async function removeAllTrackData(): Promise { diff --git a/src/core/state/session.svelte.ts b/src/core/state/session.svelte.ts index 6978629..8d731f2 100644 --- a/src/core/state/session.svelte.ts +++ b/src/core/state/session.svelte.ts @@ -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): void; @@ -87,6 +90,7 @@ class SessionStore { this.bpmNoResult = false; this.#dspBlocked = false; clearTimeout(this.#bpmHintTimer); + this.onEngineDetached?.(); } get connected(): boolean { diff --git a/src/core/state/track-sync.svelte.ts b/src/core/state/track-sync.svelte.ts index 593fed0..d3924d5 100644 --- a/src/core/state/track-sync.svelte.ts +++ b/src/core/state/track-sync.svelte.ts @@ -3,6 +3,7 @@ import { makeTrackIdentity } from '../model/track-identity'; import type { EffectParams, HistoryEntry, MediaInfo, TrackData, TrackIdentity } from '../model/types'; 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 { trackDataDescriptors } from '../persist/track-data'; import { session } from './session.svelte'; @@ -15,11 +16,23 @@ class TrackSync { #identity: TrackIdentity | null = null; #pageUrl = ''; #thumbnailUrl: string | undefined; - /** Params staged by a History click, applied when that track loads. */ - #pendingRestore: { key: string; params: EffectParams } | null = null; #saveTimer: ReturnType | undefined; + /** The params last set for #identity. `session.params` is overwritten by the + * incoming engine's snapshot before that snapshot's media event reaches us, + * so the outgoing track has to be saved from this, not from the mirror. */ + #params: EffectParams | null = null; /** True once the user touched a control on this track — gates the Recent save. */ #userAdjusted = false; + /** Key of an in-flight #apply. Every connect delivers more than one snapshot + * and #identity is only assigned after an awaited save, so without this two + * runs both take the track-switch branch and the loser applies auto-reset + * over the params the winner just restored. */ + #applyingKey: string | null = null; + /** The song whose saved settings are currently applied, as `url\ntitle`. Set + * only on a successful restore, so a lookup that missed — the title had not + * settled yet — is retried on the next media event. Deliberately excludes the + * duration: drifting duration is what it has to survive. */ + #restoredFor: string | null = null; #zeroDurationTimer: ReturnType | undefined; init() { @@ -33,6 +46,17 @@ class TrackSync { } } + /** The engine link dropped — reload, reopened tab, tab switch. Whatever + * reconnects starts on the default preset, so this song has to be restored + * again; flush any live edits first so the restore reads them back. */ + onEngineLost() { + // #saveCurrent reads #userAdjusted and #params before its first await, so + // clearing the flag on the next line can't race it. + void this.#saveCurrent(); + this.#userAdjusted = false; + this.#restoredFor = null; + } + /** Called for every media info event from the engine. */ async onMedia(media: MediaInfo | null) { clearTimeout(this.#zeroDurationTimer); @@ -49,14 +73,45 @@ class TrackSync { await this.#apply(media); } + /** Puts a song's saved settings back on, however it was opened — a clicked + * row, a typed URL, a link, an SPA navigation, a reload. Recent and Favorites + * hand a song back the way it was left; the "new song" preference governs only + * songs with nothing saved. */ + #restoreSaved(identity: TrackIdentity): boolean { + const token = `${identity.normalizedUrl}\n${identity.title}`; + if (this.#restoredFor === token) return false; + const entry = findSavedEntry(identity); + if (!entry) return false; + this.#restoredFor = token; + // $state.snapshot, not structuredClone: the entry belongs to a $state store, + // so its params are a proxy (structuredClone throws DataCloneError on one) + // and patchParams would otherwise assign its EQ band array by reference. + this.#applyParams($state.snapshot(entry.params) as EffectParams); + return true; + } + + /** Sets params on the track's behalf rather than the user's. patchParams fires + * onParamsChanged synchronously, so without this every restore and every auto + * reset would count as an edit and re-save the entry it just read. */ + #applyParams(params: EffectParams) { + session.patchParams(params); + this.#userAdjusted = false; + clearTimeout(this.#saveTimer); + } + async #apply(media: MediaInfo) { const identity = makeTrackIdentity(media.pageUrl, media.title, media.duration); + if (identity.key === this.#applyingKey) return; + if (identity.key === this.#identity?.key) { // Same track — but the title may have settled late (SPA navigation). if (identity.title !== this.#identity.title) { this.#identity = identity; if (this.#userAdjusted) await this.#saveCurrent(); } + // A no-op unless something changed what this song is or what is applied to + // it: the title just settled, or the engine restarted on the defaults. + if (!this.#userAdjusted) this.#restoreSaved(identity); return; } @@ -65,54 +120,74 @@ class TrackSync { // treating it as a track switch, so Recents doesn't get a duplicate row. if (this.#identity && identity.normalizedUrl === this.#identity.normalizedUrl) { const staleKey = this.#identity.key; + const adjusted = this.#userAdjusted; this.#identity = identity; this.#pageUrl = media.pageUrl; this.#thumbnailUrl = media.thumbnailUrl ?? this.#thumbnailUrl; - if (this.#userAdjusted) { + // The key was wrong until now, so the real key's slice was never loaded. + const data = await loadTrackData(identity.key); + if (data) for (const d of trackDataDescriptors) d.load(data); + // Their edits outrank anything stored; otherwise a lookup that missed on + // the stale identity gets another go now the duration has settled. + if (!adjusted) this.#restoreSaved(identity); + if (adjusted) { await removeHistoryEntry(staleKey); await this.#saveCurrent(); - this.#persistTrackData(); + // Only carry the stale key's slice over when the real key has none, so + // this can't overwrite a saved record with an emptied one. + if (!data) this.#persistTrackData(); } return; } - // Leaving the previous track: auto-save it with its final settings. - await this.#saveCurrent(); - - this.#identity = identity; - this.#pageUrl = media.pageUrl; - this.#thumbnailUrl = media.thumbnailUrl; - - // Restore this track's per-feature data (markers, snippets, chords) if - // we've seen it before — each feature scatters its own slice. - const data = await loadTrackData(identity.key); - for (const d of trackDataDescriptors) d.load(data); - - // Starting params: history restore > auto reset > remember > carry over. - const restore = this.#pendingRestore; - this.#pendingRestore = null; - if (restore?.key === identity.key) { - session.patchParams(restore.params); - } else if (settings.current.autoReset) { - session.patchParams(structuredClone(DEFAULT_PARAMS)); - } else if (settings.current.rememberSettings && settings.current.lastUsedParams) { - session.patchParams(structuredClone(settings.current.lastUsedParams)); - } - // The patches above fire onParamsChanged synchronously — reset after them - // so only real user edits count toward the Recent save. - this.#userAdjusted = false; + this.#applyingKey = identity.key; + try { + // Leaving the previous track: auto-save it with its final settings. + await this.#saveCurrent(); + + this.#identity = identity; + this.#pageUrl = media.pageUrl; + this.#thumbnailUrl = media.thumbnailUrl; + + // Restore this track's per-feature data (markers, snippets, chords) if + // we've seen it before — each feature scatters its own slice. + const data = await loadTrackData(identity.key); + for (const d of trackDataDescriptors) d.load(data); + + // A different song, so nothing is applied for it yet — even if we happen + // to be coming back to one restored earlier. Reset after both awaits, so + // this branch is the last writer and an event that slipped through during + // them can't restore only to be auto-reset over. + this.#restoredFor = null; + this.#userAdjusted = false; + // Starting params: the song's own saved settings > auto reset > remember + // > carry over. The last three are for songs with nothing saved. + if (!this.#restoreSaved(identity)) { + if (settings.current.autoReset) { + this.#applyParams(structuredClone(DEFAULT_PARAMS)); + } else if (settings.current.rememberSettings && settings.current.lastUsedParams) { + // $state.snapshot, not structuredClone: settings.current is a rune, so + // lastUsedParams is a proxy and structuredClone throws on it. + this.#applyParams($state.snapshot(settings.current.lastUsedParams) as EffectParams); + } + } + this.#params = $state.snapshot(session.params) as EffectParams; - // If the track is favorited, bump its Last Accessed timestamp. - await touchFavorite(identity.key, { - pageUrl: media.pageUrl, - thumbnailUrl: media.thumbnailUrl, - }); + // If the track is favorited, bump its Last Accessed timestamp. + await touchFavorite(identity, { + pageUrl: media.pageUrl, + thumbnailUrl: media.thumbnailUrl, + }); + } finally { + this.#applyingKey = null; + } } /** Called on (debounced) param changes to keep history and * "Remember settings" fresh without waiting for a track switch. */ onParamsChanged() { this.#userAdjusted = true; + this.#params = $state.snapshot(session.params) as EffectParams; clearTimeout(this.#saveTimer); this.#saveTimer = setTimeout(() => { void this.#saveCurrent(); @@ -126,11 +201,21 @@ class TrackSync { async #saveCurrent() { if (!this.#identity) return; - const params = $state.snapshot(session.params) as EffectParams; - // Favorites mirror the latest settings so they recall them on open. - await touchFavorite(this.#identity.key, { params }); - if (!settings.current.autoSave || !this.#userAdjusted) return; - await upsertHistory(this.#identity, params, this.#pageUrl, this.#thumbnailUrl); + // Nothing was edited on this track — mirroring now would write auto-reset + // or carried-over state over what the user actually saved. + if (!this.#userAdjusted) return; + const params = this.#params ?? ($state.snapshot(session.params) as EffectParams); + // Both copies, from one value, in one call: a song that is favorited *and* + // in Recent must never end up with the two disagreeing. Auto Save off stops + // new rows being added, not an existing one being kept current. + await touchFavorite(this.#identity, { params }); + await upsertHistory( + this.#identity, + params, + this.#pageUrl, + this.#thumbnailUrl, + !settings.current.autoSave, + ); } #persistTrackData() { @@ -150,9 +235,21 @@ class TrackSync { void saveTrackData(data); } - /** History → Recent entry clicked: navigate there and stage its settings. */ + /** A row in the Songs list was clicked: go there. Its settings need no staging + * — arriving at a song is what puts them back on, whoever asked for it. */ async openHistoryEntry(tabId: number | null, entry: HistoryEntry) { - this.#pendingRestore = { key: entry.identity.key, params: entry.params }; + const target = makeTrackIdentity(entry.pageUrl, '', 0).normalizedUrl; + const playing = session.media?.pageUrl; + // Already on that page: apply in place. Re-navigating to the URL it is + // already on would reload for nothing (losing the playhead), and may not + // fire a media event at all. + if (playing && makeTrackIdentity(playing, '', 0).normalizedUrl === target) { + // Snapshot: `entry` belongs to a $state store, so patchParams would + // otherwise assign its EQ band array straight off the entry. + this.#applyParams($state.snapshot(entry.params) as EffectParams); + return; + } + if (tabId != null) { await browser.tabs.update(tabId, { url: entry.pageUrl }); } else { diff --git a/src/entrypoints/sidepanel/App.svelte b/src/entrypoints/sidepanel/App.svelte index e8534fd..3926646 100644 --- a/src/entrypoints/sidepanel/App.svelte +++ b/src/entrypoints/sidepanel/App.svelte @@ -29,6 +29,7 @@ }); }; session.onUserParamsChange = () => trackSync.onParamsChanged(); + session.onEngineDetached = () => trackSync.onEngineLost(); // Diagnostics for the E2E harness; kept out of release builds. if (import.meta.env.DEV || import.meta.env.MODE === 'testing') { (globalThis as Record).__panelDebug = { diff --git a/src/features/library/panel/LibraryView.svelte b/src/features/library/panel/LibraryView.svelte index 352349d..4992181 100644 --- a/src/features/library/panel/LibraryView.svelte +++ b/src/features/library/panel/LibraryView.svelte @@ -233,7 +233,7 @@
{#each history.entries as entry (entry.identity.key)} - {@const favorited = favorites.has(entry.identity.key)} + {@const favorited = favorites.has(entry.identity)}
{@render entryButton(entry, entry.updatedAt)} Promise): Promise { + try { + await run(); + } catch (err) { + console.error(`[note-by-note] favorites: ${what} failed:`, err); + } +} + class FavoritesStore { entries = $state([]); @@ -16,17 +28,28 @@ class FavoritesStore { }); } - has(key: string): boolean { - return this.entries.some((e) => e.identity.key === key); + /** By song, not by key: a favorite stored under a duration that has since + * drifted is still this track, and its star has to read as lit. */ + has(identity: TrackIdentity): boolean { + return this.entries.some((e) => isSameTrack(e.identity, identity)); } async toggle(entry: HistoryEntry) { - if (this.has(entry.identity.key)) await removeFavorite(entry.identity.key); - else await addFavorite(entry); + // Unstar the row as it was stored — its key may differ from this one's. + const existing = this.entries.find((e) => isSameTrack(e.identity, entry.identity)); + // $state.snapshot: `entry` belongs to the history store, so it and its + // nested identity/params are proxies. Firefox structured-clones storage + // writes and throws DataCloneError on a proxy (Chrome, which serializes to + // JSON, does not) — without this the star silently never lights. + await write('toggle', () => + existing + ? removeFavorite(existing.identity.key) + : addFavorite($state.snapshot(entry) as HistoryEntry), + ); } async remove(key: string) { - await removeFavorite(key); + await write('remove', () => removeFavorite(key)); } /** Commit a new manual order (complete list of identity keys). Applied @@ -37,7 +60,7 @@ class FavoritesStore { .map((k) => byKey.get(k)) .filter((e): e is (typeof this.entries)[number] => e !== undefined); if (next.length === this.entries.length) this.entries = next; - await setFavoritesOrder(keys); + await write('reorder', () => setFavoritesOrder(keys)); } } diff --git a/src/features/library/panel/history.svelte.ts b/src/features/library/panel/history.svelte.ts index feeb030..93d06ae 100644 --- a/src/features/library/panel/history.svelte.ts +++ b/src/features/library/panel/history.svelte.ts @@ -1,11 +1,14 @@ import type { HistoryEntry } from '../../../core/model/types'; -import { clearHistory, removeHistoryEntry } from '../persist/history'; +import { clearHistory, dedupeHistory, removeHistoryEntry } from '../persist/history'; import { historyItem } from '../../../core/persist/storage'; class HistoryStore { entries = $state([]); async init() { + // Rows saved before dedupe-on-write can already be duplicated; collapse + // them once, on the way in, so the list the user sees is the stored one. + await dedupeHistory(); this.entries = await historyItem.getValue(); historyItem.watch((value) => { this.entries = value ?? []; diff --git a/src/features/library/panel/saved-settings.ts b/src/features/library/panel/saved-settings.ts new file mode 100644 index 0000000..03b02d4 --- /dev/null +++ b/src/features/library/panel/saved-settings.ts @@ -0,0 +1,25 @@ +import type { HistoryEntry, TrackIdentity } from '../../../core/model/types'; +import { favorites } from './favorites.svelte'; +import { history } from './history.svelte'; + +/** The settings saved for a song that is being *visited* — a typed URL, a link, + * an SPA navigation — or null when it has none. + * + * Deliberately looser than `isSameTrack`: at a page's first media event the + * title has usually not settled (sites rewrite document.title after the element + * fires), so demanding a title match would miss the very case this exists for. + * The URL alone is not enough either — every local file reports the local-player + * page URL and is told apart only by its title. So the title is required exactly + * when the URL is ambiguous, i.e. when it holds more than one song. */ +export function findSavedEntry(identity: TrackIdentity): HistoryEntry | null { + // Favorites first: the two lists are written together and cannot disagree, + // but with Auto Save off only the favorite is guaranteed to exist. + const pool = [...favorites.entries, ...history.entries].filter( + (e) => e.identity.normalizedUrl === identity.normalizedUrl, + ); + const titled = pool.filter((e) => e.identity.title === identity.title); + if (titled.length > 0) return titled[0]; + // Count songs, not rows — a favorited song is a row in both lists. + const oneSong = new Set(pool.map((e) => e.identity.title)).size === 1; + return oneSong ? pool[0] : null; +} diff --git a/src/features/library/persist/favorites.ts b/src/features/library/persist/favorites.ts index e730481..17f4cda 100644 --- a/src/features/library/persist/favorites.ts +++ b/src/features/library/persist/favorites.ts @@ -1,11 +1,14 @@ -import type { EffectParams, HistoryEntry } from '../../../core/model/types'; +import type { EffectParams, HistoryEntry, TrackIdentity } from '../../../core/model/types'; +import { isSameTrack } from '../../../core/model/track-identity'; import { favoritesItem } from '../../../core/persist/storage'; /** Star a song: copy the history entry into the Favorites library (top of the * manual order). No-op if already favorited. */ export async function addFavorite(entry: HistoryEntry): Promise { const list = await favoritesItem.getValue(); - if (list.some((e) => e.identity.key === entry.identity.key)) return; + // By song, not by key — starring the same track after its duration settled + // differently must not add a second row. + if (list.some((e) => isSameTrack(e.identity, entry.identity))) return; const now = Date.now(); await favoritesItem.setValue([ { ...entry, favoritedAt: now, lastAccessedAt: now }, @@ -29,19 +32,27 @@ export async function setFavoritesOrder(keys: string[]): Promise { } /** Refresh a favorite when its track is opened/played: bump Last Accessed and - * mirror the latest settings. No-op if the track isn't favorited. */ + * mirror the latest settings. No-op if the track isn't favorited. + * + * Matched by song, like every other favorites operation. Matching on + * `identity.key` would stop finding the row as soon as the duration drifted + * (pre-roll ad, late metadata) — the favorite would then freeze while Recent, + * which matches by song, kept updating, and the two copies would disagree. */ export async function touchFavorite( - key: string, + identity: TrackIdentity, patch?: { params?: EffectParams; pageUrl?: string; thumbnailUrl?: string }, ): Promise { const list = await favoritesItem.getValue(); - const index = list.findIndex((e) => e.identity.key === key); + const index = list.findIndex((e) => isSameTrack(e.identity, identity)); if (index === -1) return; const now = Date.now(); const entry = list[index]; const next = [...list]; next[index] = { ...entry, + // Adopt the current identity so the row stops being pinned to whatever + // duration it happened to be saved under. + identity, params: patch?.params ?? entry.params, pageUrl: patch?.pageUrl ?? entry.pageUrl, thumbnailUrl: patch?.thumbnailUrl ?? entry.thumbnailUrl, diff --git a/src/features/library/persist/history.ts b/src/features/library/persist/history.ts index fbf57e1..b3feb85 100644 --- a/src/features/library/persist/history.ts +++ b/src/features/library/persist/history.ts @@ -1,17 +1,25 @@ import { HISTORY_LIMIT } from '../../../core/model/defaults'; -import type { EffectParams, TrackIdentity } from '../../../core/model/types'; +import type { EffectParams, HistoryEntry, TrackIdentity } from '../../../core/model/types'; +import { isSameTrack } from '../../../core/model/track-identity'; import { historyItem } from '../../../core/persist/storage'; -/** Insert or refresh a Recent entry (newest first, LRU-capped). */ +/** Insert or refresh a Recent entry (newest first, LRU-capped). + * + * `onlyExisting` refreshes a row that is already there but never adds one — + * what Auto Save off means, since that toggle is about *adding* every song you + * play. Keeping the row current either way is what stops the Recent copy from + * drifting away from the Favorites copy of the same song. */ export async function upsertHistory( identity: TrackIdentity, params: EffectParams, pageUrl: string, thumbnailUrl?: string, + onlyExisting = false, ): Promise { const list = await historyItem.getValue(); const now = Date.now(); - const existing = list.find((e) => e.identity.key === identity.key); + const existing = list.find((e) => isSameTrack(e.identity, identity)); + if (!existing && onlyExisting) return; const entry = { identity, params, @@ -20,10 +28,24 @@ export async function upsertHistory( createdAt: existing?.createdAt ?? now, updatedAt: now, }; - const next = [entry, ...list.filter((e) => e.identity.key !== identity.key)]; + // Matched by song, not by key: this row supersedes every older one for the + // same song, so a duration that settled differently can't leave a twin behind. + const next = [entry, ...list.filter((e) => !isSameTrack(e.identity, identity))]; await historyItem.setValue(next.slice(0, HISTORY_LIMIT)); } +/** Collapse rows written before saves were matched by song (one song split + * across several durations). The list is newest-first, so the first row for a + * song wins and the older twins are dropped. */ +export async function dedupeHistory(): Promise { + const list = await historyItem.getValue(); + const kept: HistoryEntry[] = []; + for (const entry of list) { + if (!kept.some((e) => isSameTrack(e.identity, entry.identity))) kept.push(entry); + } + if (kept.length !== list.length) await historyItem.setValue(kept); +} + export async function removeHistoryEntry(key: string): Promise { const list = await historyItem.getValue(); await historyItem.setValue(list.filter((e) => e.identity.key !== key)); diff --git a/src/features/pitch/engine/rubberband.worklet.ts b/src/features/pitch/engine/rubberband.worklet.ts index a1ccb99..6b979dc 100644 --- a/src/features/pitch/engine/rubberband.worklet.ts +++ b/src/features/pitch/engine/rubberband.worklet.ts @@ -294,8 +294,14 @@ class RubberBandProcessor extends AudioWorkletProcessor { heap.set(inR, this.#inCh[1] >> 2); mod._rubberband_process(this.#state, this.#inPtr, BLOCK, false); - while (mod._rubberband_available(this.#state) > 0) { - const got = mod._rubberband_retrieve(this.#state, this.#outPtr, BLOCK); + // Retrieve only what's there: R3's output is hop-aligned, so the last pass + // almost always holds a partial block, and asking for BLOCK anyway makes + // Rubber Band log a short-read warning to stderr — i.e. a console.error per + // render quantum, from the audio thread. Never exceeds BLOCK, so #outPtr + // stays big enough. + let avail: number; + while ((avail = mod._rubberband_available(this.#state)) > 0) { + const got = mod._rubberband_retrieve(this.#state, this.#outPtr, Math.min(avail, BLOCK)); if (got <= 0) break; heap = mod.HEAPF32; const ol = this.#outCh[0] >> 2; diff --git a/src/features/settings/panel/SettingsView.svelte b/src/features/settings/panel/SettingsView.svelte index 467ba7c..9b4ca50 100644 --- a/src/features/settings/panel/SettingsView.svelte +++ b/src/features/settings/panel/SettingsView.svelte @@ -396,7 +396,7 @@
{@render prefText( 'When you open a new song', - 'What happens to transpose, pitch and speed: start from the defaults, keep what is set right now, or reuse the settings from your last song. Songs you reopen from History always come back with their own saved settings.', + 'What happens to transpose, pitch and speed on a song with nothing saved yet: start from the defaults, keep what is set right now, or reuse the settings from your last song. Songs in Recent or Favorites always come back with their own settings, however you open them.', )}