Merge branch 'feature/playlist-suggestions' into pre-release

This commit is contained in:
Victor RH committed 2026-01-29 12:52:47 -06:00
commit cbbe86f231
40 files changed
+748 -1762

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+38 -2
View File
@@ -736,6 +736,8 @@ impl Player {
let mut current_engine: Option<PlaybackEngine> = None;
// Store audio data for seeking (we need to re-decode from the beginning)
let mut current_audio_data: Option<Vec<u8>> = None;
// Store streaming source for resume (when download completes, we can get the data)
let mut current_streaming_source: Option<Arc<BufferedMediaSource>> = None;
// Track consecutive sink creation failures to detect broken streams
let mut consecutive_sink_failures: u32 = 0;
const MAX_SINK_FAILURES: u32 = 3;
@@ -749,6 +751,7 @@ impl Player {
let mut handle_command = |command: AudioCommand,
current_engine: &mut Option<PlaybackEngine>,
current_audio_data: &mut Option<Vec<u8>>,
current_streaming_source: &mut Option<Arc<BufferedMediaSource>>,
stream_opt: &mut Option<StreamType>,
current_device_name: &mut Option<String>,
consecutive_sink_failures: &mut u32,
@@ -966,6 +969,7 @@ impl Player {
}
*current_audio_data = Some(data.clone());
*current_streaming_source = None; // Clear streaming source for non-streaming playback
// Create PlaybackEngine from StreamType
let mut engine = match stream {
@@ -1068,6 +1072,11 @@ impl Player {
);
*pause_suspend_deadline = None;
// Store streaming source for resume capability
// When download completes, we can extract the data for resume
*current_streaming_source = Some(source.clone());
*current_audio_data = None; // Clear regular audio data
// Get DAC passthrough setting
let dac_passthrough = thread_settings
.lock()
@@ -1284,7 +1293,30 @@ impl Player {
AudioCommand::Resume => {
*pause_suspend_deadline = None;
if current_engine.is_none() {
let Some(ref audio_data) = *current_audio_data else {
// Try to get audio data from regular storage or streaming source
let audio_data: Vec<u8> = if let Some(ref data) = *current_audio_data {
data.clone()
} else if let Some(ref streaming_src) = *current_streaming_source {
// Try to get complete data from streaming source
if streaming_src.is_complete() {
match streaming_src.take_complete_data() {
Some(data) => {
log::info!("Resume: using complete streaming data ({} bytes)", data.len());
// Store it in current_audio_data for future use
*current_audio_data = Some(data.clone());
data
}
None => {
log::warn!("Audio thread: cannot resume - streaming source complete but data unavailable");
return;
}
}
} else {
log::warn!("Audio thread: cannot resume - streaming not complete yet ({} bytes buffered)",
streaming_src.buffer_size());
return;
}
} else {
log::warn!("Audio thread: cannot resume - no audio data available");
return;
};
@@ -1326,7 +1358,7 @@ impl Player {
let volume = thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
engine.set_volume(volume);
let source = match decode_with_fallback(audio_data) {
let source = match decode_with_fallback(&audio_data) {
Ok(s) => s,
Err(e) => {
log::error!("Failed to decode audio for resume: {}", e);
@@ -1366,6 +1398,7 @@ impl Player {
engine.stop();
}
*current_audio_data = None;
*current_streaming_source = None;
thread_state.is_playing.store(false, Ordering::SeqCst);
thread_state.position.store(0, Ordering::SeqCst);
thread_state.playback_start_millis.store(0, Ordering::SeqCst);
@@ -1504,6 +1537,7 @@ impl Player {
command,
&mut current_engine,
&mut current_audio_data,
&mut current_streaming_source,
&mut stream_opt,
&mut current_device_name,
&mut consecutive_sink_failures,
@@ -1554,6 +1588,7 @@ impl Player {
command,
&mut current_engine,
&mut current_audio_data,
&mut current_streaming_source,
&mut stream_opt,
&mut current_device_name,
&mut consecutive_sink_failures,
@@ -1577,6 +1612,7 @@ impl Player {
command,
&mut current_engine,
&mut current_audio_data,
&mut current_streaming_source,
&mut stream_opt,
&mut current_device_name,
&mut consecutive_sink_failures,
+143 -25
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { RefreshCw, Plus, X, Info, Sparkles, Play } from 'lucide-svelte';
import { RefreshCw, Plus, X, Info, Sparkles, Play, Check } from 'lucide-svelte';
import { fade, slide } from 'svelte/transition';
import { flip } from 'svelte/animate';
import {
type SuggestedTrack,
type SuggestionResult,
@@ -16,7 +18,10 @@
playlistId: number;
artists: PlaylistArtist[];
excludeTrackIds: number[];
onAddTrack?: (trackId: number) => Promise<void>;
/** Existing tracks in playlist for title+artist deduplication */
existingTracks?: Array<{ title: string; artist?: string }>;
/** Callback when track is added - receives full track data for local state update */
onAddTrack?: (track: SuggestedTrack) => Promise<void>;
onGoToAlbum?: (albumId: string) => void;
onGoToArtist?: (artistId: number) => void;
onPreviewTrack?: (track: SuggestedTrack) => void;
@@ -27,6 +32,7 @@
playlistId,
artists,
excludeTrackIds,
existingTracks = [],
onAddTrack,
onGoToAlbum,
onGoToArtist,
@@ -34,6 +40,42 @@
showReasons = false
}: Props = $props();
/**
* Normalize a string for comparison (lowercase, trim, remove extra whitespace)
*/
function normalizeForComparison(str: string): string {
return str.toLowerCase().trim().replace(/\s+/g, ' ');
}
/**
* Create a key for title+artist deduplication
*/
function makeTrackKey(title: string, artist: string): string {
return `${normalizeForComparison(title)}|${normalizeForComparison(artist)}`;
}
/**
* Build set of existing track keys for fast lookup
*/
const existingTrackKeys = $derived.by(() => {
const keys = new Set<string>();
for (const track of existingTracks) {
if (track.title && track.artist) {
keys.add(makeTrackKey(track.title, track.artist));
}
}
return keys;
});
/**
* Check if a suggested track is a duplicate of an existing track
*/
function isDuplicateOfExisting(track: SuggestedTrack): boolean {
if (!track.title || !track.artist_name) return false;
const key = makeTrackKey(track.title, track.artist_name);
return existingTrackKeys.has(key);
}
// TrackInfo modal state
let trackInfoOpen = $state(false);
let trackInfoId = $state<number | null>(null);
@@ -81,9 +123,24 @@
// Derived state
const dismissedIds = $derived(getDismissedTrackIds(playlistId));
const filteredPool = $derived(
pool.filter(t => !dismissedIds.has(t.track_id) && !excludeTrackIds.includes(t.track_id))
);
const filteredPool = $derived.by(() => {
// First filter by ID and dismissed
const byId = pool.filter(t =>
!dismissedIds.has(t.track_id) && !excludeTrackIds.includes(t.track_id)
);
// Then filter by title+artist to avoid duplicates from compilations, live albums, etc.
const filtered = byId.filter(t => !isDuplicateOfExisting(t));
// Also deduplicate within the pool itself (same song from different albums)
const seenKeys = new Set<string>();
return filtered.filter(t => {
const key = makeTrackKey(t.title, t.artist_name);
if (seenKeys.has(key)) return false;
seenKeys.add(key);
return true;
});
});
const totalPages = $derived(Math.ceil(filteredPool.length / VISIBLE_COUNT));
const visibleTracks = $derived(
filteredPool.slice(currentPage * VISIBLE_COUNT, (currentPage + 1) * VISIBLE_COUNT)
@@ -229,25 +286,42 @@
}
}
// Track which tracks are being added (for success animation)
let addingTrackIds = $state<Set<number>>(new Set());
let addedTrackIds = $state<Set<number>>(new Set());
async function handleAddTrack(track: SuggestedTrack) {
if (!onAddTrack) return;
if (!onAddTrack || addingTrackIds.has(track.track_id)) return;
// Mark as adding
addingTrackIds = new Set([...addingTrackIds, track.track_id]);
try {
await onAddTrack(track.track_id);
// Remove from pool locally (will be excluded on next load anyway)
pool = pool.filter(t => t.track_id !== track.track_id);
await onAddTrack(track);
// Show success state briefly
addedTrackIds = new Set([...addedTrackIds, track.track_id]);
addingTrackIds = new Set([...addingTrackIds].filter(id => id !== track.track_id));
// After a short delay, remove from pool (triggers exit animation)
setTimeout(() => {
pool = pool.filter(t => t.track_id !== track.track_id);
addedTrackIds = new Set([...addedTrackIds].filter(id => id !== track.track_id));
}, 600);
// Reset cycle counter - user found something they liked
completedCycles = 0;
lastAddedAt = pool.length;
} catch (err) {
console.error('Failed to add track:', err);
addingTrackIds = new Set([...addingTrackIds].filter(id => id !== track.track_id));
}
}
function handleDismiss(track: SuggestedTrack) {
dismissTrack(playlistId, track.track_id);
// Force reactivity by reassigning pool
pool = [...pool];
// Remove from pool immediately (triggers exit animation)
pool = pool.filter(t => t.track_id !== track.track_id);
}
</script>
@@ -266,11 +340,6 @@
<div class="header-left">
<Sparkles size={18} class="sparkle-icon" />
<h3>Suggested songs</h3>
{#if result && !loading}
<span class="stats">
Based on {result.playlist_artists_count} artists
</span>
{/if}
</div>
<button
class="refresh-btn"
@@ -296,7 +365,16 @@
{:else}
<div class="suggestions-list">
{#each visibleTracks as track (track.track_id)}
<div class="suggestion-row">
{@const isAdding = addingTrackIds.has(track.track_id)}
{@const isAdded = addedTrackIds.has(track.track_id)}
<div
class="suggestion-row"
class:adding={isAdding}
class:added={isAdded}
in:fade={{ duration: 200, delay: 50 }}
out:slide={{ duration: 250 }}
animate:flip={{ duration: 200 }}
>
<div class="album-art">
{#if track.album_image_url}
<img
@@ -363,15 +441,25 @@
</button>
<button
class="action-btn add"
class:adding={isAdding}
class:added={isAdded}
onclick={() => handleAddTrack(track)}
title="Add to playlist"
disabled={isAdding || isAdded}
title={isAdded ? 'Added!' : isAdding ? 'Adding...' : 'Add to playlist'}
>
<Plus size={16} />
{#if isAdded}
<Check size={16} />
{:else if isAdding}
<div class="btn-spinner"></div>
{:else}
<Plus size={16} />
{/if}
</button>
<button
class="action-btn dismiss"
onclick={() => handleDismiss(track)}
title="Not interested"
disabled={isAdding || isAdded}
>
<X size={16} />
</button>
@@ -421,12 +509,6 @@
color: var(--accent-primary);
}
.stats {
font-size: 12px;
color: var(--text-muted);
margin-left: 8px;
}
.refresh-btn {
display: flex;
align-items: center;
@@ -499,6 +581,9 @@
display: flex;
flex-direction: column;
gap: 2px;
/* Fixed height to prevent layout jump during add/dismiss animations */
/* 6 rows × 56px (40px content + 16px padding) + 5 gaps × 2px = 346px */
min-height: 346px;
}
.suggestion-row {
@@ -669,4 +754,37 @@
border-radius: 50%;
animation: spin 1s linear infinite;
}
/* Adding/Added states */
.suggestion-row.adding {
opacity: 0.7;
pointer-events: none;
}
.suggestion-row.added {
background: linear-gradient(90deg, rgba(34, 197, 94, 0.15) 0%, transparent 100%);
}
.suggestion-row.added .actions {
opacity: 1;
}
.action-btn.add.adding,
.action-btn.add.added {
pointer-events: none;
}
.action-btn.add.added {
background: var(--accent-primary);
color: white;
}
.btn-spinner {
width: 14px;
height: 14px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
</style>
@@ -38,6 +38,9 @@
getTrackArtist?: (track: Track) => string | undefined;
getTrackDuration?: (track: Track) => number;
getTrackAlbumKey?: (track: Track) => string | undefined;
getTrackAlbum?: (track: Track) => string | undefined;
// Whether to show the album column (false when grouping by album to avoid redundancy)
showAlbum?: boolean;
// Optional props for non-local tracks (Favorites, Search, etc.)
isLocal?: boolean;
hideDownload?: boolean;
@@ -81,6 +84,8 @@
getTrackArtist = (t: Track) => t.artist,
getTrackDuration = (t: Track) => t.duration_secs,
getTrackAlbumKey = (t: Track) => t.album_group_key,
getTrackAlbum = (t: Track) => t.album,
showAlbum = false,
// Non-local defaults
isLocal = true,
hideDownload = true,
@@ -305,6 +310,7 @@
{@const trackId = getTrackId(item.track)}
{@const trackArtist = getTrackArtist(item.track)}
{@const albumKey = getTrackAlbumKey(item.track)}
{@const albumName = showAlbum ? getTrackAlbum(item.track) : undefined}
{@const artistId = getArtistId?.(item.track)}
{@const albumId = getAlbumId?.(item.track)}
{@const downloadInfo = getOfflineCacheStatus?.(trackId) ?? { status: 'none' as const, progress: 0 }}
@@ -314,6 +320,7 @@
number={getTrackNumber(item.track, item.index)}
title={getTrackTitle(item.track)}
artist={trackArtist}
album={albumName}
duration={formatDuration(getTrackDuration(item.track))}
quality={getQualityBadge(item.track)}
isPlaying={isPlaybackActive && activeTrackId === trackId}
+83 -7
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { invoke, convertFileSrc } from '@tauri-apps/api/core';
import { onMount, tick } from 'svelte';
import { Heart, Play, Disc3, Mic2, Music, Search, X, LayoutGrid, List, ChevronDown, ListMusic, Edit3, Star, Folder, Library } from 'lucide-svelte';
import { Heart, Play, Disc3, Mic2, Music, Search, X, LayoutGrid, List, ChevronDown, ListMusic, Edit3, Star, Folder, Library, CloudDownload } from 'lucide-svelte';
import AlbumCard from '../AlbumCard.svelte';
import TrackRow from '../TrackRow.svelte';
import QualityBadge from '../QualityBadge.svelte';
@@ -799,6 +799,7 @@
const getFavoriteTrackArtist = (t: FavoriteTrack) => t.performer?.name;
const getFavoriteTrackDuration = (t: FavoriteTrack) => t.duration;
const getFavoriteTrackAlbumKey = (t: FavoriteTrack) => t.album?.id;
const getFavoriteTrackAlbum = (t: FavoriteTrack) => t.album?.title;
const getFavoriteArtistId = (t: FavoriteTrack) => t.performer?.id;
const getFavoriteAlbumId = (t: FavoriteTrack) => t.album?.id;
@@ -1208,7 +1209,21 @@
? new Set(trackIndexTargets.keys())
: new Set<string>()}
<!-- Always use virtualization for tracks - handles any favorites size efficiently -->
<!-- Track list header - outside virtualized container -->
<div class="track-list-header">
<div class="col-number">#</div>
<div class="col-title">Title</div>
{#if !trackGroupingEnabled || trackGroupMode !== 'album'}
<div class="col-album">Album</div>
{/if}
<div class="col-duration">Duration</div>
<div class="col-quality">Quality</div>
<div class="col-icon"><Heart size={14} /></div>
<div class="col-icon"><CloudDownload size={14} /></div>
<div class="col-spacer"></div>
</div>
<!-- Virtualized track list -->
<div class="track-sections virtualized">
<div class="virtualized-container">
<VirtualizedTrackList
@@ -1230,6 +1245,8 @@
getTrackArtist={getFavoriteTrackArtist}
getTrackDuration={getFavoriteTrackDuration}
getTrackAlbumKey={getFavoriteTrackAlbumKey}
getTrackAlbum={getFavoriteTrackAlbum}
showAlbum={!trackGroupingEnabled || trackGroupMode !== 'album'}
getArtistId={getFavoriteArtistId}
getAlbumId={getFavoriteAlbumId}
isLocal={false}
@@ -1774,6 +1791,7 @@
.dropdown-container {
position: relative;
z-index: 1000;
}
.control-btn {
@@ -1813,14 +1831,14 @@
.dropdown-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
left: 0;
min-width: 170px;
background: var(--bg-secondary);
border: 1px solid var(--bg-tertiary);
border-radius: 8px;
padding: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
z-index: 10;
z-index: 100;
max-height: 260px;
overflow-y: auto;
scrollbar-width: thin;
@@ -1998,14 +2016,72 @@
.track-sections.virtualized {
flex: 1;
min-height: 0;
height: 100%;
height: calc(100vh - 380px);
min-height: 400px;
overflow: hidden;
}
.virtualized-container {
flex: 1;
height: 100%;
min-height: 400px;
min-width: 0;
overflow: hidden;
}
/* Track list header - matches PlaylistDetailView style */
.track-list-header {
width: 100%;
height: 40px;
padding: 0 16px;
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
font-size: 12px;
text-transform: uppercase;
color: #666666;
font-weight: 400;
box-sizing: border-box;
border-bottom: 1px solid var(--bg-tertiary);
margin-bottom: 8px;
}
.track-list-header .col-number {
width: 48px;
text-align: center;
}
.track-list-header .col-title {
flex: 1;
min-width: 0;
}
.track-list-header .col-album {
flex: 1;
min-width: 0;
}
.track-list-header .col-duration {
width: 80px;
text-align: center;
}
.track-list-header .col-quality {
width: 80px;
text-align: center;
}
.track-list-header .col-icon {
width: 28px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
opacity: 0.5;
}
.track-list-header .col-spacer {
width: 28px;
}
.track-group-list {
@@ -8,7 +8,7 @@
import { open } from '@tauri-apps/plugin-dialog';
import TrackRow from '../TrackRow.svelte';
import PlaylistSuggestions from '../PlaylistSuggestions.svelte';
import { extractTopArtists } from '$lib/services/playlistSuggestionsService';
import { extractAdaptiveArtists } from '$lib/services/playlistSuggestionsService';
import { type OfflineCacheStatus } from '$lib/stores/offlineCacheState';
import {
subscribe as subscribeOffline,
@@ -194,9 +194,10 @@
let localTracksDuration = $derived(localTracks.reduce((sum, t) => sum + t.duration_secs, 0));
let totalDuration = $derived((playlist?.duration ?? 0) + localTracksDuration);
// Playlist suggestions: extract top 5 artists by track count (not local tracks)
// Playlist suggestions: adaptive artist selection (quantity scales with playlist size,
// mix of top artists for coherence + random artists for discovery)
const playlistArtists = $derived(
extractTopArtists(tracks.filter(t => !t.isLocal), 5)
extractAdaptiveArtists(tracks.filter(t => !t.isLocal))
);
// Track IDs to exclude from suggestions (already in playlist)
const excludeTrackIds = $derived(
@@ -1187,14 +1188,39 @@
}
// Add a suggested track to the playlist
async function handleAddSuggestedTrack(trackId: number) {
async function handleAddSuggestedTrack(suggestedTrack: import('$lib/services/playlistSuggestionsService').SuggestedTrack) {
try {
// Add to Qobuz playlist
await invoke('add_tracks_to_playlist', {
playlistId,
trackIds: [trackId]
trackIds: [suggestedTrack.track_id]
});
// Reload to show the new track
await loadPlaylist();
// Add to local tracks array immediately (no reload needed)
const newTrack: DisplayTrack = {
id: suggestedTrack.track_id,
number: tracks.length + 1,
title: suggestedTrack.title,
artist: suggestedTrack.artist_name,
artistId: suggestedTrack.artist_id,
album: suggestedTrack.album_title,
albumId: suggestedTrack.album_id,
albumArt: suggestedTrack.album_image_url,
duration: formatDuration(suggestedTrack.duration),
durationSeconds: suggestedTrack.duration,
addedIndex: tracks.length, // Latest added
};
// Append to tracks array
tracks = [...tracks, newTrack];
// Update playlist count
if (playlist) {
playlist.tracks_count = (playlist.tracks_count || 0) + 1;
playlist.duration = (playlist.duration || 0) + suggestedTrack.duration;
}
// Notify parent (sidebar count update, etc.)
notifyParentOfCounts();
onPlaylistUpdated?.();
} catch (err) {
@@ -1702,6 +1728,7 @@
playlistId={playlistId}
artists={playlistArtists}
excludeTrackIds={excludeTrackIds}
existingTracks={tracks.filter(t => !t.isLocal).map(t => ({ title: t.title, artist: t.artist }))}
onAddTrack={handleAddSuggestedTrack}
onGoToAlbum={onTrackGoToAlbum}
onGoToArtist={onTrackGoToArtist}
@@ -2159,6 +2186,7 @@
.sort-btn {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
background-color: var(--bg-tertiary);
@@ -2168,6 +2196,8 @@
font-size: 13px;
cursor: pointer;
transition: color 150ms ease;
min-width: 200px;
white-space: nowrap;
}
.sort-btn:hover {
@@ -2192,7 +2222,7 @@
border: 1px solid var(--bg-tertiary);
border-radius: 8px;
padding: 4px;
min-width: 140px;
min-width: 200px;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
@@ -2209,6 +2239,7 @@
cursor: pointer;
border-radius: 4px;
transition: all 150ms ease;
white-space: nowrap;
}
.sort-option:hover {
+97 -4
View File
@@ -161,18 +161,111 @@ export async function cleanupVectorStore(maxAgeDays?: number): Promise<number> {
// ============ Helpers ============
/** Default number of top artists to use for suggestions */
const DEFAULT_TOP_ARTISTS = 30;
/**
* Calculate adaptive artist limit based on playlist size
* Larger playlists = more seed artists for better discovery
*/
function calculateAdaptiveLimit(trackCount: number): number {
if (trackCount < 15) return Math.max(3, Math.min(5, trackCount));
if (trackCount < 50) return Math.min(10, Math.ceil(trackCount * 0.3));
if (trackCount < 100) return Math.min(15, Math.ceil(trackCount * 0.2));
return Math.min(20, Math.ceil(trackCount * 0.15));
}
/**
* Shuffle array using Fisher-Yates algorithm
*/
function shuffleArray<T>(array: T[]): T[] {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
/**
* Extract artists from playlist tracks with adaptive quantity and mixed selection
*
* This function balances coherence (top artists by frequency) with discovery
* (random selection from less frequent artists).
*
* @param tracks - Playlist tracks
* @param options - Configuration options
* @param options.topRatio - Ratio of top artists vs random (default: 0.6 = 60% top, 40% random)
* @param options.forceLimit - Override adaptive limit with fixed number
*/
export function extractAdaptiveArtists(
tracks: Array<{ artist?: string; artistId?: number }>,
options: { topRatio?: number; forceLimit?: number } = {}
): PlaylistArtist[] {
const { topRatio = 0.6, forceLimit } = options;
// Count tracks per artist
const artistCounts = new Map<string, { count: number; qobuzId?: number }>();
for (const track of tracks) {
if (track.artist) {
const existing = artistCounts.get(track.artist);
if (existing) {
existing.count++;
} else {
artistCounts.set(track.artist, { count: 1, qobuzId: track.artistId });
}
}
}
const uniqueArtistCount = artistCounts.size;
if (uniqueArtistCount === 0) return [];
// Calculate how many artists to select
const limit = forceLimit ?? calculateAdaptiveLimit(tracks.length);
const actualLimit = Math.min(limit, uniqueArtistCount);
// If we have few artists, just return all of them shuffled
if (uniqueArtistCount <= actualLimit) {
const all = [...artistCounts.entries()].map(([name, data]) => ({
name,
qobuzId: data.qobuzId
}));
return shuffleArray(all);
}
// Sort by count descending
const sorted = [...artistCounts.entries()]
.sort((a, b) => b[1].count - a[1].count);
// Split into top artists and the rest
const topCount = Math.max(1, Math.floor(actualLimit * topRatio));
const randomCount = actualLimit - topCount;
// Take top artists (coherence)
const topArtists = sorted.slice(0, topCount);
// Randomly select from remaining artists (discovery)
const remaining = sorted.slice(topCount);
const randomArtists = shuffleArray(remaining).slice(0, randomCount);
// Combine and shuffle the final selection
const combined = [...topArtists, ...randomArtists];
const shuffled = shuffleArray(combined);
return shuffled.map(([name, data]) => ({
name,
qobuzId: data.qobuzId
}));
}
/**
* Extract top artists from playlist tracks, sorted by frequency (track count)
* @deprecated Use extractAdaptiveArtists for better discovery
*
* @param tracks - Playlist tracks
* @param limit - Maximum number of artists to return (default: 5)
* @param limit - Maximum number of artists to return
*/
export function extractTopArtists(
tracks: Array<{ artist?: string; artistId?: number }>,
limit: number = DEFAULT_TOP_ARTISTS
limit: number = 30
): PlaylistArtist[] {
// Count tracks per artist
const artistCounts = new Map<string, { count: number; qobuzId?: number }>();
+30 -2
View File
@@ -68,11 +68,34 @@ interface PlaybackEvent {
// ============ State ============
/**
* Load persisted volume from localStorage
*/
function loadPersistedVolume(): number {
if (typeof localStorage === 'undefined') return 75;
const stored = localStorage.getItem('qbz-volume');
if (stored) {
const parsed = parseFloat(stored);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 100) {
return parsed;
}
}
return 75;
}
/**
* Save volume to localStorage
*/
function persistVolume(vol: number): void {
if (typeof localStorage === 'undefined') return;
localStorage.setItem('qbz-volume', String(vol));
}
let currentTrack: PlayingTrack | null = null;
let isPlaying = false;
let currentTime = 0;
let duration = 0;
let volume = 75;
let volume = loadPersistedVolume();
let isFavorite = false;
// Event listener state (replaces polling)
let eventUnlisten: UnlistenFn | null = null;
@@ -322,10 +345,13 @@ export async function seek(position: number): Promise<void> {
/**
* Set volume (0-100)
* Always persists the volume, even when nothing is playing.
* The volume will be applied when playback starts.
*/
export async function setVolume(newVolume: number): Promise<void> {
const clampedVolume = Math.max(0, Math.min(100, newVolume));
volume = clampedVolume;
persistVolume(clampedVolume);
notifyListeners();
try {
@@ -334,9 +360,11 @@ export async function setVolume(newVolume: number): Promise<void> {
return;
}
// Try to set volume on backend - will fail silently if no track is loaded
await invoke('set_volume', { volume: clampedVolume / 100 });
} catch (err) {
console.error('Failed to set volume:', err);
// Ignore errors when nothing is playing - volume is saved and will apply on next play
console.debug('Volume set locally (no active playback):', clampedVolume);
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 20 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 271 KiB

+311 -1714
View File
File diff suppressed because it is too large. Load diff

Before

Width:  |  Height:  |  Size: 350 KiB

After

Width:  |  Height:  |  Size: 29 KiB