Merge branch 'pre-release' into fix/general-debug-1.1.7
No files matched your search
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 29 KiB |