feat: replace polling with Tauri events and add unit tests

- Backend now emits playback:state events every 250ms when state changes
- PlayerStore uses listen() instead of setInterval for real-time updates
- Added Vitest testing framework with mocked Tauri APIs
- Added 53 unit tests for playerStore and queueStore
This commit is contained in:
vicrodh committed 2026-01-10 06:14:59 -06:00
1 parent 6dfcc8adaa
commit 71156e9d0a
8 files changed
+1872 -48

No files matched your search

+1094 -1
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -9,6 +9,8 @@
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest",
"test:run": "vitest run",
"tauri": "tauri"
},
"license": "MIT",
@@ -25,9 +27,12 @@
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2",
"@testing-library/svelte": "^5.3.1",
"jsdom": "^27.4.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "~5.6.2",
"vite": "^6.0.3"
"vite": "^6.0.3",
"vitest": "^4.0.16"
}
}
+46
View File
@@ -19,6 +19,7 @@ pub mod queue;
pub mod share;
use std::sync::Arc;
use tauri::{Emitter, Manager};
use tokio::sync::Mutex;
use api::QobuzClient;
@@ -113,6 +114,51 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_clipboard_manager::init())
.manage(AppState::new())
.setup(|app| {
// Start background task to emit playback events
let app_handle = app.handle().clone();
let player_state = app.state::<AppState>().player.state.clone();
std::thread::spawn(move || {
let mut last_position: u64 = 0;
let mut last_is_playing: bool = false;
let mut last_track_id: u64 = 0;
loop {
std::thread::sleep(std::time::Duration::from_millis(250));
let is_playing = player_state.is_playing();
let position = player_state.current_position();
let duration = player_state.duration();
let track_id = player_state.current_track_id();
let volume = player_state.volume();
// Only emit if state changed or position advanced
let should_emit = track_id != 0 && (
is_playing != last_is_playing
|| track_id != last_track_id
|| (is_playing && position != last_position)
);
if should_emit {
let event = player::PlaybackEvent {
is_playing,
position,
duration,
track_id,
volume,
};
let _ = app_handle.emit("playback:state", &event);
last_position = position;
last_is_playing = is_playing;
last_track_id = track_id;
}
}
});
Ok(())
})
.manage(library_state)
.manage(cast_state)
.manage(dlna_state)
+350
View File
@@ -0,0 +1,350 @@
/**
* Player Store Unit Tests
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import {
subscribe,
getCurrentTrack,
getIsPlaying,
getCurrentTime,
getDuration,
getVolume,
getIsFavorite,
getIsSkipping,
getPlayerState,
setCurrentTrack,
setIsFavorite,
setIsSkipping,
setIsPlaying,
setQueueEnded,
togglePlay,
seek,
setVolume,
stop,
startPolling,
stopPolling,
isPollingActive,
reset,
type PlayingTrack
} from './playerStore';
const mockedInvoke = vi.mocked(invoke);
const mockedListen = vi.mocked(listen);
const mockTrack: PlayingTrack = {
id: 123,
title: 'Test Song',
artist: 'Test Artist',
album: 'Test Album',
artwork: 'http://art.jpg',
duration: 180,
quality: 'CD Quality',
bitDepth: 16,
samplingRate: 44100
};
describe('playerStore', () => {
beforeEach(() => {
reset();
vi.clearAllMocks();
});
describe('initial state', () => {
it('should have no current track', () => {
expect(getCurrentTrack()).toBeNull();
});
it('should not be playing', () => {
expect(getIsPlaying()).toBe(false);
});
it('should have zero current time', () => {
expect(getCurrentTime()).toBe(0);
});
it('should have zero duration', () => {
expect(getDuration()).toBe(0);
});
it('should have default volume of 75', () => {
expect(getVolume()).toBe(75);
});
it('should not be favorite', () => {
expect(getIsFavorite()).toBe(false);
});
it('should not be skipping', () => {
expect(getIsSkipping()).toBe(false);
});
});
describe('subscribe', () => {
it('should notify listener immediately', () => {
const listener = vi.fn();
subscribe(listener);
expect(listener).toHaveBeenCalledTimes(1);
});
it('should return unsubscribe function', () => {
const listener = vi.fn();
const unsubscribe = subscribe(listener);
expect(typeof unsubscribe).toBe('function');
});
it('should stop notifying after unsubscribe', () => {
const listener = vi.fn();
const unsubscribe = subscribe(listener);
listener.mockClear();
unsubscribe();
setIsPlaying(true);
expect(listener).not.toHaveBeenCalled();
});
});
describe('getPlayerState', () => {
it('should return complete state', () => {
setCurrentTrack(mockTrack);
setIsPlaying(true);
setIsFavorite(true);
const state = getPlayerState();
expect(state).toEqual({
currentTrack: mockTrack,
isPlaying: true,
currentTime: 0,
duration: 180,
volume: 75,
isFavorite: true,
isSkipping: false
});
});
});
describe('setCurrentTrack', () => {
it('should set track and update duration', () => {
setCurrentTrack(mockTrack);
expect(getCurrentTrack()).toEqual(mockTrack);
expect(getDuration()).toBe(180);
expect(getCurrentTime()).toBe(0);
});
it('should reset times when clearing track', () => {
setCurrentTrack(mockTrack);
setCurrentTrack(null);
expect(getCurrentTrack()).toBeNull();
expect(getDuration()).toBe(0);
expect(getCurrentTime()).toBe(0);
});
it('should notify listeners', () => {
const listener = vi.fn();
subscribe(listener);
listener.mockClear();
setCurrentTrack(mockTrack);
expect(listener).toHaveBeenCalledTimes(1);
});
});
describe('setIsFavorite', () => {
it('should update favorite status', () => {
setIsFavorite(true);
expect(getIsFavorite()).toBe(true);
setIsFavorite(false);
expect(getIsFavorite()).toBe(false);
});
it('should notify listeners', () => {
const listener = vi.fn();
subscribe(listener);
listener.mockClear();
setIsFavorite(true);
expect(listener).toHaveBeenCalledTimes(1);
});
});
describe('setIsSkipping', () => {
it('should update skipping status', () => {
setIsSkipping(true);
expect(getIsSkipping()).toBe(true);
});
});
describe('setIsPlaying', () => {
it('should update playing status', () => {
setIsPlaying(true);
expect(getIsPlaying()).toBe(true);
});
});
describe('togglePlay', () => {
it('should do nothing without current track', async () => {
await togglePlay();
expect(mockedInvoke).not.toHaveBeenCalled();
});
it('should resume playback when paused', async () => {
setCurrentTrack(mockTrack);
mockedInvoke.mockResolvedValueOnce(undefined);
await togglePlay();
expect(getIsPlaying()).toBe(true);
expect(mockedInvoke).toHaveBeenCalledWith('resume_playback');
});
it('should pause playback when playing', async () => {
setCurrentTrack(mockTrack);
setIsPlaying(true);
mockedInvoke.mockResolvedValueOnce(undefined);
await togglePlay();
expect(getIsPlaying()).toBe(false);
expect(mockedInvoke).toHaveBeenCalledWith('pause_playback');
});
it('should revert on error', async () => {
setCurrentTrack(mockTrack);
mockedInvoke.mockRejectedValueOnce(new Error('Failed'));
await togglePlay();
// Should revert to false after failure
expect(getIsPlaying()).toBe(false);
});
});
describe('seek', () => {
it('should clamp position to valid range', async () => {
setCurrentTrack(mockTrack); // duration = 180
mockedInvoke.mockResolvedValue(undefined);
// Test negative
await seek(-10);
expect(getCurrentTime()).toBe(0);
// Test beyond duration
await seek(200);
expect(getCurrentTime()).toBe(180);
// Test valid position
await seek(90);
expect(getCurrentTime()).toBe(90);
});
it('should invoke backend with floored position', async () => {
setCurrentTrack(mockTrack);
mockedInvoke.mockResolvedValueOnce(undefined);
await seek(45.7);
expect(mockedInvoke).toHaveBeenCalledWith('seek', { position: 45 });
});
});
describe('setVolume', () => {
it('should clamp volume to 0-100', async () => {
mockedInvoke.mockResolvedValue(undefined);
await setVolume(-10);
expect(getVolume()).toBe(0);
await setVolume(150);
expect(getVolume()).toBe(100);
await setVolume(50);
expect(getVolume()).toBe(50);
});
it('should invoke backend with normalized volume', async () => {
mockedInvoke.mockResolvedValueOnce(undefined);
await setVolume(80);
expect(mockedInvoke).toHaveBeenCalledWith('set_volume', { volume: 0.8 });
});
});
describe('stop', () => {
it('should stop playback and clear state', async () => {
setCurrentTrack(mockTrack);
setIsPlaying(true);
mockedInvoke.mockResolvedValueOnce(undefined);
await stop();
expect(getIsPlaying()).toBe(false);
expect(getCurrentTrack()).toBeNull();
expect(getCurrentTime()).toBe(0);
expect(getDuration()).toBe(0);
});
});
describe('event listening', () => {
it('should start listening for events', async () => {
const mockUnlisten = vi.fn();
mockedListen.mockResolvedValueOnce(mockUnlisten);
await startPolling();
expect(mockedListen).toHaveBeenCalledWith('playback:state', expect.any(Function));
expect(isPollingActive()).toBe(true);
});
it('should not start if already listening', async () => {
const mockUnlisten = vi.fn();
mockedListen.mockResolvedValue(mockUnlisten);
await startPolling();
await startPolling();
expect(mockedListen).toHaveBeenCalledTimes(1);
});
it('should stop listening', async () => {
const mockUnlisten = vi.fn();
mockedListen.mockResolvedValueOnce(mockUnlisten);
await startPolling();
stopPolling();
expect(mockUnlisten).toHaveBeenCalled();
expect(isPollingActive()).toBe(false);
});
});
describe('reset', () => {
it('should reset all state', async () => {
setCurrentTrack(mockTrack);
setIsPlaying(true);
setIsFavorite(true);
setIsSkipping(true);
const mockUnlisten = vi.fn();
mockedListen.mockResolvedValueOnce(mockUnlisten);
await startPolling();
reset();
expect(getCurrentTrack()).toBeNull();
expect(getIsPlaying()).toBe(false);
expect(getCurrentTime()).toBe(0);
expect(getDuration()).toBe(0);
expect(getIsFavorite()).toBe(false);
expect(getIsSkipping()).toBe(false);
expect(isPollingActive()).toBe(false);
});
});
});
+56 -46
View File
@@ -2,11 +2,11 @@
* Player State Store
*
* Manages playback state including current track, play/pause, position, volume.
* Currently uses polling to sync with backend - designed to be replaced with
* Tauri events in the future.
* Uses Tauri events for real-time updates from the backend.
*/
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
// ============ Types ============
@@ -31,6 +31,15 @@ interface BackendPlaybackState {
volume: number;
}
// Event payload from backend
interface PlaybackEvent {
is_playing: boolean;
position: number;
duration: number;
track_id: number;
volume: number;
}
// ============ State ============
let currentTrack: PlayingTrack | null = null;
@@ -40,8 +49,8 @@ let duration = 0;
let volume = 75;
let isFavorite = false;
// Polling state
let pollInterval: ReturnType<typeof setInterval> | null = null;
// Event listener state (replaces polling)
let eventUnlisten: UnlistenFn | null = null;
let isAdvancingTrack = false;
let isSkipping = false;
let queueEnded = false;
@@ -242,7 +251,7 @@ export async function stop(): Promise<void> {
}
}
// ============ Polling ============
// ============ Event-Based Updates ============
/**
* Set callback for when track ends (for auto-advance)
@@ -252,71 +261,72 @@ export function setOnTrackEnded(callback: () => Promise<void>): void {
}
/**
* Poll playback state from backend
* Handle playback event from backend
*/
async function pollPlaybackState(): Promise<void> {
async function handlePlaybackEvent(event: PlaybackEvent): Promise<void> {
if (!currentTrack) return;
try {
const state = await invoke<BackendPlaybackState>('get_playback_state');
// Only update if we have a matching track
if (event.track_id === currentTrack.id) {
currentTime = event.position;
isPlaying = event.is_playing;
notifyListeners();
// Only update if we have a matching track
if (state.track_id === currentTrack.id) {
currentTime = state.position;
isPlaying = state.is_playing;
notifyListeners();
// Check if track ended - auto-advance to next
if (
event.duration > 0 &&
event.position >= event.duration - 1 &&
!event.is_playing &&
!isAdvancingTrack &&
!queueEnded &&
onTrackEnded
) {
console.log('Track finished, advancing to next...');
isAdvancingTrack = true;
// Check if track ended - auto-advance to next
if (
state.duration > 0 &&
state.position >= state.duration - 1 &&
!state.is_playing &&
!isAdvancingTrack &&
!queueEnded &&
onTrackEnded
) {
console.log('Track finished, advancing to next...');
isAdvancingTrack = true;
try {
await onTrackEnded();
} catch (err) {
console.error('Failed to auto-advance:', err);
} finally {
isAdvancingTrack = false;
}
try {
await onTrackEnded();
} catch (err) {
console.error('Failed to auto-advance:', err);
} finally {
isAdvancingTrack = false;
}
}
} catch (err) {
console.error('Failed to poll playback state:', err);
}
}
/**
* Start polling for playback state
* Start listening for playback events from backend
*/
export function startPolling(): void {
if (pollInterval) return;
export async function startPolling(): Promise<void> {
if (eventUnlisten) return;
pollInterval = setInterval(pollPlaybackState, 500);
pollPlaybackState(); // Poll immediately
try {
eventUnlisten = await listen<PlaybackEvent>('playback:state', (event) => {
handlePlaybackEvent(event.payload);
});
console.log('Started listening for playback events');
} catch (err) {
console.error('Failed to start playback event listener:', err);
}
}
/**
* Stop polling
* Stop listening for playback events
*/
export function stopPolling(): void {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
if (eventUnlisten) {
eventUnlisten();
eventUnlisten = null;
console.log('Stopped listening for playback events');
}
}
/**
* Check if polling is active
* Check if event listener is active
*/
export function isPollingActive(): boolean {
return pollInterval !== null;
return eventUnlisten !== null;
}
// ============ Cleanup ============
+275
View File
@@ -0,0 +1,275 @@
/**
* Queue Store Unit Tests
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { invoke } from '@tauri-apps/api/core';
import {
subscribe,
getQueue,
getQueueTotalTracks,
getIsShuffle,
getRepeatMode,
getQueueState,
isLocalTrack,
setLocalTrackIds,
clearLocalTrackIds,
reset,
syncQueueState,
toggleShuffle,
toggleRepeat,
type BackendQueueTrack
} from './queueStore';
const mockedInvoke = vi.mocked(invoke);
describe('queueStore', () => {
beforeEach(() => {
// Reset store state between tests
reset();
vi.clearAllMocks();
});
describe('initial state', () => {
it('should have empty queue', () => {
expect(getQueue()).toEqual([]);
});
it('should have zero total tracks', () => {
expect(getQueueTotalTracks()).toBe(0);
});
it('should have shuffle off', () => {
expect(getIsShuffle()).toBe(false);
});
it('should have repeat mode off', () => {
expect(getRepeatMode()).toBe('off');
});
});
describe('subscribe', () => {
it('should notify listener immediately on subscribe', () => {
const listener = vi.fn();
subscribe(listener);
expect(listener).toHaveBeenCalledTimes(1);
});
it('should return unsubscribe function', () => {
const listener = vi.fn();
const unsubscribe = subscribe(listener);
expect(typeof unsubscribe).toBe('function');
});
it('should not notify after unsubscribe', () => {
const listener = vi.fn();
const unsubscribe = subscribe(listener);
listener.mockClear();
unsubscribe();
reset(); // This normally triggers notifications
expect(listener).not.toHaveBeenCalled();
});
});
describe('getQueueState', () => {
it('should return complete state object', () => {
const state = getQueueState();
expect(state).toEqual({
queue: [],
queueTotalTracks: 0,
isShuffle: false,
repeatMode: 'off'
});
});
it('should return a copy of queue array', () => {
const state1 = getQueueState();
const state2 = getQueueState();
expect(state1.queue).not.toBe(state2.queue);
});
});
describe('local track management', () => {
it('should track local track IDs', () => {
setLocalTrackIds([1, 2, 3]);
expect(isLocalTrack(1)).toBe(true);
expect(isLocalTrack(2)).toBe(true);
expect(isLocalTrack(3)).toBe(true);
expect(isLocalTrack(4)).toBe(false);
});
it('should clear local track IDs', () => {
setLocalTrackIds([1, 2, 3]);
clearLocalTrackIds();
expect(isLocalTrack(1)).toBe(false);
});
it('should reset local tracks on full reset', () => {
setLocalTrackIds([1, 2, 3]);
reset();
expect(isLocalTrack(1)).toBe(false);
});
});
describe('syncQueueState', () => {
it('should sync state from backend', async () => {
mockedInvoke.mockResolvedValueOnce({
current_track: null,
current_index: null,
upcoming: [
{ id: 1, title: 'Song 1', artist: 'Artist', album: 'Album', duration_secs: 180, artwork_url: 'http://art.jpg' },
{ id: 2, title: 'Song 2', artist: 'Artist', album: 'Album', duration_secs: 240, artwork_url: null }
],
history: [],
shuffle: true,
repeat: 'All',
total_tracks: 5
});
await syncQueueState();
expect(getQueue()).toHaveLength(2);
expect(getQueue()[0]).toEqual({
id: '1',
artwork: 'http://art.jpg',
title: 'Song 1',
artist: 'Artist',
duration: '3:00'
});
expect(getQueue()[1].duration).toBe('4:00');
expect(getQueueTotalTracks()).toBe(5);
expect(getIsShuffle()).toBe(true);
expect(getRepeatMode()).toBe('all');
});
it('should handle empty artwork gracefully', async () => {
mockedInvoke.mockResolvedValueOnce({
current_track: null,
current_index: null,
upcoming: [
{ id: 1, title: 'Song', artist: 'Artist', album: 'Album', duration_secs: 60, artwork_url: null }
],
history: [],
shuffle: false,
repeat: 'Off',
total_tracks: 1
});
await syncQueueState();
expect(getQueue()[0].artwork).toBe('');
});
it('should handle sync errors gracefully', async () => {
mockedInvoke.mockRejectedValueOnce(new Error('Backend error'));
// Should not throw
await syncQueueState();
// State remains unchanged
expect(getQueue()).toEqual([]);
});
});
describe('toggleShuffle', () => {
it('should toggle shuffle on', async () => {
mockedInvoke.mockResolvedValueOnce(undefined);
const result = await toggleShuffle();
expect(result).toEqual({ success: true, enabled: true });
expect(getIsShuffle()).toBe(true);
expect(mockedInvoke).toHaveBeenCalledWith('set_shuffle', { enabled: true });
});
it('should toggle shuffle off', async () => {
// First toggle on
mockedInvoke.mockResolvedValueOnce(undefined);
await toggleShuffle();
// Then toggle off
mockedInvoke.mockResolvedValueOnce(undefined);
const result = await toggleShuffle();
expect(result).toEqual({ success: true, enabled: false });
expect(getIsShuffle()).toBe(false);
});
it('should revert on error', async () => {
mockedInvoke.mockRejectedValueOnce(new Error('Failed'));
const result = await toggleShuffle();
expect(result).toEqual({ success: false, enabled: false });
expect(getIsShuffle()).toBe(false);
});
});
describe('toggleRepeat', () => {
it('should cycle through repeat modes', async () => {
mockedInvoke.mockResolvedValue(undefined);
// off -> all
let result = await toggleRepeat();
expect(result.mode).toBe('all');
// all -> one
result = await toggleRepeat();
expect(result.mode).toBe('one');
// one -> off
result = await toggleRepeat();
expect(result.mode).toBe('off');
});
it('should not change mode on error', async () => {
mockedInvoke.mockRejectedValueOnce(new Error('Failed'));
const result = await toggleRepeat();
expect(result).toEqual({ success: false, mode: 'off' });
expect(getRepeatMode()).toBe('off');
});
});
describe('reset', () => {
it('should reset all state', async () => {
// Set up some state
mockedInvoke.mockResolvedValueOnce({
current_track: null,
current_index: null,
upcoming: [{ id: 1, title: 'Song', artist: 'A', album: 'B', duration_secs: 60, artwork_url: null }],
history: [],
shuffle: true,
repeat: 'One',
total_tracks: 1
});
await syncQueueState();
setLocalTrackIds([1, 2, 3]);
// Verify state was set
expect(getQueue()).toHaveLength(1);
expect(getIsShuffle()).toBe(true);
// Reset
reset();
// Verify everything is cleared
expect(getQueue()).toEqual([]);
expect(getQueueTotalTracks()).toBe(0);
expect(getIsShuffle()).toBe(false);
expect(getRepeatMode()).toBe('off');
expect(isLocalTrack(1)).toBe(false);
});
it('should notify listeners on reset', () => {
const listener = vi.fn();
subscribe(listener);
listener.mockClear();
reset();
expect(listener).toHaveBeenCalledTimes(1);
});
});
});
+29
View File
@@ -0,0 +1,29 @@
/**
* Vitest Setup
*
* Mocks Tauri APIs for unit testing frontend stores.
*/
import { vi } from 'vitest';
// Mock @tauri-apps/api/core
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn()
}));
// Mock @tauri-apps/api/event
vi.mock('@tauri-apps/api/event', () => ({
listen: vi.fn(() => Promise.resolve(() => {})),
emit: vi.fn()
}));
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
length: 0,
key: vi.fn()
};
Object.defineProperty(global, 'localStorage', { value: localStorageMock });
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config';
import { sveltekit } from '@sveltejs/kit/vite';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['src/**/*.{test,spec}.ts'],
environment: 'jsdom',
globals: true,
setupFiles: ['src/test/setup.ts'],
alias: {
'$lib': '/src/lib',
'$app': '/src/test/mocks/app'
}
}
});