refactor(player): E.1 — extract queue-identity helpers to utils/ (#563)

Four pure helpers move out of playerStore: normalizeAnalysisTrackId,
sameQueueTrackId, queuesStructuralEqual, shallowCloneQueueTracks.
Adds focused unit tests for the stream:-prefix normalization and the
no-op detection that prevents unnecessary queue rewrites.

Behaviour preserved verbatim. playerStore 3618 → 3598 LOC.
This commit is contained in:
Frank Stellmacher
2026-05-12 11:08:21 +02:00
committed by GitHub
parent f0971d5108
commit dcf3dd98e0
3 changed files with 150 additions and 26 deletions
+6 -26
View File
@@ -24,6 +24,12 @@ import { resolveReplayGainDb } from '../utils/resolveReplayGainDb';
import { shuffleArray } from '../utils/shuffleArray';
import { songToTrack } from '../utils/songToTrack';
import { buildInfiniteQueueCandidates } from '../utils/buildInfiniteQueueCandidates';
import {
normalizeAnalysisTrackId,
queuesStructuralEqual,
sameQueueTrackId,
shallowCloneQueueTracks,
} from '../utils/queueIdentity';
import { getWindowKind } from '../app/windowKind';
// Re-export for backward compatibility with the ~30 call sites that still
@@ -370,10 +376,6 @@ export function consumePendingQueueListScrollTop(): number | undefined {
return v;
}
function shallowCloneQueueTracks(queue: Track[]): Track[] {
return queue.map(t => ({ ...t }));
}
function queueUndoSnapshotFromState(s: PlayerState): QueueUndoSnapshot {
const scrollTop = readQueueListScrollTopForUndo();
return {
@@ -476,28 +478,6 @@ function emitNormalizationDebug(step: string, details?: Record<string, unknown>)
}).catch(() => {});
}
function normalizeAnalysisTrackId(trackId?: string | null): string | null {
if (!trackId) return null;
if (trackId.startsWith('stream:')) return trackId.slice('stream:'.length);
return trackId;
}
/** Compare track ids across `stream:` / bare Subsonic forms. */
function sameQueueTrackId(a: string | undefined | null, b: string | undefined | null): boolean {
if (a == null || b == null) return false;
const na = normalizeAnalysisTrackId(a) ?? a;
const nb = normalizeAnalysisTrackId(b) ?? b;
return na === nb;
}
function queuesStructuralEqual(a: Track[], b: Track[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!sameQueueTrackId(a[i]?.id, b[i]?.id)) return false;
}
return true;
}
function normalizationAlmostEqual(a: number | null, b: number | null, eps = 0.12): boolean {
if (a == null && b == null) return true;
if (a == null || b == null) return false;
+107
View File
@@ -0,0 +1,107 @@
/**
* Pure helpers extracted from playerStore. The interesting behaviour is the
* `stream:` prefix normalization (Rust events sometimes wrap track ids when
* routing through the HTTP source) and the no-op detection in
* `queuesStructuralEqual` that prevents unnecessary store rewrites.
*/
import { describe, expect, it } from 'vitest';
import type { Track } from '../store/playerStore';
import {
normalizeAnalysisTrackId,
queuesStructuralEqual,
sameQueueTrackId,
shallowCloneQueueTracks,
} from './queueIdentity';
function track(id: string, overrides: Partial<Track> = {}): Track {
return {
id,
title: `Title ${id}`,
artist: 'Artist',
album: 'Album',
albumId: 'A',
duration: 180,
...overrides,
};
}
describe('normalizeAnalysisTrackId', () => {
it('strips the stream: prefix', () => {
expect(normalizeAnalysisTrackId('stream:abc123')).toBe('abc123');
});
it('returns bare ids unchanged', () => {
expect(normalizeAnalysisTrackId('abc123')).toBe('abc123');
});
it('returns null for null / undefined / empty', () => {
expect(normalizeAnalysisTrackId(null)).toBeNull();
expect(normalizeAnalysisTrackId(undefined)).toBeNull();
expect(normalizeAnalysisTrackId('')).toBeNull();
});
});
describe('sameQueueTrackId', () => {
it('matches bare ids', () => {
expect(sameQueueTrackId('a', 'a')).toBe(true);
expect(sameQueueTrackId('a', 'b')).toBe(false);
});
it('matches across stream: prefix mismatch', () => {
expect(sameQueueTrackId('stream:a', 'a')).toBe(true);
expect(sameQueueTrackId('a', 'stream:a')).toBe(true);
expect(sameQueueTrackId('stream:a', 'stream:a')).toBe(true);
});
it('returns false when either side is null', () => {
expect(sameQueueTrackId(null, 'a')).toBe(false);
expect(sameQueueTrackId('a', null)).toBe(false);
expect(sameQueueTrackId(null, null)).toBe(false);
});
});
describe('queuesStructuralEqual', () => {
it('returns true for same ids in same order', () => {
expect(queuesStructuralEqual([track('a'), track('b')], [track('a'), track('b')])).toBe(true);
});
it('returns true when one side wraps ids with stream:', () => {
expect(queuesStructuralEqual(
[track('a'), track('b')],
[track('stream:a'), track('stream:b')],
)).toBe(true);
});
it('returns false for different lengths', () => {
expect(queuesStructuralEqual([track('a')], [track('a'), track('b')])).toBe(false);
});
it('returns false for any id mismatch', () => {
expect(queuesStructuralEqual([track('a'), track('b')], [track('a'), track('c')])).toBe(false);
});
it('treats empty queues as equal', () => {
expect(queuesStructuralEqual([], [])).toBe(true);
});
});
describe('shallowCloneQueueTracks', () => {
it('returns a new array with new objects (callers can mutate freely)', () => {
const original = [track('a'), track('b')];
const cloned = shallowCloneQueueTracks(original);
expect(cloned).not.toBe(original);
expect(cloned[0]).not.toBe(original[0]);
expect(cloned[1]).not.toBe(original[1]);
expect(cloned).toEqual(original);
});
it('preserves all fields', () => {
const original = [track('a', { coverArt: 'cover', userRating: 5, autoAdded: true })];
const cloned = shallowCloneQueueTracks(original);
expect(cloned[0]).toEqual(original[0]);
});
it('handles empty queues', () => {
expect(shallowCloneQueueTracks([])).toEqual([]);
});
});
+37
View File
@@ -0,0 +1,37 @@
import type { Track } from '../store/playerStore';
/**
* Strip the `stream:` prefix that some Rust events attach to track ids when
* they're routed through the HTTP source. Both forms identify the same track,
* so equality and structural-diff checks need to normalize first.
*/
export function normalizeAnalysisTrackId(trackId?: string | null): string | null {
if (!trackId) return null;
if (trackId.startsWith('stream:')) return trackId.slice('stream:'.length);
return trackId;
}
/** Compare track ids across `stream:` / bare Subsonic forms. */
export function sameQueueTrackId(a: string | undefined | null, b: string | undefined | null): boolean {
if (a == null || b == null) return false;
const na = normalizeAnalysisTrackId(a) ?? a;
const nb = normalizeAnalysisTrackId(b) ?? b;
return na === nb;
}
/**
* Same-length + same-ids check. Used to skip no-op queue rewrites that would
* otherwise reset selection / scroll / drag-source state in subscribers.
*/
export function queuesStructuralEqual(a: Track[], b: Track[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!sameQueueTrackId(a[i]?.id, b[i]?.id)) return false;
}
return true;
}
/** One-level clone so callers can mutate per-track fields without aliasing state. */
export function shallowCloneQueueTracks(queue: Track[]): Track[] {
return queue.map(t => ({ ...t }));
}