mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat(lucky-mix): instant mix from your preferences
Lucky Mix targets ~50 tracks in one run: pick seeds from your most-played artists/albums and 4+ rated songs, add similar tracks, then fill the mix with random library picks, skipping anything you rated 1–2. On start it trims the old “upcoming” tail so the queue does not show stale next tracks, starts playback on the first viable seed, routes to Now Playing, and can emit structured steps to the backend debug log.
This commit is contained in:
@@ -157,6 +157,12 @@ fn export_runtime_logs(path: String) -> Result<usize, String> {
|
||||
crate::logging::export_logs_to_file(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn frontend_debug_log(scope: String, message: String) -> Result<(), String> {
|
||||
crate::app_deprintln!("[frontend][{}] {}", scope, message);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_subsonic_wire_user_agent(
|
||||
user_agent: String,
|
||||
@@ -3558,6 +3564,7 @@ pub fn run() {
|
||||
set_linux_webkit_smooth_scrolling,
|
||||
set_logging_mode,
|
||||
export_runtime_logs,
|
||||
frontend_debug_log,
|
||||
set_subsonic_wire_user_agent,
|
||||
no_compositing_mode,
|
||||
is_tiling_wm_cmd,
|
||||
|
||||
@@ -27,6 +27,7 @@ import Login from './pages/Login';
|
||||
import AlbumDetail from './pages/AlbumDetail';
|
||||
import MostPlayed from './pages/MostPlayed';
|
||||
import RandomAlbums from './pages/RandomAlbums';
|
||||
import LuckyMixPage from './pages/LuckyMix';
|
||||
import SearchResults from './pages/SearchResults';
|
||||
import Playlists from './pages/Playlists';
|
||||
import PlaylistDetail from './pages/PlaylistDetail';
|
||||
@@ -453,6 +454,7 @@ function AppShell() {
|
||||
<Route path="/new-releases" element={<NewReleases />} />
|
||||
<Route path="/favorites" element={<Favorites />} />
|
||||
<Route path="/random/mix" element={<RandomMix />} />
|
||||
<Route path="/lucky-mix" element={<LuckyMixPage />} />
|
||||
<Route path="/label/:name" element={<LabelAlbums />} />
|
||||
<Route path="/search" element={<SearchResults />} />
|
||||
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
||||
|
||||
@@ -14,8 +14,11 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu);
|
||||
const audiomuseByServer = useAuthStore(s => s.audiomuseNavidromeByServer);
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
const luckyMixAvailable = randomNavMode === 'separate' && showLuckyMixMenu && Boolean(serverId && audiomuseByServer[serverId]);
|
||||
|
||||
const items = sidebarItems
|
||||
.filter(cfg => {
|
||||
@@ -25,6 +28,7 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
|
||||
if (BOTTOM_NAV_ROUTES.has(item.to)) return false;
|
||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
|
||||
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
||||
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
|
||||
return true;
|
||||
})
|
||||
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
|
||||
|
||||
@@ -17,6 +17,7 @@ import LyricsPane from './LyricsPane';
|
||||
import NowPlayingInfo from './NowPlayingInfo';
|
||||
import { TFunction } from 'i18next';
|
||||
import OverlayScrollArea from './OverlayScrollArea';
|
||||
import { useLuckyMixStore } from '../store/luckyMixStore';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -269,6 +270,7 @@ export default function QueuePanel() {
|
||||
|
||||
const activeTab = useLyricsStore(s => s.activeTab);
|
||||
const setTab = useLyricsStore(s => s.setTab);
|
||||
const luckyRolling = useLuckyMixStore(s => s.isRolling);
|
||||
|
||||
const [showRemainingTime, setShowRemainingTime] = useState(false);
|
||||
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
|
||||
@@ -657,7 +659,8 @@ export default function QueuePanel() {
|
||||
{t('queue.emptyQueue')}
|
||||
</div>
|
||||
) : (
|
||||
queue.map((track, idx) => {
|
||||
<>
|
||||
{queue.map((track, idx) => {
|
||||
const isPlaying = idx === queueIndex;
|
||||
const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
|
||||
const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
|
||||
@@ -726,9 +729,30 @@ export default function QueuePanel() {
|
||||
{formatTime(track.duration)}
|
||||
</div>
|
||||
</div>
|
||||
{luckyRolling && isPlaying && (
|
||||
<div className="queue-lucky-loading" aria-hidden>
|
||||
<div className="queue-lucky-loading__dice">
|
||||
<div className="queue-lucky-cube queue-lucky-cube--a">
|
||||
<span className="lucky-mix-pip lucky-mix-pip--tl" />
|
||||
<span className="lucky-mix-pip lucky-mix-pip--tr" />
|
||||
<span className="lucky-mix-pip lucky-mix-pip--bl" />
|
||||
<span className="lucky-mix-pip lucky-mix-pip--br" />
|
||||
</div>
|
||||
<div className="queue-lucky-cube queue-lucky-cube--b">
|
||||
<span className="lucky-mix-pip lucky-mix-pip--center" />
|
||||
</div>
|
||||
<div className="queue-lucky-cube queue-lucky-cube--c">
|
||||
<span className="lucky-mix-pip lucky-mix-pip--tl" />
|
||||
<span className="lucky-mix-pip lucky-mix-pip--center" />
|
||||
<span className="lucky-mix-pip lucky-mix-pip--br" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</OverlayScrollArea>
|
||||
</>) : activeTab === 'lyrics' ? (
|
||||
|
||||
@@ -68,6 +68,9 @@ export default function Sidebar({
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const setSidebarItems = useSidebarStore(s => s.setItems);
|
||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||
const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu);
|
||||
const audiomuseByServer = useAuthStore(s => s.audiomuseNavidromeByServer);
|
||||
const luckyMixAvailable = randomNavMode === 'separate' && showLuckyMixMenu && Boolean(serverId && audiomuseByServer[serverId]);
|
||||
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
|
||||
const [playlistsExpanded, setPlaylistsExpanded] = useState(false);
|
||||
const playlistsRaw = usePlaylistStore(s => s.playlists);
|
||||
@@ -95,8 +98,13 @@ export default function Sidebar({
|
||||
[sidebarItems],
|
||||
);
|
||||
const visibleLibraryConfigs = useMemo(
|
||||
() => libraryItemsForReorder.filter(c => c.visible),
|
||||
[libraryItemsForReorder],
|
||||
() =>
|
||||
libraryItemsForReorder.filter(c => {
|
||||
if (!c.visible) return false;
|
||||
if (c.id === 'luckyMix' && !luckyMixAvailable) return false;
|
||||
return true;
|
||||
}),
|
||||
[libraryItemsForReorder, luckyMixAvailable],
|
||||
);
|
||||
const visibleSystemConfigs = useMemo(
|
||||
() => systemItemsForReorder.filter(c => c.visible),
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import {
|
||||
Disc3, Users, Music4, Radio, Heart, BarChart3,
|
||||
HelpCircle, Tags, ListMusic, Cast, TrendingUp,
|
||||
FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices,
|
||||
FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices, Sparkles,
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface NavItemMeta {
|
||||
@@ -20,6 +20,7 @@ export const ALL_NAV_ITEMS: Record<string, NavItemMeta> = {
|
||||
randomPicker: { icon: Wand2, labelKey: 'sidebar.randomPicker', to: '/random', section: 'library' },
|
||||
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random/mix', section: 'library' },
|
||||
randomAlbums: { icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random/albums', section: 'library' },
|
||||
luckyMix: { icon: Sparkles, labelKey: 'sidebar.feelingLucky', to: '/lucky-mix', section: 'library' },
|
||||
artists: { icon: Users, labelKey: 'sidebar.artists', to: '/artists', section: 'library' },
|
||||
genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' },
|
||||
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
|
||||
|
||||
+11
-1
@@ -31,6 +31,7 @@ export const deTranslation = {
|
||||
expandPlaylists: 'Playlists ausklappen',
|
||||
collapsePlaylists: 'Playlists einklappen',
|
||||
more: 'Mehr',
|
||||
feelingLucky: 'Glück',
|
||||
},
|
||||
home: {
|
||||
hero: 'Featured',
|
||||
@@ -283,6 +284,8 @@ export const deTranslation = {
|
||||
mixByTracksDesc: 'Zufällige Auswahl aus deiner gesamten Mediathek',
|
||||
mixByAlbums: 'Mix nach Alben',
|
||||
mixByAlbumsDesc: 'Zufällige Alben für neue Entdeckungen',
|
||||
mixByLucky: 'Glück',
|
||||
mixByLuckyDesc: 'Smarter Instant Mix aus Top-Künstlern, Alben und Bewertungen',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Zufallsalben',
|
||||
@@ -335,6 +338,11 @@ export const deTranslation = {
|
||||
filterPanelDesc: 'Genre-Tag oder Künstlername in der Liste anklicken, um ihn aus zukünftigen Mixes auszuschließen.',
|
||||
genreClickHint: 'Genre-Tag anklicken,\num es als Filter-Keyword hinzuzufügen.\nPrüft Genre, Titel, Album & Künstler.',
|
||||
},
|
||||
luckyMix: {
|
||||
done: '„Glück“ bereit: {{count}} Titel',
|
||||
failed: '„Glück“ konnte nicht erstellt werden. Bitte erneut versuchen.',
|
||||
unavailable: '„Glück“ ist für diesen Server nicht verfügbar.',
|
||||
},
|
||||
albums: {
|
||||
title: 'Alle Alben',
|
||||
sortByName: 'A–Z (Album)',
|
||||
@@ -720,6 +728,8 @@ export const deTranslation = {
|
||||
showChangelogOnUpdate: "'Was ist neu' bei Update anzeigen",
|
||||
showChangelogOnUpdateDesc: 'Blendet nach einem Update einen dezenten Changelog-Banner über Now Playing ein. Klick öffnet die Release Notes, X blendet ihn aus.',
|
||||
randomMixTitle: 'Zufallsmix-Blacklist',
|
||||
luckyMixMenuTitle: '„Glück“ im Menü anzeigen',
|
||||
luckyMixMenuDesc: 'Aktiviert „Glück“ im "Mix erstellen"-Hub und als separaten Menüeintrag bei getrennter Navigation. Sichtbar nur bei aktiviertem AudioMuse auf dem aktiven Server.',
|
||||
randomMixBlacklistTitle: 'Eigene Filter-Keywords',
|
||||
randomMixBlacklistDesc: 'Songs werden ausgeschlossen, wenn ein Keyword auf Genre, Titel, Album oder Künstler zutrifft (aktiv wenn die Checkbox oben an ist).',
|
||||
randomMixBlacklistPlaceholder: 'Keyword hinzufügen…',
|
||||
@@ -755,7 +765,7 @@ export const deTranslation = {
|
||||
sidebarDrag: 'Ziehen zum Umsortieren',
|
||||
sidebarFixed: 'Immer sichtbar',
|
||||
randomNavSplitTitle: 'Mix-Navigation aufteilen',
|
||||
randomNavSplitDesc: '"Zufallsmix" und "Zufallsalben" als separate Sidebar-Einträge statt als "Mix erstellen"-Hub anzeigen.',
|
||||
randomNavSplitDesc: '"Zufallsmix", "Zufallsalben" und "Glück" als separate Sidebar-Einträge statt als "Mix erstellen"-Hub anzeigen.',
|
||||
tabInput: 'Eingabe',
|
||||
tabUsers: 'Benutzer',
|
||||
shortcutsReset: 'Auf Standard zurücksetzen',
|
||||
|
||||
+11
-1
@@ -32,6 +32,7 @@ export const enTranslation = {
|
||||
expandPlaylists: 'Expand playlists',
|
||||
collapsePlaylists: 'Collapse playlists',
|
||||
more: 'More',
|
||||
feelingLucky: 'Lucky Mix',
|
||||
},
|
||||
home: {
|
||||
hero: 'Featured',
|
||||
@@ -284,6 +285,8 @@ export const enTranslation = {
|
||||
mixByTracksDesc: 'Random selection of tracks from your entire library',
|
||||
mixByAlbums: 'Mix by Albums',
|
||||
mixByAlbumsDesc: 'Random album picks for your next discovery',
|
||||
mixByLucky: 'Lucky Mix',
|
||||
mixByLuckyDesc: 'Smart instant mix from your top artists, albums, and ratings',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Random Albums',
|
||||
@@ -336,6 +339,11 @@ export const enTranslation = {
|
||||
filterPanelDesc: 'Click a genre tag or artist name in the tracklist below to block it from future mixes.',
|
||||
genreClickHint: 'Click a genre tag to add it\nas a filter keyword.\nMatches genre, title, album & artist.',
|
||||
},
|
||||
luckyMix: {
|
||||
done: 'Lucky mix ready: {{count}} tracks',
|
||||
failed: 'Could not build lucky mix. Try again.',
|
||||
unavailable: 'Lucky Mix is unavailable for this server.',
|
||||
},
|
||||
albums: {
|
||||
title: 'All Albums',
|
||||
sortByName: 'A–Z (Album)',
|
||||
@@ -722,6 +730,8 @@ export const enTranslation = {
|
||||
showChangelogOnUpdate: "Show 'What's New' on update",
|
||||
showChangelogOnUpdateDesc: "Show a discreet changelog banner above Now Playing after an update. Click opens the release notes; X dismisses it.",
|
||||
randomMixTitle: 'Random Mix Blacklist',
|
||||
luckyMixMenuTitle: 'Show Lucky Mix in menu',
|
||||
luckyMixMenuDesc: 'Enables Lucky Mix in Build a Mix and as a separate menu item when split navigation is on. Visible only when AudioMuse is enabled on the active server.',
|
||||
randomMixBlacklistTitle: 'Custom Filter Keywords',
|
||||
randomMixBlacklistDesc: 'Songs are excluded when any keyword matches their genre, title, album, or artist (active when the checkbox above is on).',
|
||||
randomMixBlacklistPlaceholder: 'Add keyword…',
|
||||
@@ -757,7 +767,7 @@ export const enTranslation = {
|
||||
sidebarDrag: 'Drag to reorder',
|
||||
sidebarFixed: 'Always visible',
|
||||
randomNavSplitTitle: 'Split Mix navigation',
|
||||
randomNavSplitDesc: 'Show "Random Mix" and "Random Albums" as separate sidebar entries instead of the "Build a Mix" hub.',
|
||||
randomNavSplitDesc: 'Show "Random Mix", "Random Albums", and "Lucky Mix" as separate sidebar entries instead of the "Build a Mix" hub.',
|
||||
tabInput: 'Input',
|
||||
tabUsers: 'Users',
|
||||
tabSystem: 'System',
|
||||
|
||||
+11
-1
@@ -32,6 +32,7 @@ export const esTranslation = {
|
||||
expandPlaylists: 'Expandir listas',
|
||||
collapsePlaylists: 'Colapsar listas',
|
||||
more: 'Más',
|
||||
feelingLucky: 'Suerte',
|
||||
},
|
||||
home: {
|
||||
hero: 'Destacado',
|
||||
@@ -284,6 +285,8 @@ export const esTranslation = {
|
||||
mixByTracksDesc: 'Selección aleatoria de canciones de toda tu biblioteca',
|
||||
mixByAlbums: 'Mezcla por Álbumes',
|
||||
mixByAlbumsDesc: 'Álbumes aleatorios para tu próximo descubrimiento',
|
||||
mixByLucky: 'Suerte',
|
||||
mixByLuckyDesc: 'Instant Mix inteligente con artistas, álbumes y valoraciones destacadas',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Álbumes Aleatorios',
|
||||
@@ -336,6 +339,11 @@ export const esTranslation = {
|
||||
filterPanelDesc: 'Click en una etiqueta de género o nombre de artista en la lista para bloquearlo de futuras mezclas.',
|
||||
genreClickHint: 'Click en una etiqueta de género para agregarla\ncomo palabra clave filtrada.\nBusca en género, título, álbum y artista.',
|
||||
},
|
||||
luckyMix: {
|
||||
done: 'Mezcla lista: {{count}} canciones',
|
||||
failed: 'No se pudo crear la mezcla. Inténtalo de nuevo.',
|
||||
unavailable: 'Suerte no está disponible para este servidor.',
|
||||
},
|
||||
albums: {
|
||||
title: 'Todos los Álbumes',
|
||||
sortByName: 'A–Z (Álbum)',
|
||||
@@ -712,6 +720,8 @@ export const esTranslation = {
|
||||
showChangelogOnUpdate: "Mostrar 'Novedades' al actualizar",
|
||||
showChangelogOnUpdateDesc: 'Muestra un discreto banner de changelog encima de Now Playing tras una actualización. Clic abre las notas de la versión; X lo oculta.',
|
||||
randomMixTitle: 'Lista negra de Mezcla Aleatoria',
|
||||
luckyMixMenuTitle: 'Mostrar Suerte en el menú',
|
||||
luckyMixMenuDesc: 'Activa Suerte en "Crear Mezcla" y como elemento de menú separado cuando la navegación dividida está activa. Solo visible cuando AudioMuse está activo en el servidor actual.',
|
||||
randomMixBlacklistTitle: 'Palabras Clave de Filtro Personalizadas',
|
||||
randomMixBlacklistDesc: 'Las canciones se excluyen cuando cualquier palabra clave coincide con su género, título, álbum o artista (activo cuando el checkbox de arriba está activado).',
|
||||
randomMixBlacklistPlaceholder: 'Agregar palabra clave…',
|
||||
@@ -748,7 +758,7 @@ export const esTranslation = {
|
||||
sidebarDrag: 'Arrastra para reordenar',
|
||||
sidebarFixed: 'Siempre visible',
|
||||
randomNavSplitTitle: 'Dividir navegación Mix',
|
||||
randomNavSplitDesc: 'Mostrar "Mezcla Aleatoria" y "Álbumes Aleatorios" como entradas separadas en la barra lateral en lugar del hub "Crear Mezcla".',
|
||||
randomNavSplitDesc: 'Mostrar "Mezcla Aleatoria", "Álbumes Aleatorios" y "Suerte" como entradas separadas en la barra lateral en lugar del hub "Crear Mezcla".',
|
||||
tabInput: 'Entrada',
|
||||
tabUsers: 'Usuarios',
|
||||
tabSystem: 'Sistema',
|
||||
|
||||
+11
-1
@@ -31,6 +31,7 @@ export const frTranslation = {
|
||||
expandPlaylists: 'Développer les playlists',
|
||||
collapsePlaylists: 'Réduire les playlists',
|
||||
more: 'Plus',
|
||||
feelingLucky: 'Chance',
|
||||
},
|
||||
home: {
|
||||
hero: 'En vedette',
|
||||
@@ -283,6 +284,8 @@ export const frTranslation = {
|
||||
mixByTracksDesc: 'Sélection aléatoire de titres depuis toute votre médiathèque',
|
||||
mixByAlbums: 'Mix par albums',
|
||||
mixByAlbumsDesc: 'Albums aléatoires pour vos prochaines découvertes',
|
||||
mixByLucky: 'Chance',
|
||||
mixByLuckyDesc: 'Instant Mix intelligent basé sur artistes, albums et notes élevées',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Albums aléatoires',
|
||||
@@ -335,6 +338,11 @@ export const frTranslation = {
|
||||
filterPanelDesc: 'Cliquez sur un tag de genre ou un nom d\'artiste dans la liste pour l\'exclure des futurs mix.',
|
||||
genreClickHint: 'Cliquez sur un tag de genre pour l\'ajouter\ncomme mot-clé de filtre.\nCorrespond au genre, titre, album et artiste.',
|
||||
},
|
||||
luckyMix: {
|
||||
done: 'Mix prêt : {{count}} titres',
|
||||
failed: 'Impossible de créer le mix. Réessayez.',
|
||||
unavailable: 'Chance n\'est pas disponible pour ce serveur.',
|
||||
},
|
||||
albums: {
|
||||
title: 'Tous les albums',
|
||||
sortByName: 'A–Z (Album)',
|
||||
@@ -707,6 +715,8 @@ export const frTranslation = {
|
||||
showChangelogOnUpdate: "Afficher 'Quoi de neuf' lors des mises à jour",
|
||||
showChangelogOnUpdateDesc: "Affiche une bannière discrète du changelog au-dessus de Now Playing après une mise à jour. Un clic ouvre les notes de version, le X la masque.",
|
||||
randomMixTitle: 'Liste noire du mix aléatoire',
|
||||
luckyMixMenuTitle: 'Afficher Chance dans le menu',
|
||||
luckyMixMenuDesc: 'Active Chance dans "Créer un mix" et comme entrée séparée quand la navigation est scindée. Visible uniquement si AudioMuse est actif sur le serveur courant.',
|
||||
randomMixBlacklistTitle: 'Mots-clés de filtre personnalisés',
|
||||
randomMixBlacklistDesc: 'Les morceaux sont exclus si un mot-clé correspond à leur genre, titre, album ou artiste (actif quand la case ci-dessus est cochée).',
|
||||
randomMixBlacklistPlaceholder: 'Ajouter un mot-clé…',
|
||||
@@ -743,7 +753,7 @@ export const frTranslation = {
|
||||
sidebarDrag: 'Glisser pour réorganiser',
|
||||
sidebarFixed: 'Toujours visible',
|
||||
randomNavSplitTitle: 'Diviser la navigation Mix',
|
||||
randomNavSplitDesc: 'Afficher "Mix Aléatoire" et "Albums Aléatoires" comme entrées séparées dans la barre latérale plutôt que le hub "Créer un Mix".',
|
||||
randomNavSplitDesc: 'Afficher "Mix Aléatoire", "Albums Aléatoires" et "Chance" comme entrées séparées dans la barre latérale plutôt que le hub "Créer un Mix".',
|
||||
tabInput: 'Entrée',
|
||||
tabUsers: 'Utilisateurs',
|
||||
shortcutsReset: 'Réinitialiser',
|
||||
|
||||
+11
-1
@@ -31,6 +31,7 @@ export const nbTranslation = {
|
||||
expandPlaylists: 'Utvid spillelister',
|
||||
collapsePlaylists: 'Skjul spillelister',
|
||||
more: 'Mer',
|
||||
feelingLucky: 'Lykke',
|
||||
},
|
||||
home: {
|
||||
hero: 'Utvalgt',
|
||||
@@ -283,6 +284,8 @@ export const nbTranslation = {
|
||||
mixByTracksDesc: 'Tilfeldig utvalg av spor fra hele biblioteket ditt',
|
||||
mixByAlbums: 'Miks etter album',
|
||||
mixByAlbumsDesc: 'Tilfeldige album for nye oppdagelser',
|
||||
mixByLucky: 'Lykke',
|
||||
mixByLuckyDesc: 'Smart Instant Mix fra toppartister, album og gode vurderinger',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Tilfeldige album',
|
||||
@@ -335,6 +338,11 @@ export const nbTranslation = {
|
||||
filterPanelDesc: 'Klikk på en sjanger-tag eller på artistnavnet i sporlisten nedenfor, for å blokkere den fra fremtidige mikser.',
|
||||
genreClickHint: 'Klikk på en sjanger-tag for å legge den til\nsom et filternøkkelord.\nSamsvarer med sjanger, tittel, album og artist.',
|
||||
},
|
||||
luckyMix: {
|
||||
done: 'Miksen er klar: {{count}} spor',
|
||||
failed: 'Kunne ikke lage miksen. Prøv igjen.',
|
||||
unavailable: 'Lykke er ikke tilgjengelig for denne serveren.',
|
||||
},
|
||||
albums: {
|
||||
title: 'Alle album',
|
||||
sortByName: 'A–Å (Album)',
|
||||
@@ -706,6 +714,8 @@ export const nbTranslation = {
|
||||
showChangelogOnUpdate: "Vis 'Hva er nytt' ved oppdatering til ny versjon",
|
||||
showChangelogOnUpdateDesc: "Viser et diskret changelog-banner over Now Playing etter en oppdatering. Klikk åpner versjonsnotatene; X skjuler det.",
|
||||
randomMixTitle: 'Svarteliste for tilfeldig miks',
|
||||
luckyMixMenuTitle: 'Vis Lykke i menyen',
|
||||
luckyMixMenuDesc: 'Aktiverer Lykke i "Lag en miks" og som eget menypunkt når delt navigasjon er aktiv. Vises bare når AudioMuse er aktiv på gjeldende server.',
|
||||
randomMixBlacklistTitle: 'Egendefinerte filternøkkelord',
|
||||
randomMixBlacklistDesc: 'Sanger ekskluderes når et nøkkelord samsvarer med sjanger, tittel, album eller artist (aktiv når avkrysningsboksen ovenfor er på).',
|
||||
randomMixBlacklistPlaceholder: 'Legg til nøkkelord…',
|
||||
@@ -742,7 +752,7 @@ export const nbTranslation = {
|
||||
sidebarDrag: 'Dra for å endre rekkefølge',
|
||||
sidebarFixed: 'Alltid synlig',
|
||||
randomNavSplitTitle: 'Del Mix-navigasjon',
|
||||
randomNavSplitDesc: 'Vis "Tilfeldig miks" og "Tilfeldige album" som separate sidefeltsoppføringer i stedet for "Lag en miks"-huben.',
|
||||
randomNavSplitDesc: 'Vis "Tilfeldig miks", "Tilfeldige album" og "Lykke" som separate sidefeltsoppføringer i stedet for "Lag en miks"-huben.',
|
||||
tabShortcuts: 'Snarveier',
|
||||
tabUsers: 'Brukere',
|
||||
tabSystem: 'System',
|
||||
|
||||
+11
-1
@@ -31,6 +31,7 @@ export const nlTranslation = {
|
||||
expandPlaylists: 'Afspeellijsten uitklappen',
|
||||
collapsePlaylists: 'Afspeellijsten inklappen',
|
||||
more: 'Meer',
|
||||
feelingLucky: 'Geluk',
|
||||
},
|
||||
home: {
|
||||
hero: 'Uitgelicht',
|
||||
@@ -282,6 +283,8 @@ export const nlTranslation = {
|
||||
mixByTracksDesc: 'Willekeurige selectie uit je volledige mediatheek',
|
||||
mixByAlbums: 'Mix op albums',
|
||||
mixByAlbumsDesc: 'Willekeurige albums voor nieuwe ontdekkingen',
|
||||
mixByLucky: 'Geluk',
|
||||
mixByLuckyDesc: 'Slimme Instant Mix op basis van topartiesten, albums en hoge beoordelingen',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Willekeurige albums',
|
||||
@@ -334,6 +337,11 @@ export const nlTranslation = {
|
||||
filterPanelDesc: 'Klik op een genre-tag of artiestennaam in de lijst om deze uit toekomstige mixes te weren.',
|
||||
genreClickHint: 'Klik op een genre-tag om het\ntoe te voegen als filtertrefwoord.\nVergelijkt genre, titel, album & artiest.',
|
||||
},
|
||||
luckyMix: {
|
||||
done: 'Mix klaar: {{count}} nummers',
|
||||
failed: 'Kon de mix niet maken. Probeer opnieuw.',
|
||||
unavailable: 'Geluk is niet beschikbaar voor deze server.',
|
||||
},
|
||||
albums: {
|
||||
title: 'Alle albums',
|
||||
sortByName: 'A–Z (Album)',
|
||||
@@ -706,6 +714,8 @@ export const nlTranslation = {
|
||||
showChangelogOnUpdate: "'Wat is nieuw' tonen bij update",
|
||||
showChangelogOnUpdateDesc: 'Toont een discrete changelog-banner boven Now Playing na een update. Klik opent de release-notities; X verbergt hem.',
|
||||
randomMixTitle: 'Willekeurige mix-blacklist',
|
||||
luckyMixMenuTitle: 'Toon Geluk in menu',
|
||||
luckyMixMenuDesc: 'Schakelt Geluk in bij "Mix samenstellen" en als apart menu-item bij gesplitste navigatie. Alleen zichtbaar wanneer AudioMuse actief is op de huidige server.',
|
||||
randomMixBlacklistTitle: 'Aangepaste filtertrefwoorden',
|
||||
randomMixBlacklistDesc: 'Nummers worden uitgesloten als een trefwoord overeenkomt met hun genre, titel, album of artiest (actief wanneer het selectievakje hierboven is aangevinkt).',
|
||||
randomMixBlacklistPlaceholder: 'Trefwoord toevoegen…',
|
||||
@@ -742,7 +752,7 @@ export const nlTranslation = {
|
||||
sidebarDrag: 'Slepen om te herordenen',
|
||||
sidebarFixed: 'Altijd zichtbaar',
|
||||
randomNavSplitTitle: 'Mix-navigatie splitsen',
|
||||
randomNavSplitDesc: 'Toon "Willekeurige mix" en "Willekeurige albums" als afzonderlijke zijbalkitems in plaats van de "Mix samenstellen"-hub.',
|
||||
randomNavSplitDesc: 'Toon "Willekeurige mix", "Willekeurige albums" en "Geluk" als afzonderlijke zijbalkitems in plaats van de "Mix samenstellen"-hub.',
|
||||
tabInput: 'Invoer',
|
||||
tabUsers: 'Gebruikers',
|
||||
shortcutsReset: 'Standaard herstellen',
|
||||
|
||||
+13
-1
@@ -32,6 +32,7 @@ export const ruTranslation = {
|
||||
expandPlaylists: 'Развернуть плейлисты',
|
||||
collapsePlaylists: 'Свернуть плейлисты',
|
||||
more: 'Ещё',
|
||||
feelingLucky: 'Мне повезёт',
|
||||
},
|
||||
home: {
|
||||
hero: 'Подборка',
|
||||
@@ -297,6 +298,8 @@ export const ruTranslation = {
|
||||
mixByTracksDesc: 'Случайная подборка треков со всей медиатеки',
|
||||
mixByAlbums: 'Микс по альбомам',
|
||||
mixByAlbumsDesc: 'Случайная подборка альбомов для открытий',
|
||||
mixByLucky: 'Мне повезёт',
|
||||
mixByLuckyDesc: 'Умный Instant Mix из топ-артистов, альбомов и высоких оценок',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Случайные альбомы',
|
||||
@@ -354,6 +357,11 @@ export const ruTranslation = {
|
||||
genreClickHint:
|
||||
'Нажмите на тег жанра — слово попадёт в фильтр.\nУчитываются жанр, название, альбом и исполнитель.',
|
||||
},
|
||||
luckyMix: {
|
||||
done: '«Мне повезёт» готово: {{count}} треков',
|
||||
failed: 'Не удалось собрать микс. Попробуйте ещё раз.',
|
||||
unavailable: '«Мне повезёт» недоступен на этом сервере.',
|
||||
},
|
||||
albums: {
|
||||
title: 'Все альбомы',
|
||||
sortByName: 'А–Я (альбом)',
|
||||
@@ -745,6 +753,9 @@ export const ruTranslation = {
|
||||
showChangelogOnUpdate: 'Показывать «Что нового» после обновления',
|
||||
showChangelogOnUpdateDesc: 'После обновления над «Сейчас играет» появится ненавязчивый баннер журнала изменений. Клик открывает заметки о выпуске, X скрывает.',
|
||||
randomMixTitle: 'Чёрный список случайного микса',
|
||||
luckyMixMenuTitle: 'Показывать «Мне повезёт» в меню',
|
||||
luckyMixMenuDesc:
|
||||
'Включает «Мне повезёт» в «Собрать микс», а при раздельной навигации — отдельным пунктом меню. Видно только при включённом AudioMuse на активном сервере.',
|
||||
randomMixBlacklistTitle: 'Свои слова-фильтры',
|
||||
randomMixBlacklistDesc:
|
||||
'Треки скрываются, если слово встречается в жанре, названии, альбоме или исполнителе (когда включён фильтр выше).',
|
||||
@@ -784,7 +795,8 @@ export const ruTranslation = {
|
||||
sidebarDrag: 'Перетащите для порядка',
|
||||
sidebarFixed: 'Всегда показывать',
|
||||
randomNavSplitTitle: 'Разделить навигацию микса',
|
||||
randomNavSplitDesc: 'Показывать «Случайный микс» и «Случайные альбомы» как отдельные пункты боковой панели вместо хаба «Собрать микс».',
|
||||
randomNavSplitDesc:
|
||||
'Показывать «Случайный микс», «Случайные альбомы» и «Мне повезёт» как отдельные пункты боковой панели вместо хаба «Собрать микс».',
|
||||
tabInput: 'Ввод',
|
||||
tabUsers: 'Пользователи',
|
||||
tabSystem: 'Система',
|
||||
|
||||
+11
-1
@@ -31,6 +31,7 @@ export const zhTranslation = {
|
||||
expandPlaylists: '展开播放列表',
|
||||
collapsePlaylists: '收起播放列表',
|
||||
more: '更多',
|
||||
feelingLucky: '好运',
|
||||
},
|
||||
home: {
|
||||
hero: '精选',
|
||||
@@ -281,6 +282,8 @@ export const zhTranslation = {
|
||||
mixByTracksDesc: '从整个媒体库随机选取曲目',
|
||||
mixByAlbums: '按专辑混音',
|
||||
mixByAlbumsDesc: '随机选取专辑,探索新音乐',
|
||||
mixByLucky: '好运',
|
||||
mixByLuckyDesc: '基于高频艺人、专辑和高评分歌曲的智能 Instant Mix',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: '随机专辑',
|
||||
@@ -333,6 +336,11 @@ export const zhTranslation = {
|
||||
filterPanelDesc: '点击下方列表中的流派标签或艺术家名称,将其从未来的混音中排除。',
|
||||
genreClickHint: '点击流派标签将其添加为过滤关键词。\\n匹配流派、标题、专辑和艺术家。',
|
||||
},
|
||||
luckyMix: {
|
||||
done: '幸运混音已就绪:{{count}} 首',
|
||||
failed: '生成混音失败,请重试。',
|
||||
unavailable: '此服务器不可用“好运”。',
|
||||
},
|
||||
albums: {
|
||||
title: '全部专辑',
|
||||
sortByName: '按名称排序 (A-Z)',
|
||||
@@ -701,6 +709,8 @@ export const zhTranslation = {
|
||||
showChangelogOnUpdate: '更新时显示"新功能"',
|
||||
showChangelogOnUpdateDesc: '更新后在「正在播放」上方显示一个低调的更新日志横幅。点击打开发行说明,X 按钮关闭。',
|
||||
randomMixTitle: '随机混音黑名单',
|
||||
luckyMixMenuTitle: '在菜单中显示“好运”',
|
||||
luckyMixMenuDesc: '在“创建混音”中启用“好运”,并在分离导航时作为独立菜单项显示。仅当当前服务器启用 AudioMuse 时可见。',
|
||||
randomMixBlacklistTitle: '自定义过滤关键词',
|
||||
randomMixBlacklistDesc: '当任何关键词匹配流派、标题、专辑或艺术家时,歌曲将被排除(当上方复选框开启时生效)。',
|
||||
randomMixBlacklistPlaceholder: '添加关键词…',
|
||||
@@ -737,7 +747,7 @@ export const zhTranslation = {
|
||||
sidebarDrag: '拖动以重新排序',
|
||||
sidebarFixed: '始终显示',
|
||||
randomNavSplitTitle: '拆分混音导航',
|
||||
randomNavSplitDesc: '在侧边栏中将"随机混音"和"随机专辑"显示为独立条目,而非"创建混音"合并入口。',
|
||||
randomNavSplitDesc: '在侧边栏中将“随机混音”、“随机专辑”和“好运”显示为独立条目,而非“创建混音”合并入口。',
|
||||
tabInput: '输入',
|
||||
tabUsers: '用户',
|
||||
tabSystem: '系统',
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { buildAndPlayLuckyMix } from '../utils/luckyMix';
|
||||
|
||||
export default function LuckyMixPage() {
|
||||
const navigate = useNavigate();
|
||||
const startedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (startedRef.current) return;
|
||||
startedRef.current = true;
|
||||
void buildAndPlayLuckyMix();
|
||||
navigate('/now-playing', { replace: true });
|
||||
}, [navigate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Shuffle, Dices } from 'lucide-react';
|
||||
import { Shuffle, Dices, Sparkles } from 'lucide-react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
interface MixCard {
|
||||
icon: React.ElementType;
|
||||
@@ -28,11 +29,26 @@ const CARDS: MixCard[] = [
|
||||
export default function RandomLanding() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu);
|
||||
const audiomuseByServer = useAuthStore(s => s.audiomuseNavidromeByServer);
|
||||
const luckyMixAvailable = showLuckyMixMenu && Boolean(activeServerId && audiomuseByServer[activeServerId]);
|
||||
const cards = luckyMixAvailable
|
||||
? [
|
||||
...CARDS,
|
||||
{
|
||||
icon: Sparkles,
|
||||
labelKey: 'randomLanding.mixByLucky',
|
||||
descKey: 'randomLanding.mixByLuckyDesc',
|
||||
to: '/lucky-mix',
|
||||
},
|
||||
]
|
||||
: CARDS;
|
||||
|
||||
return (
|
||||
<div className="random-landing">
|
||||
<div className="random-landing-grid">
|
||||
{CARDS.map(({ icon: Icon, labelKey, descKey, to }) => (
|
||||
{cards.map(({ icon: Icon, labelKey, descKey, to }) => (
|
||||
<button
|
||||
key={to}
|
||||
className="mix-pick-card"
|
||||
|
||||
+25
-1
@@ -2658,6 +2658,25 @@ export default function Settings() {
|
||||
|
||||
<div className="divider" style={{ margin: '1rem 0' }} />
|
||||
|
||||
<div className="settings-toggle-row" style={{ marginBottom: '1rem' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.luckyMixMenuTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.luckyMixMenuDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.luckyMixMenuTitle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.showLuckyMixMenu}
|
||||
onChange={e => auth.setShowLuckyMixMenu(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="divider" style={{ margin: '1rem 0' }} />
|
||||
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||
{AUDIOBOOK_GENRES_DISPLAY.map(genre => (
|
||||
@@ -4394,11 +4413,16 @@ function SidebarCustomizer() {
|
||||
itemsRef.current = items;
|
||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||
const setRandomNavMode = useAuthStore(s => s.setRandomNavMode);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const audiomuseByServer = useAuthStore(s => s.audiomuseNavidromeByServer);
|
||||
const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu);
|
||||
const luckyMixAvailable = randomNavMode === 'separate' && showLuckyMixMenu && Boolean(activeServerId && audiomuseByServer[activeServerId]);
|
||||
|
||||
const libraryItems = items.filter(cfg => {
|
||||
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false;
|
||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
|
||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums' || cfg.id === 'luckyMix')) return false;
|
||||
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
||||
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
|
||||
return true;
|
||||
});
|
||||
const systemItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system');
|
||||
|
||||
@@ -138,6 +138,8 @@ interface AuthState {
|
||||
mixMinRatingAlbum: number;
|
||||
/** 0 = ignore; artist rating from payload / nested OpenSubsonic fields or `getArtist`. */
|
||||
mixMinRatingArtist: number;
|
||||
/** Show "Lucky Mix" as a regular sidebar/menu item. */
|
||||
showLuckyMixMenu: boolean;
|
||||
|
||||
/** Subsonic music folders for the active server (not persisted; refetched on login / server change). */
|
||||
musicFolders: Array<{ id: string; name: string }>;
|
||||
@@ -251,6 +253,7 @@ interface AuthState {
|
||||
setMixMinRatingSong: (v: number) => void;
|
||||
setMixMinRatingAlbum: (v: number) => void;
|
||||
setMixMinRatingArtist: (v: number) => void;
|
||||
setShowLuckyMixMenu: (v: boolean) => void;
|
||||
setMusicFolders: (folders: Array<{ id: string; name: string }>) => void;
|
||||
setMusicLibraryFilter: (folderId: 'all' | string) => void;
|
||||
|
||||
@@ -361,6 +364,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
mixMinRatingSong: 0,
|
||||
mixMinRatingAlbum: 0,
|
||||
mixMinRatingArtist: 0,
|
||||
showLuckyMixMenu: true,
|
||||
randomNavMode: 'hub',
|
||||
musicFolders: [],
|
||||
musicLibraryFilterByServer: {},
|
||||
@@ -528,6 +532,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }),
|
||||
setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }),
|
||||
setMixMinRatingArtist: (v) => set({ mixMinRatingArtist: clampMixFilterMinStars(v) }),
|
||||
setShowLuckyMixMenu: (v) => set({ showLuckyMixMenu: v }),
|
||||
setRandomNavMode: (v) => set({ randomNavMode: v }),
|
||||
|
||||
setMusicFolders: (folders) => {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface LuckyMixState {
|
||||
isRolling: boolean;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
export const useLuckyMixStore = create<LuckyMixState>((set) => ({
|
||||
isRolling: false,
|
||||
start: () => set({ isRolling: true }),
|
||||
stop: () => set({ isRolling: false }),
|
||||
}));
|
||||
@@ -197,6 +197,8 @@ interface PlayerState {
|
||||
enqueueAt: (tracks: Track[], insertIndex: number) => void;
|
||||
enqueueRadio: (tracks: Track[], artistId?: string) => void;
|
||||
setRadioArtistId: (artistId: string) => void;
|
||||
/** For Lucky Mix: drop upcoming tail; keep the currently playing item only. */
|
||||
pruneUpcomingToCurrent: () => void;
|
||||
clearQueue: () => void;
|
||||
|
||||
isQueueVisible: boolean;
|
||||
@@ -1362,6 +1364,25 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
if (!wasPlaying) get().resume();
|
||||
},
|
||||
|
||||
pruneUpcomingToCurrent: () => {
|
||||
const s = get();
|
||||
if (s.currentRadio) return;
|
||||
if (!s.currentTrack) {
|
||||
if (s.queue.length === 0) return;
|
||||
set({ queue: [], queueIndex: 0 });
|
||||
syncQueueToServer([], null, 0);
|
||||
return;
|
||||
}
|
||||
const at = s.queue.findIndex(t => t.id === s.currentTrack!.id);
|
||||
const newQueue: Track[] =
|
||||
at >= 0
|
||||
? s.queue.slice(0, at + 1)
|
||||
: [s.currentTrack!];
|
||||
const newIndex = at >= 0 ? at : 0;
|
||||
set({ queue: newQueue, queueIndex: newIndex });
|
||||
syncQueueToServer(newQueue, s.currentTrack, s.currentTime);
|
||||
},
|
||||
|
||||
// ── pause / resume / togglePlay ──────────────────────────────────────────
|
||||
pause: () => {
|
||||
clearAllPlaybackScheduleTimers();
|
||||
|
||||
@@ -15,6 +15,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
|
||||
{ id: 'randomPicker', visible: true },
|
||||
{ id: 'randomMix', visible: true },
|
||||
{ id: 'randomAlbums', visible: true },
|
||||
{ id: 'luckyMix', visible: true },
|
||||
{ id: 'artists', visible: true },
|
||||
{ id: 'genres', visible: true },
|
||||
{ id: 'favorites', visible: true },
|
||||
|
||||
@@ -2427,6 +2427,197 @@
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
}
|
||||
|
||||
/* ─ Lucky Mix overlay (big rolling dice) ─ */
|
||||
.lucky-mix-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10050;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background: color-mix(in srgb, var(--ctp-mantle) 84%, transparent);
|
||||
backdrop-filter: blur(5px);
|
||||
-webkit-backdrop-filter: blur(5px);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.lucky-mix-overlay__dice {
|
||||
position: relative;
|
||||
width: 240px;
|
||||
height: 190px;
|
||||
}
|
||||
|
||||
.lucky-mix-cube {
|
||||
position: absolute;
|
||||
width: 86px;
|
||||
height: 86px;
|
||||
border-radius: 18px;
|
||||
border: 2px solid color-mix(in srgb, #fff 70%, var(--accent) 30%);
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
color-mix(in srgb, var(--accent) 72%, #fff 28%) 0%,
|
||||
color-mix(in srgb, var(--accent) 84%, #000 16%) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 12px 28px rgba(0, 0, 0, 0.4),
|
||||
inset -8px -10px 18px rgba(0, 0, 0, 0.2),
|
||||
inset 6px 8px 16px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.lucky-mix-cube--a {
|
||||
left: 10px;
|
||||
top: 74px;
|
||||
transform: rotate(-12deg);
|
||||
animation: luckyCubeA 980ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
.lucky-mix-cube--b {
|
||||
left: 76px;
|
||||
top: 18px;
|
||||
width: 98px;
|
||||
height: 98px;
|
||||
transform: rotate(9deg);
|
||||
animation: luckyCubeB 900ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
.lucky-mix-cube--c {
|
||||
left: 152px;
|
||||
top: 82px;
|
||||
transform: rotate(15deg);
|
||||
animation: luckyCubeC 1120ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
.lucky-mix-pip {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.lucky-mix-pip--tl { top: 16px; left: 16px; }
|
||||
.lucky-mix-pip--tr { top: 16px; right: 16px; }
|
||||
.lucky-mix-pip--bl { bottom: 16px; left: 16px; }
|
||||
.lucky-mix-pip--br { bottom: 16px; right: 16px; }
|
||||
.lucky-mix-pip--center {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.lucky-mix-overlay__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(30px, 4.5vw, 52px);
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
letter-spacing: 0.01em;
|
||||
text-shadow: 0 10px 32px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.lucky-mix-overlay__phase {
|
||||
font-size: clamp(14px, 2vw, 18px);
|
||||
color: color-mix(in srgb, #fff 78%, var(--accent) 22%);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@keyframes luckyCubeA {
|
||||
0% { transform: translate(0, 0) rotate(0deg) scale(1); }
|
||||
50% { transform: translate(-7px, -14px) rotate(-20deg) scale(1.06); }
|
||||
100% { transform: translate(0, 0) rotate(0deg) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes luckyCubeB {
|
||||
0% { transform: translate(0, 0) rotate(0deg) scale(1.02); }
|
||||
50% { transform: translate(6px, -18px) rotate(13deg) scale(1.09); }
|
||||
100% { transform: translate(0, 0) rotate(0deg) scale(1.02); }
|
||||
}
|
||||
|
||||
@keyframes luckyCubeC {
|
||||
0% { transform: translate(0, 0) rotate(0deg) scale(1); }
|
||||
50% { transform: translate(8px, -12px) rotate(17deg) scale(1.04); }
|
||||
100% { transform: translate(0, 0) rotate(0deg) scale(1); }
|
||||
}
|
||||
|
||||
.queue-lucky-loading {
|
||||
margin: 2px 10px 4px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.queue-lucky-loading__dice {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 30px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.queue-lucky-cube {
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid color-mix(in srgb, #fff 65%, var(--accent) 35%);
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
color-mix(in srgb, var(--accent) 66%, #fff 34%) 0%,
|
||||
color-mix(in srgb, var(--accent) 84%, #000 16%) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 4px 9px rgba(0, 0, 0, 0.28),
|
||||
inset -2px -3px 5px rgba(0, 0, 0, 0.18),
|
||||
inset 2px 2px 4px rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.queue-lucky-cube .lucky-mix-pip {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.queue-lucky-cube .lucky-mix-pip--tl { top: 4px; left: 4px; }
|
||||
.queue-lucky-cube .lucky-mix-pip--tr { top: 4px; right: 4px; }
|
||||
.queue-lucky-cube .lucky-mix-pip--bl { bottom: 4px; left: 4px; }
|
||||
.queue-lucky-cube .lucky-mix-pip--br { bottom: 4px; right: 4px; }
|
||||
|
||||
.queue-lucky-cube--a {
|
||||
left: 0;
|
||||
top: 11px;
|
||||
animation: queueLuckyCubeA 760ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
.queue-lucky-cube--b {
|
||||
left: 18px;
|
||||
top: 3px;
|
||||
animation: queueLuckyCubeB 690ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
.queue-lucky-cube--c {
|
||||
left: 36px;
|
||||
top: 11px;
|
||||
animation: queueLuckyCubeC 830ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes queueLuckyCubeA {
|
||||
0% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-6px) rotate(-10deg); }
|
||||
100% { transform: translateY(0) rotate(0deg); }
|
||||
}
|
||||
|
||||
@keyframes queueLuckyCubeB {
|
||||
0% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-8px) rotate(10deg); }
|
||||
100% { transform: translateY(0) rotate(0deg); }
|
||||
}
|
||||
|
||||
@keyframes queueLuckyCubeC {
|
||||
0% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-5px) rotate(12deg); }
|
||||
100% { transform: translateY(0) rotate(0deg); }
|
||||
}
|
||||
|
||||
/* ─ Playlist edit modal ─ */
|
||||
.playlist-edit-modal {
|
||||
max-width: 560px;
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import {
|
||||
filterSongsToActiveLibrary,
|
||||
getAlbum,
|
||||
getAlbumList,
|
||||
getRandomSongs,
|
||||
getSimilarSongs,
|
||||
getTopSongs,
|
||||
type SubsonicAlbum,
|
||||
type SubsonicSong,
|
||||
} from '../api/subsonic';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import i18n from '../i18n';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { songToTrack, usePlayerStore } from '../store/playerStore';
|
||||
import { useLuckyMixStore } from '../store/luckyMixStore';
|
||||
import { showToast } from './toast';
|
||||
|
||||
interface TopArtist {
|
||||
id: string;
|
||||
name: string;
|
||||
totalPlays: number;
|
||||
}
|
||||
|
||||
const MOST_PLAYED_PAGE_SIZE = 100;
|
||||
const MOST_PLAYED_MAX_ALBUMS = 500;
|
||||
const MIX_TARGET_SIZE = 50;
|
||||
const SEED_TARGET_SIZE = 15;
|
||||
|
||||
function sampleRandom<T>(items: T[], count: number): T[] {
|
||||
if (count <= 0 || items.length === 0) return [];
|
||||
const arr = [...items];
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr.slice(0, Math.min(count, arr.length));
|
||||
}
|
||||
|
||||
function uniqueBySongId(items: SubsonicSong[]): SubsonicSong[] {
|
||||
const out: SubsonicSong[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const s of items) {
|
||||
if (!s?.id || seen.has(s.id)) continue;
|
||||
seen.add(s.id);
|
||||
out.push(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function uniqueAppend(base: SubsonicSong[], incoming: SubsonicSong[]): SubsonicSong[] {
|
||||
return uniqueBySongId([...base, ...incoming]);
|
||||
}
|
||||
|
||||
function deriveTopArtistsFromFrequentAlbums(albums: SubsonicAlbum[]): TopArtist[] {
|
||||
const map = new Map<string, TopArtist>();
|
||||
for (const a of albums) {
|
||||
const plays = a.playCount ?? 0;
|
||||
if (!a.artistId || !a.artist || plays <= 0) continue;
|
||||
const prev = map.get(a.artistId);
|
||||
if (prev) {
|
||||
prev.totalPlays += plays;
|
||||
continue;
|
||||
}
|
||||
map.set(a.artistId, { id: a.artistId, name: a.artist, totalPlays: plays });
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.totalPlays - a.totalPlays);
|
||||
}
|
||||
|
||||
async function fetchFrequentAlbumsPool(): Promise<SubsonicAlbum[]> {
|
||||
const out: SubsonicAlbum[] = [];
|
||||
let offset = 0;
|
||||
while (out.length < MOST_PLAYED_MAX_ALBUMS) {
|
||||
const page = await getAlbumList('frequent', MOST_PLAYED_PAGE_SIZE, offset);
|
||||
if (!page.length) break;
|
||||
out.push(...page);
|
||||
if (page.length < MOST_PLAYED_PAGE_SIZE) break;
|
||||
offset += MOST_PLAYED_PAGE_SIZE;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function pickSongsForArtist(artist: TopArtist, need: number): Promise<SubsonicSong[]> {
|
||||
const primary = uniqueBySongId(await filterSongsToActiveLibrary(await getTopSongs(artist.name)));
|
||||
if (primary.length >= need) return sampleRandom(primary, need);
|
||||
|
||||
const extra: SubsonicSong[] = [];
|
||||
for (let i = 0; i < 8 && primary.length + extra.length < need; i++) {
|
||||
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
|
||||
for (const s of rnd) {
|
||||
if (s.artistId === artist.id || s.artist === artist.name) {
|
||||
extra.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sampleRandom(uniqueBySongId([...primary, ...extra]), need);
|
||||
}
|
||||
|
||||
async function pickSongsForAlbum(albumId: string, need: number): Promise<SubsonicSong[]> {
|
||||
const full = await getAlbum(albumId).catch(() => null);
|
||||
if (!full?.songs?.length) return [];
|
||||
const scopedSongs = await filterSongsToActiveLibrary(full.songs);
|
||||
return sampleRandom(uniqueBySongId(scopedSongs), need);
|
||||
}
|
||||
|
||||
async function pickGoodRatedSongs(existingIds: Set<string>, need: number): Promise<SubsonicSong[]> {
|
||||
const out: SubsonicSong[] = [];
|
||||
const push = (s: SubsonicSong) => {
|
||||
const r = s.userRating ?? 0;
|
||||
if (r < 4) return;
|
||||
if (existingIds.has(s.id)) return;
|
||||
if (out.some(x => x.id === s.id)) return;
|
||||
out.push(s);
|
||||
};
|
||||
|
||||
for (let i = 0; i < 14 && out.length < need; i++) {
|
||||
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
|
||||
rnd.forEach(push);
|
||||
}
|
||||
|
||||
return sampleRandom(out, need);
|
||||
}
|
||||
|
||||
export async function buildAndPlayLuckyMix(): Promise<void> {
|
||||
const lucky = useLuckyMixStore.getState();
|
||||
if (lucky.isRolling) return;
|
||||
const auth = useAuthStore.getState();
|
||||
const debugEnabled = auth.loggingMode === 'debug';
|
||||
const debugSteps: Array<{ step: string; details?: unknown }> = [];
|
||||
const logStep = (step: string, details?: unknown) => {
|
||||
if (!debugEnabled) return;
|
||||
const payload = { step, details };
|
||||
debugSteps.push(payload);
|
||||
console.debug('[psysonic][lucky-mix]', payload);
|
||||
void invoke('frontend_debug_log', {
|
||||
scope: 'lucky-mix',
|
||||
message: JSON.stringify(payload),
|
||||
}).catch(() => {});
|
||||
};
|
||||
const songDebug = (songs: SubsonicSong[]) =>
|
||||
songs.map(s => ({ id: s.id, title: s.title, artist: s.artist, rating: s.userRating ?? 0 }));
|
||||
const albumDebug = (albums: SubsonicAlbum[]) =>
|
||||
albums.map(a => ({ id: a.id, name: a.name, artist: a.artist, playCount: a.playCount ?? 0 }));
|
||||
const activeServerId = auth.activeServerId;
|
||||
const audiomuseEnabled = Boolean(activeServerId && auth.audiomuseNavidromeByServer[activeServerId]);
|
||||
logStep('init', {
|
||||
activeServerId,
|
||||
audiomuseEnabled,
|
||||
showLuckyMixMenu: auth.showLuckyMixMenu,
|
||||
libraryFilter: activeServerId ? (auth.musicLibraryFilterByServer[activeServerId] ?? 'all') : 'all',
|
||||
});
|
||||
if (!auth.showLuckyMixMenu || !audiomuseEnabled) {
|
||||
logStep('abort_unavailable');
|
||||
showToast(i18n.t('luckyMix.unavailable'), 4000, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop the old "upcoming" tail immediately so the queue UI does not show stale
|
||||
// next tracks while the mix is still building (first playTrack may be delayed).
|
||||
usePlayerStore.getState().pruneUpcomingToCurrent();
|
||||
|
||||
lucky.start();
|
||||
try {
|
||||
const queuedIds = new Set<string>();
|
||||
let allSeedSongs: SubsonicSong[] = [];
|
||||
let startedPlayback = false;
|
||||
const reachedTarget = () => queuedIds.size >= MIX_TARGET_SIZE;
|
||||
const isBlockedByRating = (song: SubsonicSong) => {
|
||||
const rating = song.userRating ?? 0;
|
||||
return rating === 1 || rating === 2;
|
||||
};
|
||||
|
||||
const startImmediatePlayback = (song: SubsonicSong, source: string) => {
|
||||
if (startedPlayback || !song?.id || isBlockedByRating(song)) return;
|
||||
startedPlayback = true;
|
||||
queuedIds.add(song.id);
|
||||
const track = songToTrack(song);
|
||||
usePlayerStore.getState().playTrack(track, [track], true);
|
||||
logStep('start_immediate_playback', {
|
||||
source,
|
||||
song: songDebug([song])[0],
|
||||
queuedCount: queuedIds.size,
|
||||
});
|
||||
};
|
||||
|
||||
const appendSongsToQueue = (songs: SubsonicSong[], reason: string): number => {
|
||||
if (reachedTarget()) return 0;
|
||||
if (!songs.length) return 0;
|
||||
const deduped = uniqueBySongId(songs).filter(s => !queuedIds.has(s.id) && !isBlockedByRating(s));
|
||||
if (!deduped.length) return 0;
|
||||
|
||||
const candidates = [...deduped];
|
||||
if (!startedPlayback && candidates.length > 0) {
|
||||
const first = candidates.shift();
|
||||
if (first) startImmediatePlayback(first, reason);
|
||||
}
|
||||
|
||||
if (!candidates.length) return 0;
|
||||
const remaining = Math.max(0, MIX_TARGET_SIZE - queuedIds.size);
|
||||
if (remaining <= 0) return 0;
|
||||
const toAdd = sampleRandom(candidates, Math.min(remaining, candidates.length));
|
||||
if (!toAdd.length) return 0;
|
||||
toAdd.forEach(s => queuedIds.add(s.id));
|
||||
usePlayerStore.getState().enqueue(toAdd.map(songToTrack));
|
||||
logStep('append_queue_batch', {
|
||||
reason,
|
||||
added: toAdd.length,
|
||||
queuedCount: queuedIds.size,
|
||||
songs: songDebug(toAdd),
|
||||
});
|
||||
return toAdd.length;
|
||||
};
|
||||
|
||||
const frequentAlbums = await fetchFrequentAlbumsPool();
|
||||
const albumsWithPlays = frequentAlbums.filter(a => (a.playCount ?? 0) > 0);
|
||||
logStep('fetch_frequent_albums', {
|
||||
fetched: frequentAlbums.length,
|
||||
withPlays: albumsWithPlays.length,
|
||||
});
|
||||
const topArtists = deriveTopArtistsFromFrequentAlbums(albumsWithPlays);
|
||||
const pickedArtists = sampleRandom(topArtists, 2);
|
||||
logStep('pick_top_artists', {
|
||||
topArtistsCount: topArtists.length,
|
||||
pickedArtists,
|
||||
});
|
||||
|
||||
for (const artist of pickedArtists) {
|
||||
const songs = await pickSongsForArtist(artist, 3);
|
||||
allSeedSongs = uniqueAppend(allSeedSongs, songs);
|
||||
const firstPlayable = songs.find(s => !isBlockedByRating(s));
|
||||
if (firstPlayable) startImmediatePlayback(firstPlayable, `artist:${artist.name}`);
|
||||
logStep('pick_artist_songs', {
|
||||
artist,
|
||||
pickedCount: songs.length,
|
||||
songs: songDebug(songs),
|
||||
});
|
||||
}
|
||||
|
||||
const pickedAlbums = sampleRandom(albumsWithPlays, 2);
|
||||
logStep('pick_top_albums', {
|
||||
poolCount: albumsWithPlays.length,
|
||||
pickedAlbums: albumDebug(pickedAlbums),
|
||||
});
|
||||
for (const album of pickedAlbums) {
|
||||
const songs = await pickSongsForAlbum(album.id, 3);
|
||||
allSeedSongs = uniqueAppend(allSeedSongs, songs);
|
||||
const firstPlayable = songs.find(s => !isBlockedByRating(s));
|
||||
if (firstPlayable) startImmediatePlayback(firstPlayable, `album:${album.id}`);
|
||||
logStep('pick_album_songs', {
|
||||
albumId: album.id,
|
||||
pickedCount: songs.length,
|
||||
songs: songDebug(songs),
|
||||
});
|
||||
}
|
||||
|
||||
const rated = await pickGoodRatedSongs(new Set(allSeedSongs.map(s => s.id)), 3);
|
||||
logStep('pick_rated_songs_4plus_only', {
|
||||
ratedPickedCount: rated.length,
|
||||
ratedSongs: songDebug(rated),
|
||||
});
|
||||
allSeedSongs = uniqueAppend(allSeedSongs, rated);
|
||||
let seeds = allSeedSongs.filter(s => !isBlockedByRating(s));
|
||||
logStep('seed_after_dedup', {
|
||||
seedCount: seeds.length,
|
||||
seeds: songDebug(seeds),
|
||||
});
|
||||
|
||||
if (seeds.length < SEED_TARGET_SIZE) {
|
||||
logStep('seed_fill_start', { target: SEED_TARGET_SIZE, before: seeds.length });
|
||||
for (let i = 0; i < 10 && seeds.length < SEED_TARGET_SIZE; i++) {
|
||||
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(80));
|
||||
const allowedRnd = rnd.filter(s => !isBlockedByRating(s));
|
||||
seeds = uniqueAppend(seeds, allowedRnd);
|
||||
const firstPlayable = allowedRnd[0];
|
||||
if (firstPlayable) startImmediatePlayback(firstPlayable, `seed-fill-batch:${i + 1}`);
|
||||
logStep('seed_fill_batch', {
|
||||
batch: i + 1,
|
||||
fetched: rnd.length,
|
||||
seedCount: seeds.length,
|
||||
});
|
||||
}
|
||||
seeds = seeds.slice(0, SEED_TARGET_SIZE);
|
||||
logStep('seed_fill_end', {
|
||||
finalSeedCount: seeds.length,
|
||||
seeds: songDebug(seeds),
|
||||
});
|
||||
}
|
||||
|
||||
if (seeds.length === 0) {
|
||||
throw new Error('no-seeds');
|
||||
}
|
||||
if (!startedPlayback) {
|
||||
const firstPlayableSeed = seeds.find(s => !isBlockedByRating(s));
|
||||
if (firstPlayableSeed) startImmediatePlayback(firstPlayableSeed, 'seed-fallback-first');
|
||||
}
|
||||
|
||||
let similarRaw: SubsonicSong[] = [];
|
||||
let similar: SubsonicSong[] = [];
|
||||
for (let i = 0; i < seeds.length; i++) {
|
||||
const seed = seeds[i];
|
||||
const oneRaw = await getSimilarSongs(seed.id, 60).catch(() => [] as SubsonicSong[]);
|
||||
const oneScoped = await filterSongsToActiveLibrary(oneRaw);
|
||||
similarRaw = uniqueAppend(similarRaw, oneRaw);
|
||||
similar = uniqueAppend(similar, oneScoped);
|
||||
appendSongsToQueue(oneScoped, `similar-seed-${i + 1}/${seeds.length}`);
|
||||
if (reachedTarget()) break;
|
||||
}
|
||||
const seedForPool = seeds.filter(() => Math.random() < 0.5);
|
||||
let pool = uniqueBySongId([...seedForPool, ...similar]);
|
||||
appendSongsToQueue(seedForPool, 'seed-50pct');
|
||||
logStep('instant_mix', {
|
||||
seedUsedForInstantMixCount: seeds.length,
|
||||
seedIncludedInPoolCount: seedForPool.length,
|
||||
seedIncludedInPool: songDebug(seedForPool),
|
||||
similarRawCount: similarRaw.length,
|
||||
similarScopedCount: similar.length,
|
||||
initialPoolCount: pool.length,
|
||||
});
|
||||
|
||||
for (let i = 0; i < 10 && pool.length < MIX_TARGET_SIZE; i++) {
|
||||
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
|
||||
pool = uniqueAppend(pool, rnd);
|
||||
appendSongsToQueue(rnd, `pool-fill-${i + 1}`);
|
||||
logStep('pool_fill_batch', {
|
||||
batch: i + 1,
|
||||
fetched: rnd.length,
|
||||
poolCount: pool.length,
|
||||
});
|
||||
if (reachedTarget()) break;
|
||||
}
|
||||
|
||||
const finalSongs = sampleRandom(pool, MIX_TARGET_SIZE).filter(s => !queuedIds.has(s.id));
|
||||
appendSongsToQueue(finalSongs, 'finalize-randomized');
|
||||
logStep('final_queue_state', {
|
||||
poolCount: pool.length,
|
||||
queuedCount: queuedIds.size,
|
||||
queuedTarget: MIX_TARGET_SIZE,
|
||||
});
|
||||
if (queuedIds.size === 0) {
|
||||
throw new Error('empty-mix');
|
||||
}
|
||||
showToast(i18n.t('luckyMix.done', { count: queuedIds.size }), 3500, 'success');
|
||||
logStep('done', { queueCount: queuedIds.size });
|
||||
if (debugEnabled) {
|
||||
console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
|
||||
void invoke('frontend_debug_log', {
|
||||
scope: 'lucky-mix',
|
||||
message: JSON.stringify({ step: 'full-steps', details: debugSteps }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[psysonic] lucky mix failed:', err);
|
||||
logStep('failed', { error: String(err) });
|
||||
if (debugEnabled) {
|
||||
console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
|
||||
void invoke('frontend_debug_log', {
|
||||
scope: 'lucky-mix',
|
||||
message: JSON.stringify({ step: 'full-steps', details: debugSteps }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
showToast(i18n.t('luckyMix.failed'), 5000, 'error');
|
||||
} finally {
|
||||
useLuckyMixStore.getState().stop();
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export function getLibraryItemsForReorder(
|
||||
): SidebarItemConfig[] {
|
||||
return items.filter(cfg => {
|
||||
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false;
|
||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
|
||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums' || cfg.id === 'luckyMix')) return false;
|
||||
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user