refactor: extract playback logic to playbackService

Move core playback operations to centralized service:
- playTrack() - unified track playback with all side effects
- MPRIS metadata updates
- System notifications
- Last.fm scrobbling and now playing
- Favorite status checking and toggling

Simplifies handlers: handlePlaylistTrackPlay, handleLocalTrackPlay, toggleFavorite.
Reduces +page.svelte from 1675 to 1525 lines.
This commit is contained in:
vicrodh committed 2026-01-10 05:12:04 -06:00
1 parent d3ea80c5d4
commit ea066ded97
2 files changed
+316 -184

No files matched your search

+282
View File
@@ -0,0 +1,282 @@
/**
* Playback Service
*
* Centralizes playback-related operations including:
* - Playing tracks (Qobuz and local)
* - MPRIS metadata updates
* - System notifications
* - Last.fm scrobbling
* - Favorite status checking
*/
import { invoke } from '@tauri-apps/api/core';
import {
setCurrentTrack,
setIsPlaying,
setIsFavorite,
type PlayingTrack
} from '$lib/stores/playerStore';
import { syncQueueState } from '$lib/stores/queueStore';
// ============ Types ============
export interface PlayTrackOptions {
isLocal?: boolean;
showLoadingToast?: boolean;
}
export interface MediaMetadata {
title: string;
artist: string;
album: string;
durationSecs: number;
coverUrl: string | null;
}
// ============ Callbacks ============
// These are set by the consumer (e.g., +page.svelte) to handle UI feedback
let toastCallback: ((message: string, type: 'success' | 'error' | 'info') => void) | null = null;
export function setToastCallback(callback: (message: string, type: 'success' | 'error' | 'info') => void): void {
toastCallback = callback;
}
function showToast(message: string, type: 'success' | 'error' | 'info'): void {
if (toastCallback) {
toastCallback(message, type);
}
}
// ============ Core Playback ============
/**
* Play a track and handle all related side effects
*/
export async function playTrack(
track: PlayingTrack,
options: PlayTrackOptions = {}
): Promise<boolean> {
const { isLocal = false, showLoadingToast = true } = options;
// Set current track in store
setCurrentTrack(track);
try {
if (showLoadingToast) {
showToast(`Loading: ${track.title}`, 'info');
}
// Use appropriate playback command
if (isLocal) {
await invoke('library_play_track', { trackId: track.id });
} else {
await invoke('play_track', { trackId: track.id });
}
setIsPlaying(true);
showToast(`Playing: ${track.title}`, 'success');
// Update MPRIS metadata
await updateMediaMetadata({
title: track.title,
artist: track.artist,
album: track.album,
durationSecs: track.duration,
coverUrl: track.artwork || null
});
// Show system notification
await showTrackNotification(track.title, track.artist, track.album, track.artwork);
// Update Last.fm
await updateLastfmNowPlaying(track.title, track.artist, track.album, track.duration, track.id);
// Check favorite status (only for Qobuz tracks)
if (!isLocal) {
const isFav = await checkTrackFavorite(track.id);
setIsFavorite(isFav);
} else {
setIsFavorite(false);
}
// Sync queue state
await syncQueueState();
return true;
} catch (err) {
console.error('Failed to play track:', err);
showToast(`Playback error: ${err}`, 'error');
setIsPlaying(false);
return false;
}
}
// ============ MPRIS Metadata ============
/**
* Update system media controls metadata
*/
export async function updateMediaMetadata(metadata: MediaMetadata): Promise<void> {
try {
await invoke('set_media_metadata', {
title: metadata.title,
artist: metadata.artist,
album: metadata.album,
durationSecs: metadata.durationSecs,
coverUrl: metadata.coverUrl
});
} catch (err) {
console.error('Failed to update media metadata:', err);
}
}
// ============ System Notifications ============
/**
* Show system notification for track change
*/
export async function showTrackNotification(
title: string,
artist: string,
album: string,
artworkUrl?: string
): Promise<void> {
try {
await invoke('show_track_notification', {
title,
artist,
album,
artworkUrl: artworkUrl || null
});
} catch (err) {
console.error('Failed to show track notification:', err);
}
}
// ============ Last.fm Integration ============
let lastScrobbledTrackId: number | null = null;
let scrobbleTimeout: ReturnType<typeof setTimeout> | null = null;
/**
* Update Last.fm "now playing" and schedule scrobble
*/
export async function updateLastfmNowPlaying(
title: string,
artist: string,
album: string,
durationSecs: number,
trackId: number
): Promise<void> {
// Check if scrobbling is enabled
const scrobblingEnabled = localStorage.getItem('qbz-lastfm-scrobbling') !== 'false';
const sessionKey = localStorage.getItem('qbz-lastfm-session-key');
if (!scrobblingEnabled || !sessionKey) return;
try {
// Update "now playing"
await invoke('lastfm_now_playing', {
artist,
track: title,
album: album || null
});
console.log('Last.fm: Updated now playing');
// Schedule scrobble after 50% of track or 4 minutes (whichever is shorter)
if (scrobbleTimeout) {
clearTimeout(scrobbleTimeout);
}
const scrobbleDelay = Math.min(durationSecs * 0.5, 240) * 1000; // in ms
scrobbleTimeout = setTimeout(async () => {
if (lastScrobbledTrackId !== trackId) {
try {
const timestamp = Math.floor(Date.now() / 1000);
await invoke('lastfm_scrobble', {
artist,
track: title,
album: album || null,
timestamp
});
lastScrobbledTrackId = trackId;
console.log('Last.fm: Scrobbled track');
} catch (err) {
console.error('Last.fm scrobble failed:', err);
}
}
}, scrobbleDelay);
} catch (err) {
console.error('Last.fm now playing failed:', err);
}
}
// ============ Favorites ============
/**
* Check if a track is in favorites
*/
export async function checkTrackFavorite(trackId: number): Promise<boolean> {
try {
const response = await invoke<{ tracks?: { items: Array<{ id: number }> } }>('get_favorites', {
favType: 'tracks',
limit: 500
});
if (response.tracks?.items) {
return response.tracks.items.some(item => item.id === trackId);
}
return false;
} catch (err) {
console.error('Failed to check favorite status:', err);
return false;
}
}
/**
* Toggle favorite status for a track
*/
export async function toggleTrackFavorite(
trackId: number,
currentlyFavorite: boolean
): Promise<{ success: boolean; isFavorite: boolean }> {
const newFavoriteState = !currentlyFavorite;
try {
if (newFavoriteState) {
await invoke('add_favorite', { favType: 'track', itemId: String(trackId) });
} else {
await invoke('remove_favorite', { favType: 'track', itemId: String(trackId) });
}
return { success: true, isFavorite: newFavoriteState };
} catch (err) {
console.error('Failed to toggle favorite:', err);
return { success: false, isFavorite: currentlyFavorite };
}
}
/**
* Add a track to favorites
*/
export async function addTrackToFavorites(trackId: number): Promise<boolean> {
try {
await invoke('add_favorite', { favType: 'track', itemId: String(trackId) });
return true;
} catch (err) {
console.error('Failed to add to favorites:', err);
return false;
}
}
// ============ Cleanup ============
/**
* Clear any pending scrobble timeouts
*/
export function cleanup(): void {
if (scrobbleTimeout) {
clearTimeout(scrobbleTimeout);
scrobbleTimeout = null;
}
}
+34 -184
View File
@@ -122,6 +122,18 @@
SongLinkResponse
} from '$lib/types';
// Services
import {
playTrack,
setToastCallback,
checkTrackFavorite,
toggleTrackFavorite,
addTrackToFavorites,
showTrackNotification,
updateLastfmNowPlaying,
cleanup as cleanupPlayback
} from '$lib/services/playbackService';
// Components
import Sidebar from '$lib/components/Sidebar.svelte';
import NowPlayingBar from '$lib/components/NowPlayingBar.svelte';
@@ -385,12 +397,11 @@
}
}
async function addTrackToFavorites(trackId: number) {
try {
await invoke('add_favorite', { favType: 'track', itemId: String(trackId) });
async function handleAddTrackToFavorites(trackId: number) {
const success = await addTrackToFavorites(trackId);
if (success) {
showToast('Added to favorites', 'success');
} catch (err) {
console.error('Failed to add to favorites:', err);
} else {
showToast('Failed to add to favorites', 'error');
}
}
@@ -586,40 +597,14 @@
}
}
// Check if a track is in favorites
async function checkTrackFavorite(trackId: number): Promise<boolean> {
try {
const response = await invoke<{ tracks?: { items: Array<{ id: number }> } }>('get_favorites', {
favType: 'tracks',
limit: 500
});
if (response.tracks?.items) {
return response.tracks.items.some(item => item.id === trackId);
}
return false;
} catch (err) {
console.error('Failed to check favorite status:', err);
return false;
}
}
async function toggleFavorite() {
if (!currentTrack) return;
const trackId = String(currentTrack.id);
const newFavoriteState = !isFavorite;
try {
if (newFavoriteState) {
await invoke('add_favorite', { favType: 'track', itemId: trackId });
showToast('Added to favorites', 'success');
} else {
await invoke('remove_favorite', { favType: 'track', itemId: trackId });
showToast('Removed from favorites', 'success');
}
setIsFavorite(newFavoriteState);
} catch (err) {
console.error('Failed to toggle favorite:', err);
const result = await toggleTrackFavorite(currentTrack.id, isFavorite);
if (result.success) {
setIsFavorite(result.isFavorite);
showToast(result.isFavorite ? 'Added to favorites' : 'Removed from favorites', 'success');
} else {
showToast('Failed to update favorites', 'error');
}
}
@@ -958,7 +943,7 @@
? `${track.bitDepth}bit/${track.samplingRate}kHz`
: 'CD Quality';
const newTrack: PlayingTrack = {
await playTrack({
id: track.id,
title: track.title,
artist: track.artist || 'Unknown Artist',
@@ -966,47 +951,7 @@
artwork: track.albumArt || '',
duration: track.durationSeconds,
quality
};
setCurrentTrack(newTrack);
try {
await invoke('play_track', { trackId: track.id });
setIsPlaying(true);
await invoke('set_media_metadata', {
title: track.title,
artist: track.artist || 'Unknown Artist',
album: track.album || 'Playlist',
durationSecs: track.durationSeconds,
coverUrl: track.albumArt
});
// Show system notification
showTrackNotification(
track.title,
track.artist || 'Unknown Artist',
track.album || 'Playlist',
track.albumArt
);
// Update Last.fm
updateLastfmNowPlaying(
track.title,
track.artist || 'Unknown Artist',
track.album || '',
track.durationSeconds,
track.id
);
// Check if track is favorite
setIsFavorite(await checkTrackFavorite(track.id));
await syncQueueState();
} catch (err) {
console.error('Failed to play track:', err);
showToast(`Playback error: ${err}`, 'error');
setIsPlaying(false);
}
});
}
async function handleLocalTrackPlay(track: LocalLibraryTrack) {
@@ -1019,7 +964,7 @@
: track.format)
: track.format;
const newTrack: PlayingTrack = {
await playTrack({
id: track.id,
title: track.title,
artist: track.artist,
@@ -1028,42 +973,7 @@
duration: track.duration_secs,
quality,
isLocal: true
};
setCurrentTrack(newTrack);
try {
await invoke('library_play_track', { trackId: track.id });
setIsPlaying(true);
showToast(`Playing: ${track.title}`, 'success');
// Update MPRIS metadata
await invoke('set_media_metadata', {
title: track.title,
artist: track.artist,
album: track.album,
durationSecs: track.duration_secs,
coverUrl: artwork || null
});
// Show system notification
showTrackNotification(track.title, track.artist, track.album, artwork || undefined);
// Update Last.fm
updateLastfmNowPlaying(
track.title,
track.artist,
track.album,
track.duration_secs,
track.id
);
// Local tracks don't have Qobuz favorites
setIsFavorite(false);
} catch (err) {
console.error('Failed to play local track:', err);
showToast(`Playback error: ${err}`, 'error');
setIsPlaying(false);
}
}, { isLocal: true });
}
// Handle setting queue from local library (tracks need different playback command)
@@ -1072,70 +982,6 @@
setLocalTrackIds(trackIds);
}
// System Notification for track changes
async function showTrackNotification(title: string, artist: string, album: string, artworkUrl?: string) {
try {
await invoke('show_track_notification', {
title,
artist,
album,
artworkUrl: artworkUrl || null
});
} catch (err) {
console.error('Failed to show track notification:', err);
}
}
// Last.fm scrobbling state
let lastScrobbledTrackId: number | null = null;
let scrobbleTimeout: ReturnType<typeof setTimeout> | null = null;
// Update Last.fm "now playing" and schedule scrobble
async function updateLastfmNowPlaying(title: string, artist: string, album: string, durationSecs: number, trackId: number) {
// Check if scrobbling is enabled
const scrobblingEnabled = localStorage.getItem('qbz-lastfm-scrobbling') !== 'false';
const sessionKey = localStorage.getItem('qbz-lastfm-session-key');
if (!scrobblingEnabled || !sessionKey) return;
try {
// Update "now playing"
await invoke('lastfm_now_playing', {
artist,
track: title,
album: album || null
});
console.log('Last.fm: Updated now playing');
// Schedule scrobble after 50% of track or 4 minutes (whichever is shorter)
if (scrobbleTimeout) {
clearTimeout(scrobbleTimeout);
}
const scrobbleDelay = Math.min(durationSecs * 0.5, 240) * 1000; // in ms
scrobbleTimeout = setTimeout(async () => {
if (lastScrobbledTrackId !== trackId) {
try {
const timestamp = Math.floor(Date.now() / 1000);
await invoke('lastfm_scrobble', {
artist,
track: title,
album: album || null,
timestamp
});
lastScrobbledTrackId = trackId;
console.log('Last.fm: Scrobbled track');
} catch (err) {
console.error('Last.fm scrobble failed:', err);
}
}
}, scrobbleDelay);
} catch (err) {
console.error('Last.fm now playing failed:', err);
}
}
// Playlist Modal Functions
function openCreatePlaylist() {
userPlaylists = sidebarRef?.getPlaylists() ?? [];
@@ -1268,6 +1114,9 @@
initDownloadStates();
startDownloadEventListeners();
// Set up playback service toast callback
setToastCallback(showToast);
// Subscribe to download state changes to trigger reactivity
const unsubscribeDownloads = subscribeDownloads(() => {
downloadStateVersion++;
@@ -1371,6 +1220,7 @@
unsubscribePlayer();
unsubscribeQueue();
stopPolling();
cleanupPlayback();
};
});
@@ -1421,7 +1271,7 @@
onTrackPlay={handleTrackPlay}
onTrackPlayNext={handleQobuzTrackPlayNext}
onTrackPlayLater={handleQobuzTrackPlayLater}
onTrackAddFavorite={addTrackToFavorites}
onTrackAddFavorite={handleAddTrackToFavorites}
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
onTrackShareQobuz={shareQobuzTrackLink}
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
@@ -1444,7 +1294,7 @@
onTrackPlay={handleAlbumTrackPlay}
onTrackPlayNext={handleAlbumTrackPlayNext}
onTrackPlayLater={handleAlbumTrackPlayLater}
onTrackAddFavorite={addTrackToFavorites}
onTrackAddFavorite={handleAddTrackToFavorites}
onTrackShareQobuz={shareQobuzTrackLink}
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
onTrackGoToAlbum={handleAlbumClick}
@@ -1467,7 +1317,7 @@
onTrackPlay={handlePlaylistTrackPlay}
onTrackPlayNext={handleQobuzTrackPlayNext}
onTrackPlayLater={handleQobuzTrackPlayLater}
onTrackAddFavorite={addTrackToFavorites}
onTrackAddFavorite={handleAddTrackToFavorites}
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
onTrackShareQobuz={shareQobuzTrackLink}
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
@@ -1488,7 +1338,7 @@
onTrackPlay={handlePlaylistTrackPlay}
onTrackPlayNext={handleDisplayTrackPlayNext}
onTrackPlayLater={handleDisplayTrackPlayLater}
onTrackAddFavorite={addTrackToFavorites}
onTrackAddFavorite={handleAddTrackToFavorites}
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
onTrackShareQobuz={shareQobuzTrackLink}
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
@@ -1509,7 +1359,7 @@
onArtistClick={handleArtistClick}
onTrackPlayNext={handleDisplayTrackPlayNext}
onTrackPlayLater={handleDisplayTrackPlayLater}
onTrackAddFavorite={addTrackToFavorites}
onTrackAddFavorite={handleAddTrackToFavorites}
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
onTrackShareQobuz={shareQobuzTrackLink}
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}