Merge pull request #278 from Psychotoxical/feat/lucky-mix-flow

feat(player): Lucky Mix — instant mix from listening history and ratings
This commit is contained in:
cucadmuh
2026-04-24 00:46:26 +03:00
committed by GitHub
25 changed files with 865 additions and 17 deletions
+7
View File
@@ -157,6 +157,12 @@ fn export_runtime_logs(path: String) -> Result<usize, String> {
crate::logging::export_logs_to_file(&path) 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] #[tauri::command]
fn set_subsonic_wire_user_agent( fn set_subsonic_wire_user_agent(
user_agent: String, user_agent: String,
@@ -3639,6 +3645,7 @@ pub fn run() {
set_linux_webkit_smooth_scrolling, set_linux_webkit_smooth_scrolling,
set_logging_mode, set_logging_mode,
export_runtime_logs, export_runtime_logs,
frontend_debug_log,
set_subsonic_wire_user_agent, set_subsonic_wire_user_agent,
no_compositing_mode, no_compositing_mode,
is_tiling_wm_cmd, is_tiling_wm_cmd,
+2
View File
@@ -28,6 +28,7 @@ import Login from './pages/Login';
import AlbumDetail from './pages/AlbumDetail'; import AlbumDetail from './pages/AlbumDetail';
import MostPlayed from './pages/MostPlayed'; import MostPlayed from './pages/MostPlayed';
import RandomAlbums from './pages/RandomAlbums'; import RandomAlbums from './pages/RandomAlbums';
import LuckyMixPage from './pages/LuckyMix';
import SearchResults from './pages/SearchResults'; import SearchResults from './pages/SearchResults';
import Playlists from './pages/Playlists'; import Playlists from './pages/Playlists';
import PlaylistDetail from './pages/PlaylistDetail'; import PlaylistDetail from './pages/PlaylistDetail';
@@ -452,6 +453,7 @@ function AppShell() {
<Route path="/new-releases" element={<NewReleases />} /> <Route path="/new-releases" element={<NewReleases />} />
<Route path="/favorites" element={<Favorites />} /> <Route path="/favorites" element={<Favorites />} />
<Route path="/random/mix" element={<RandomMix />} /> <Route path="/random/mix" element={<RandomMix />} />
<Route path="/lucky-mix" element={<LuckyMixPage />} />
<Route path="/label/:name" element={<LabelAlbums />} /> <Route path="/label/:name" element={<LabelAlbums />} />
<Route path="/search" element={<SearchResults />} /> <Route path="/search" element={<SearchResults />} />
<Route path="/search/advanced" element={<AdvancedSearch />} /> <Route path="/search/advanced" element={<AdvancedSearch />} />
+4
View File
@@ -6,6 +6,7 @@ import { useSidebarStore } from '../store/sidebarStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore'; import { useOfflineStore } from '../store/offlineStore';
import { ALL_NAV_ITEMS } from '../config/navItems'; import { ALL_NAV_ITEMS } from '../config/navItems';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']); const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
@@ -16,6 +17,8 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
const serverId = useAuthStore(s => s.activeServerId ?? ''); const serverId = useAuthStore(s => s.activeServerId ?? '');
const offlineAlbums = useOfflineStore(s => s.albums); const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const luckyMixBase = useLuckyMixAvailable();
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
const items = sidebarItems const items = sidebarItems
.filter(cfg => { .filter(cfg => {
@@ -25,6 +28,7 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
if (BOTTOM_NAV_ROUTES.has(item.to)) return false; if (BOTTOM_NAV_ROUTES.has(item.to)) return false;
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false; if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false; if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
return true; return true;
}) })
.map(cfg => ALL_NAV_ITEMS[cfg.id]); .map(cfg => ALL_NAV_ITEMS[cfg.id]);
+32 -2
View File
@@ -17,6 +17,7 @@ import LyricsPane from './LyricsPane';
import NowPlayingInfo from './NowPlayingInfo'; import NowPlayingInfo from './NowPlayingInfo';
import { TFunction } from 'i18next'; import { TFunction } from 'i18next';
import OverlayScrollArea from './OverlayScrollArea'; import OverlayScrollArea from './OverlayScrollArea';
import { useLuckyMixStore } from '../store/luckyMixStore';
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00'; if (!seconds || isNaN(seconds)) return '0:00';
@@ -269,6 +270,7 @@ export default function QueuePanel() {
const activeTab = useLyricsStore(s => s.activeTab); const activeTab = useLyricsStore(s => s.activeTab);
const setTab = useLyricsStore(s => s.setTab); const setTab = useLyricsStore(s => s.setTab);
const luckyRolling = useLuckyMixStore(s => s.isRolling);
const [showRemainingTime, setShowRemainingTime] = useState(false); const [showRemainingTime, setShowRemainingTime] = useState(false);
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false); const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
@@ -657,7 +659,8 @@ export default function QueuePanel() {
{t('queue.emptyQueue')} {t('queue.emptyQueue')}
</div> </div>
) : ( ) : (
queue.map((track, idx) => { <>
{queue.map((track, idx) => {
const isPlaying = idx === queueIndex; const isPlaying = idx === queueIndex;
const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded); const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded); const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
@@ -726,9 +729,36 @@ export default function QueuePanel() {
{formatTime(track.duration)} {formatTime(track.duration)}
</div> </div>
</div> </div>
{luckyRolling && isPlaying && (
<button
type="button"
className="queue-lucky-loading"
onClick={() => useLuckyMixStore.getState().cancel()}
data-tooltip={t('luckyMix.cancelTooltip')}
aria-label={t('luckyMix.cancelTooltip')}
>
<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>
</button>
)}
</React.Fragment> </React.Fragment>
); );
}) })}
</>
)} )}
</OverlayScrollArea> </OverlayScrollArea>
</>) : activeTab === 'lyrics' ? ( </>) : activeTab === 'lyrics' ? (
+12 -2
View File
@@ -27,6 +27,7 @@ import {
isSidebarNavItemUserHideable, isSidebarNavItemUserHideable,
type SidebarNavDropTarget, type SidebarNavDropTarget,
} from '../utils/sidebarNavReorder'; } from '../utils/sidebarNavReorder';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
const SIDEBAR_NAV_LONG_PRESS_MS = 1000; const SIDEBAR_NAV_LONG_PRESS_MS = 1000;
const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10; const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10;
@@ -68,6 +69,10 @@ export default function Sidebar({
const sidebarItems = useSidebarStore(s => s.items); const sidebarItems = useSidebarStore(s => s.items);
const setSidebarItems = useSidebarStore(s => s.setItems); const setSidebarItems = useSidebarStore(s => s.setItems);
const randomNavMode = useAuthStore(s => s.randomNavMode); const randomNavMode = useAuthStore(s => s.randomNavMode);
const luckyMixBase = useLuckyMixAvailable();
// Sidebar surfaces Lucky Mix as its own entry only in "separate" nav mode —
// in hub mode it lives inside the Build-a-Mix landing page instead.
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false); const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
const [playlistsExpanded, setPlaylistsExpanded] = useState(false); const [playlistsExpanded, setPlaylistsExpanded] = useState(false);
const playlistsRaw = usePlaylistStore(s => s.playlists); const playlistsRaw = usePlaylistStore(s => s.playlists);
@@ -95,8 +100,13 @@ export default function Sidebar({
[sidebarItems], [sidebarItems],
); );
const visibleLibraryConfigs = useMemo( 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( const visibleSystemConfigs = useMemo(
() => systemItemsForReorder.filter(c => c.visible), () => systemItemsForReorder.filter(c => c.visible),
+2 -1
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { import {
Disc3, Users, Music4, Radio, Heart, BarChart3, Disc3, Users, Music4, Radio, Heart, BarChart3,
HelpCircle, Tags, ListMusic, Cast, TrendingUp, HelpCircle, Tags, ListMusic, Cast, TrendingUp,
FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices, FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices, Sparkles,
} from 'lucide-react'; } from 'lucide-react';
export interface NavItemMeta { 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' }, randomPicker: { icon: Wand2, labelKey: 'sidebar.randomPicker', to: '/random', section: 'library' },
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random/mix', section: 'library' }, randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random/mix', section: 'library' },
randomAlbums: { icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random/albums', 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' }, artists: { icon: Users, labelKey: 'sidebar.artists', to: '/artists', section: 'library' },
genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' }, genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' },
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' }, favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
+40
View File
@@ -0,0 +1,40 @@
import { useAuthStore } from '../store/authStore';
/**
* Whether "Lucky Mix" should be exposed as a navigable menu/card entry.
*
* Single source of truth for the gate previously this logic was inlined in
* Sidebar, MobileMoreOverlay, RandomLanding, Settings/SidebarCustomizer, and
* the sidebarNavReorder filter. All call sites share the same three-way
* predicate:
* 1. User hasn't hidden it via the Settings toggle.
* 2. AudioMuse is enabled for the active server (feature depends on
* audiomuse-backed similar-track quality).
* 3. An active server exists at all.
*
* Callers that additionally care about the "split vs hub" navigation mode
* should combine this with `randomNavMode === 'separate'` explicitly that's
* an orthogonal UI placement concern, not an availability concern.
*/
export function isLuckyMixAvailable(args: {
activeServerId: string | null | undefined;
audiomuseByServer: Record<string, boolean>;
showLuckyMixMenu: boolean;
}): boolean {
const { activeServerId, audiomuseByServer, showLuckyMixMenu } = args;
if (!showLuckyMixMenu) return false;
if (!activeServerId) return false;
return Boolean(audiomuseByServer[activeServerId]);
}
/**
* React hook form subscribes to the three authStore slices the predicate
* depends on, so any user-facing change (toggle flip, server switch, AudioMuse
* toggle on/off) re-renders the caller automatically.
*/
export function useLuckyMixAvailable(): boolean {
const activeServerId = useAuthStore(s => s.activeServerId);
const audiomuseByServer = useAuthStore(s => s.audiomuseNavidromeByServer);
const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu);
return isLuckyMixAvailable({ activeServerId, audiomuseByServer, showLuckyMixMenu });
}
+12 -1
View File
@@ -31,6 +31,7 @@ export const deTranslation = {
expandPlaylists: 'Playlists ausklappen', expandPlaylists: 'Playlists ausklappen',
collapsePlaylists: 'Playlists einklappen', collapsePlaylists: 'Playlists einklappen',
more: 'Mehr', more: 'Mehr',
feelingLucky: 'Glücks-Mix',
}, },
home: { home: {
hero: 'Featured', hero: 'Featured',
@@ -283,6 +284,8 @@ export const deTranslation = {
mixByTracksDesc: 'Zufällige Auswahl aus deiner gesamten Mediathek', mixByTracksDesc: 'Zufällige Auswahl aus deiner gesamten Mediathek',
mixByAlbums: 'Mix nach Alben', mixByAlbums: 'Mix nach Alben',
mixByAlbumsDesc: 'Zufällige Alben für neue Entdeckungen', mixByAlbumsDesc: 'Zufällige Alben für neue Entdeckungen',
mixByLucky: 'Glücks-Mix',
mixByLuckyDesc: 'Smarter Instant Mix aus Top-Künstlern, Alben und Bewertungen',
}, },
randomAlbums: { randomAlbums: {
title: 'Zufallsalben', title: 'Zufallsalben',
@@ -335,6 +338,12 @@ export const deTranslation = {
filterPanelDesc: 'Genre-Tag oder Künstlername in der Liste anklicken, um ihn aus zukünftigen Mixes auszuschließen.', 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.', genreClickHint: 'Genre-Tag anklicken,\num es als Filter-Keyword hinzuzufügen.\nPrüft Genre, Titel, Album & Künstler.',
}, },
luckyMix: {
done: 'Glücks-Mix bereit: {{count}} Titel',
failed: 'Glücks-Mix konnte nicht erstellt werden. Bitte erneut versuchen.',
unavailable: 'Glücks-Mix ist für diesen Server nicht verfügbar.',
cancelTooltip: 'Glücks-Mix-Erstellung abbrechen',
},
albums: { albums: {
title: 'Alle Alben', title: 'Alle Alben',
sortByName: 'AZ (Album)', sortByName: 'AZ (Album)',
@@ -720,6 +729,8 @@ export const deTranslation = {
showChangelogOnUpdate: "'Was ist neu' bei Update anzeigen", 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.', 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', randomMixTitle: 'Zufallsmix-Blacklist',
luckyMixMenuTitle: 'Glücks-Mix im Menü anzeigen',
luckyMixMenuDesc: 'Aktiviert Glücks-Mix 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', 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).', 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…', randomMixBlacklistPlaceholder: 'Keyword hinzufügen…',
@@ -755,7 +766,7 @@ export const deTranslation = {
sidebarDrag: 'Ziehen zum Umsortieren', sidebarDrag: 'Ziehen zum Umsortieren',
sidebarFixed: 'Immer sichtbar', sidebarFixed: 'Immer sichtbar',
randomNavSplitTitle: 'Mix-Navigation aufteilen', randomNavSplitTitle: 'Mix-Navigation aufteilen',
randomNavSplitDesc: '"Zufallsmix" und "Zufallsalben" als separate Sidebar-Einträge statt als "Mix erstellen"-Hub anzeigen.', randomNavSplitDesc: '"Zufallsmix", "Zufallsalben" und "Glücks-Mix" als separate Sidebar-Einträge statt als "Mix erstellen"-Hub anzeigen.',
tabInput: 'Eingabe', tabInput: 'Eingabe',
tabUsers: 'Benutzer', tabUsers: 'Benutzer',
shortcutsReset: 'Auf Standard zurücksetzen', shortcutsReset: 'Auf Standard zurücksetzen',
+12 -1
View File
@@ -32,6 +32,7 @@ export const enTranslation = {
expandPlaylists: 'Expand playlists', expandPlaylists: 'Expand playlists',
collapsePlaylists: 'Collapse playlists', collapsePlaylists: 'Collapse playlists',
more: 'More', more: 'More',
feelingLucky: 'Lucky Mix',
}, },
home: { home: {
hero: 'Featured', hero: 'Featured',
@@ -284,6 +285,8 @@ export const enTranslation = {
mixByTracksDesc: 'Random selection of tracks from your entire library', mixByTracksDesc: 'Random selection of tracks from your entire library',
mixByAlbums: 'Mix by Albums', mixByAlbums: 'Mix by Albums',
mixByAlbumsDesc: 'Random album picks for your next discovery', mixByAlbumsDesc: 'Random album picks for your next discovery',
mixByLucky: 'Lucky Mix',
mixByLuckyDesc: 'Smart instant mix from your top artists, albums, and ratings',
}, },
randomAlbums: { randomAlbums: {
title: 'Random Albums', title: 'Random Albums',
@@ -336,6 +339,12 @@ export const enTranslation = {
filterPanelDesc: 'Click a genre tag or artist name in the tracklist below to block it from future mixes.', 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.', 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.',
cancelTooltip: 'Cancel Lucky Mix build',
},
albums: { albums: {
title: 'All Albums', title: 'All Albums',
sortByName: 'AZ (Album)', sortByName: 'AZ (Album)',
@@ -722,6 +731,8 @@ export const enTranslation = {
showChangelogOnUpdate: "Show 'What's New' on update", 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.", showChangelogOnUpdateDesc: "Show a discreet changelog banner above Now Playing after an update. Click opens the release notes; X dismisses it.",
randomMixTitle: 'Random Mix Blacklist', 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', 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).', randomMixBlacklistDesc: 'Songs are excluded when any keyword matches their genre, title, album, or artist (active when the checkbox above is on).',
randomMixBlacklistPlaceholder: 'Add keyword…', randomMixBlacklistPlaceholder: 'Add keyword…',
@@ -757,7 +768,7 @@ export const enTranslation = {
sidebarDrag: 'Drag to reorder', sidebarDrag: 'Drag to reorder',
sidebarFixed: 'Always visible', sidebarFixed: 'Always visible',
randomNavSplitTitle: 'Split Mix navigation', 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', tabInput: 'Input',
tabUsers: 'Users', tabUsers: 'Users',
tabSystem: 'System', tabSystem: 'System',
+12 -1
View File
@@ -32,6 +32,7 @@ export const esTranslation = {
expandPlaylists: 'Expandir listas', expandPlaylists: 'Expandir listas',
collapsePlaylists: 'Colapsar listas', collapsePlaylists: 'Colapsar listas',
more: 'Más', more: 'Más',
feelingLucky: 'Mezcla Suerte',
}, },
home: { home: {
hero: 'Destacado', hero: 'Destacado',
@@ -284,6 +285,8 @@ export const esTranslation = {
mixByTracksDesc: 'Selección aleatoria de canciones de toda tu biblioteca', mixByTracksDesc: 'Selección aleatoria de canciones de toda tu biblioteca',
mixByAlbums: 'Mezcla por Álbumes', mixByAlbums: 'Mezcla por Álbumes',
mixByAlbumsDesc: 'Álbumes aleatorios para tu próximo descubrimiento', mixByAlbumsDesc: 'Álbumes aleatorios para tu próximo descubrimiento',
mixByLucky: 'Mezcla Suerte',
mixByLuckyDesc: 'Instant Mix inteligente con artistas, álbumes y valoraciones destacadas',
}, },
randomAlbums: { randomAlbums: {
title: 'Álbumes Aleatorios', title: 'Álbumes Aleatorios',
@@ -336,6 +339,12 @@ export const esTranslation = {
filterPanelDesc: 'Click en una etiqueta de género o nombre de artista en la lista para bloquearlo de futuras mezclas.', 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.', 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 Suerte lista: {{count}} canciones',
failed: 'No se pudo crear la Mezcla Suerte. Inténtalo de nuevo.',
unavailable: 'Mezcla Suerte no está disponible para este servidor.',
cancelTooltip: 'Cancelar creación de Mezcla Suerte',
},
albums: { albums: {
title: 'Todos los Álbumes', title: 'Todos los Álbumes',
sortByName: 'AZ (Álbum)', sortByName: 'AZ (Álbum)',
@@ -712,6 +721,8 @@ export const esTranslation = {
showChangelogOnUpdate: "Mostrar 'Novedades' al actualizar", 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.', 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', randomMixTitle: 'Lista negra de Mezcla Aleatoria',
luckyMixMenuTitle: 'Mostrar Mezcla Suerte en el menú',
luckyMixMenuDesc: 'Activa Mezcla 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', 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).', 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…', randomMixBlacklistPlaceholder: 'Agregar palabra clave…',
@@ -748,7 +759,7 @@ export const esTranslation = {
sidebarDrag: 'Arrastra para reordenar', sidebarDrag: 'Arrastra para reordenar',
sidebarFixed: 'Siempre visible', sidebarFixed: 'Siempre visible',
randomNavSplitTitle: 'Dividir navegación Mix', 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 "Mezcla Suerte" como entradas separadas en la barra lateral en lugar del hub "Crear Mezcla".',
tabInput: 'Entrada', tabInput: 'Entrada',
tabUsers: 'Usuarios', tabUsers: 'Usuarios',
tabSystem: 'Sistema', tabSystem: 'Sistema',
+12 -1
View File
@@ -31,6 +31,7 @@ export const frTranslation = {
expandPlaylists: 'Développer les playlists', expandPlaylists: 'Développer les playlists',
collapsePlaylists: 'Réduire les playlists', collapsePlaylists: 'Réduire les playlists',
more: 'Plus', more: 'Plus',
feelingLucky: 'Mix Chance',
}, },
home: { home: {
hero: 'En vedette', hero: 'En vedette',
@@ -283,6 +284,8 @@ export const frTranslation = {
mixByTracksDesc: 'Sélection aléatoire de titres depuis toute votre médiathèque', mixByTracksDesc: 'Sélection aléatoire de titres depuis toute votre médiathèque',
mixByAlbums: 'Mix par albums', mixByAlbums: 'Mix par albums',
mixByAlbumsDesc: 'Albums aléatoires pour vos prochaines découvertes', mixByAlbumsDesc: 'Albums aléatoires pour vos prochaines découvertes',
mixByLucky: 'Mix Chance',
mixByLuckyDesc: 'Instant Mix intelligent basé sur artistes, albums et notes élevées',
}, },
randomAlbums: { randomAlbums: {
title: 'Albums aléatoires', title: 'Albums aléatoires',
@@ -335,6 +338,12 @@ export const frTranslation = {
filterPanelDesc: 'Cliquez sur un tag de genre ou un nom d\'artiste dans la liste pour l\'exclure des futurs mix.', 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.', 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 Chance prêt : {{count}} titres',
failed: 'Impossible de créer le Mix Chance. Réessayez.',
unavailable: 'Mix Chance n\'est pas disponible pour ce serveur.',
cancelTooltip: 'Annuler la création du Mix Chance',
},
albums: { albums: {
title: 'Tous les albums', title: 'Tous les albums',
sortByName: 'AZ (Album)', sortByName: 'AZ (Album)',
@@ -707,6 +716,8 @@ export const frTranslation = {
showChangelogOnUpdate: "Afficher 'Quoi de neuf' lors des mises à jour", 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.", 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', randomMixTitle: 'Liste noire du mix aléatoire',
luckyMixMenuTitle: 'Afficher Mix Chance dans le menu',
luckyMixMenuDesc: 'Active Mix 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', 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).', 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é…', randomMixBlacklistPlaceholder: 'Ajouter un mot-clé…',
@@ -743,7 +754,7 @@ export const frTranslation = {
sidebarDrag: 'Glisser pour réorganiser', sidebarDrag: 'Glisser pour réorganiser',
sidebarFixed: 'Toujours visible', sidebarFixed: 'Toujours visible',
randomNavSplitTitle: 'Diviser la navigation Mix', 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 "Mix Chance" comme entrées séparées dans la barre latérale plutôt que le hub "Créer un Mix".',
tabInput: 'Entrée', tabInput: 'Entrée',
tabUsers: 'Utilisateurs', tabUsers: 'Utilisateurs',
shortcutsReset: 'Réinitialiser', shortcutsReset: 'Réinitialiser',
+12 -1
View File
@@ -31,6 +31,7 @@ export const nbTranslation = {
expandPlaylists: 'Utvid spillelister', expandPlaylists: 'Utvid spillelister',
collapsePlaylists: 'Skjul spillelister', collapsePlaylists: 'Skjul spillelister',
more: 'Mer', more: 'Mer',
feelingLucky: 'Lykkemiks',
}, },
home: { home: {
hero: 'Utvalgt', hero: 'Utvalgt',
@@ -283,6 +284,8 @@ export const nbTranslation = {
mixByTracksDesc: 'Tilfeldig utvalg av spor fra hele biblioteket ditt', mixByTracksDesc: 'Tilfeldig utvalg av spor fra hele biblioteket ditt',
mixByAlbums: 'Miks etter album', mixByAlbums: 'Miks etter album',
mixByAlbumsDesc: 'Tilfeldige album for nye oppdagelser', mixByAlbumsDesc: 'Tilfeldige album for nye oppdagelser',
mixByLucky: 'Lykkemiks',
mixByLuckyDesc: 'Smart Instant Mix fra toppartister, album og gode vurderinger',
}, },
randomAlbums: { randomAlbums: {
title: 'Tilfeldige album', title: 'Tilfeldige album',
@@ -335,6 +338,12 @@ export const nbTranslation = {
filterPanelDesc: 'Klikk på en sjanger-tag eller på artistnavnet i sporlisten nedenfor, for å blokkere den fra fremtidige mikser.', 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.', genreClickHint: 'Klikk på en sjanger-tag for å legge den til\nsom et filternøkkelord.\nSamsvarer med sjanger, tittel, album og artist.',
}, },
luckyMix: {
done: 'Lykkemiks klar: {{count}} spor',
failed: 'Kunne ikke lage Lykkemiksen. Prøv igjen.',
unavailable: 'Lykkemiks er ikke tilgjengelig for denne serveren.',
cancelTooltip: 'Avbryt Lykkemiks-bygging',
},
albums: { albums: {
title: 'Alle album', title: 'Alle album',
sortByName: 'A–Å (Album)', sortByName: 'A–Å (Album)',
@@ -706,6 +715,8 @@ export const nbTranslation = {
showChangelogOnUpdate: "Vis 'Hva er nytt' ved oppdatering til ny versjon", 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.", showChangelogOnUpdateDesc: "Viser et diskret changelog-banner over Now Playing etter en oppdatering. Klikk åpner versjonsnotatene; X skjuler det.",
randomMixTitle: 'Svarteliste for tilfeldig miks', randomMixTitle: 'Svarteliste for tilfeldig miks',
luckyMixMenuTitle: 'Vis Lykkemiks i menyen',
luckyMixMenuDesc: 'Aktiverer Lykkemiks 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', 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å).', 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…', randomMixBlacklistPlaceholder: 'Legg til nøkkelord…',
@@ -742,7 +753,7 @@ export const nbTranslation = {
sidebarDrag: 'Dra for å endre rekkefølge', sidebarDrag: 'Dra for å endre rekkefølge',
sidebarFixed: 'Alltid synlig', sidebarFixed: 'Alltid synlig',
randomNavSplitTitle: 'Del Mix-navigasjon', 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 "Lykkemiks" som separate sidefeltsoppføringer i stedet for "Lag en miks"-huben.',
tabShortcuts: 'Snarveier', tabShortcuts: 'Snarveier',
tabUsers: 'Brukere', tabUsers: 'Brukere',
tabSystem: 'System', tabSystem: 'System',
+12 -1
View File
@@ -31,6 +31,7 @@ export const nlTranslation = {
expandPlaylists: 'Afspeellijsten uitklappen', expandPlaylists: 'Afspeellijsten uitklappen',
collapsePlaylists: 'Afspeellijsten inklappen', collapsePlaylists: 'Afspeellijsten inklappen',
more: 'Meer', more: 'Meer',
feelingLucky: 'Geluksmix',
}, },
home: { home: {
hero: 'Uitgelicht', hero: 'Uitgelicht',
@@ -282,6 +283,8 @@ export const nlTranslation = {
mixByTracksDesc: 'Willekeurige selectie uit je volledige mediatheek', mixByTracksDesc: 'Willekeurige selectie uit je volledige mediatheek',
mixByAlbums: 'Mix op albums', mixByAlbums: 'Mix op albums',
mixByAlbumsDesc: 'Willekeurige albums voor nieuwe ontdekkingen', mixByAlbumsDesc: 'Willekeurige albums voor nieuwe ontdekkingen',
mixByLucky: 'Geluksmix',
mixByLuckyDesc: 'Slimme Instant Mix op basis van topartiesten, albums en hoge beoordelingen',
}, },
randomAlbums: { randomAlbums: {
title: 'Willekeurige albums', title: 'Willekeurige albums',
@@ -334,6 +337,12 @@ export const nlTranslation = {
filterPanelDesc: 'Klik op een genre-tag of artiestennaam in de lijst om deze uit toekomstige mixes te weren.', 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.', genreClickHint: 'Klik op een genre-tag om het\ntoe te voegen als filtertrefwoord.\nVergelijkt genre, titel, album & artiest.',
}, },
luckyMix: {
done: 'Geluksmix klaar: {{count}} nummers',
failed: 'Kon de Geluksmix niet maken. Probeer opnieuw.',
unavailable: 'Geluksmix is niet beschikbaar voor deze server.',
cancelTooltip: 'Geluksmix-opbouw annuleren',
},
albums: { albums: {
title: 'Alle albums', title: 'Alle albums',
sortByName: 'AZ (Album)', sortByName: 'AZ (Album)',
@@ -706,6 +715,8 @@ export const nlTranslation = {
showChangelogOnUpdate: "'Wat is nieuw' tonen bij update", 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.', showChangelogOnUpdateDesc: 'Toont een discrete changelog-banner boven Now Playing na een update. Klik opent de release-notities; X verbergt hem.',
randomMixTitle: 'Willekeurige mix-blacklist', randomMixTitle: 'Willekeurige mix-blacklist',
luckyMixMenuTitle: 'Toon Geluksmix in menu',
luckyMixMenuDesc: 'Schakelt Geluksmix 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', 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).', 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…', randomMixBlacklistPlaceholder: 'Trefwoord toevoegen…',
@@ -742,7 +753,7 @@ export const nlTranslation = {
sidebarDrag: 'Slepen om te herordenen', sidebarDrag: 'Slepen om te herordenen',
sidebarFixed: 'Altijd zichtbaar', sidebarFixed: 'Altijd zichtbaar',
randomNavSplitTitle: 'Mix-navigatie splitsen', 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 "Geluksmix" als afzonderlijke zijbalkitems in plaats van de "Mix samenstellen"-hub.',
tabInput: 'Invoer', tabInput: 'Invoer',
tabUsers: 'Gebruikers', tabUsers: 'Gebruikers',
shortcutsReset: 'Standaard herstellen', shortcutsReset: 'Standaard herstellen',
+14 -1
View File
@@ -32,6 +32,7 @@ export const ruTranslation = {
expandPlaylists: 'Развернуть плейлисты', expandPlaylists: 'Развернуть плейлисты',
collapsePlaylists: 'Свернуть плейлисты', collapsePlaylists: 'Свернуть плейлисты',
more: 'Ещё', more: 'Ещё',
feelingLucky: 'Мне повезёт',
}, },
home: { home: {
hero: 'Подборка', hero: 'Подборка',
@@ -297,6 +298,8 @@ export const ruTranslation = {
mixByTracksDesc: 'Случайная подборка треков со всей медиатеки', mixByTracksDesc: 'Случайная подборка треков со всей медиатеки',
mixByAlbums: 'Микс по альбомам', mixByAlbums: 'Микс по альбомам',
mixByAlbumsDesc: 'Случайная подборка альбомов для открытий', mixByAlbumsDesc: 'Случайная подборка альбомов для открытий',
mixByLucky: 'Мне повезёт',
mixByLuckyDesc: 'Умный Instant Mix из топ-артистов, альбомов и высоких оценок',
}, },
randomAlbums: { randomAlbums: {
title: 'Случайные альбомы', title: 'Случайные альбомы',
@@ -354,6 +357,12 @@ export const ruTranslation = {
genreClickHint: genreClickHint:
'Нажмите на тег жанра — слово попадёт в фильтр.\nУчитываются жанр, название, альбом и исполнитель.', 'Нажмите на тег жанра — слово попадёт в фильтр.\nУчитываются жанр, название, альбом и исполнитель.',
}, },
luckyMix: {
done: '«Мне повезёт» готово: {{count}} треков',
failed: 'Не удалось собрать микс «Мне повезёт». Попробуйте ещё раз.',
unavailable: '«Мне повезёт» недоступен на этом сервере.',
cancelTooltip: 'Отменить сборку микса «Мне повезёт»',
},
albums: { albums: {
title: 'Все альбомы', title: 'Все альбомы',
sortByName: 'А–Я (альбом)', sortByName: 'А–Я (альбом)',
@@ -745,6 +754,9 @@ export const ruTranslation = {
showChangelogOnUpdate: 'Показывать «Что нового» после обновления', showChangelogOnUpdate: 'Показывать «Что нового» после обновления',
showChangelogOnUpdateDesc: 'После обновления над «Сейчас играет» появится ненавязчивый баннер журнала изменений. Клик открывает заметки о выпуске, X скрывает.', showChangelogOnUpdateDesc: 'После обновления над «Сейчас играет» появится ненавязчивый баннер журнала изменений. Клик открывает заметки о выпуске, X скрывает.',
randomMixTitle: 'Чёрный список случайного микса', randomMixTitle: 'Чёрный список случайного микса',
luckyMixMenuTitle: 'Показывать «Мне повезёт» в меню',
luckyMixMenuDesc:
'Включает «Мне повезёт» в «Собрать микс», а при раздельной навигации — отдельным пунктом меню. Видно только при включённом AudioMuse на активном сервере.',
randomMixBlacklistTitle: 'Свои слова-фильтры', randomMixBlacklistTitle: 'Свои слова-фильтры',
randomMixBlacklistDesc: randomMixBlacklistDesc:
'Треки скрываются, если слово встречается в жанре, названии, альбоме или исполнителе (когда включён фильтр выше).', 'Треки скрываются, если слово встречается в жанре, названии, альбоме или исполнителе (когда включён фильтр выше).',
@@ -784,7 +796,8 @@ export const ruTranslation = {
sidebarDrag: 'Перетащите для порядка', sidebarDrag: 'Перетащите для порядка',
sidebarFixed: 'Всегда показывать', sidebarFixed: 'Всегда показывать',
randomNavSplitTitle: 'Разделить навигацию микса', randomNavSplitTitle: 'Разделить навигацию микса',
randomNavSplitDesc: 'Показывать «Случайный микс» и «Случайные альбомы» как отдельные пункты боковой панели вместо хаба «Собрать микс».', randomNavSplitDesc:
'Показывать «Случайный микс», «Случайные альбомы» и «Мне повезёт» как отдельные пункты боковой панели вместо хаба «Собрать микс».',
tabInput: 'Ввод', tabInput: 'Ввод',
tabUsers: 'Пользователи', tabUsers: 'Пользователи',
tabSystem: 'Система', tabSystem: 'Система',
+12 -1
View File
@@ -31,6 +31,7 @@ export const zhTranslation = {
expandPlaylists: '展开播放列表', expandPlaylists: '展开播放列表',
collapsePlaylists: '收起播放列表', collapsePlaylists: '收起播放列表',
more: '更多', more: '更多',
feelingLucky: '好运混音',
}, },
home: { home: {
hero: '精选', hero: '精选',
@@ -281,6 +282,8 @@ export const zhTranslation = {
mixByTracksDesc: '从整个媒体库随机选取曲目', mixByTracksDesc: '从整个媒体库随机选取曲目',
mixByAlbums: '按专辑混音', mixByAlbums: '按专辑混音',
mixByAlbumsDesc: '随机选取专辑,探索新音乐', mixByAlbumsDesc: '随机选取专辑,探索新音乐',
mixByLucky: '好运混音',
mixByLuckyDesc: '基于高频艺人、专辑和高评分歌曲的智能 Instant Mix',
}, },
randomAlbums: { randomAlbums: {
title: '随机专辑', title: '随机专辑',
@@ -333,6 +336,12 @@ export const zhTranslation = {
filterPanelDesc: '点击下方列表中的流派标签或艺术家名称,将其从未来的混音中排除。', filterPanelDesc: '点击下方列表中的流派标签或艺术家名称,将其从未来的混音中排除。',
genreClickHint: '点击流派标签将其添加为过滤关键词。\\n匹配流派、标题、专辑和艺术家。', genreClickHint: '点击流派标签将其添加为过滤关键词。\\n匹配流派、标题、专辑和艺术家。',
}, },
luckyMix: {
done: '好运混音已就绪:{{count}} 首',
failed: '生成好运混音失败,请重试。',
unavailable: '当前服务器不支持好运混音。',
cancelTooltip: '取消生成好运混音',
},
albums: { albums: {
title: '全部专辑', title: '全部专辑',
sortByName: '按名称排序 (A-Z)', sortByName: '按名称排序 (A-Z)',
@@ -701,6 +710,8 @@ export const zhTranslation = {
showChangelogOnUpdate: '更新时显示"新功能"', showChangelogOnUpdate: '更新时显示"新功能"',
showChangelogOnUpdateDesc: '更新后在「正在播放」上方显示一个低调的更新日志横幅。点击打开发行说明,X 按钮关闭。', showChangelogOnUpdateDesc: '更新后在「正在播放」上方显示一个低调的更新日志横幅。点击打开发行说明,X 按钮关闭。',
randomMixTitle: '随机混音黑名单', randomMixTitle: '随机混音黑名单',
luckyMixMenuTitle: '在菜单中显示“好运混音”',
luckyMixMenuDesc: '在“创建混音”中启用“好运混音”,并在分离导航时作为独立菜单项显示。仅当当前服务器启用 AudioMuse 时可见。',
randomMixBlacklistTitle: '自定义过滤关键词', randomMixBlacklistTitle: '自定义过滤关键词',
randomMixBlacklistDesc: '当任何关键词匹配流派、标题、专辑或艺术家时,歌曲将被排除(当上方复选框开启时生效)。', randomMixBlacklistDesc: '当任何关键词匹配流派、标题、专辑或艺术家时,歌曲将被排除(当上方复选框开启时生效)。',
randomMixBlacklistPlaceholder: '添加关键词…', randomMixBlacklistPlaceholder: '添加关键词…',
@@ -737,7 +748,7 @@ export const zhTranslation = {
sidebarDrag: '拖动以重新排序', sidebarDrag: '拖动以重新排序',
sidebarFixed: '始终显示', sidebarFixed: '始终显示',
randomNavSplitTitle: '拆分混音导航', randomNavSplitTitle: '拆分混音导航',
randomNavSplitDesc: '在侧边栏中将"随机混音"和"随机专辑"显示为独立条目,而非"创建混音"合并入口。', randomNavSplitDesc: '在侧边栏中将随机混音”、“随机专辑”和“好运混音”显示为独立条目,而非创建混音合并入口。',
tabInput: '输入', tabInput: '输入',
tabUsers: '用户', tabUsers: '用户',
tabSystem: '系统', tabSystem: '系统',
+17
View File
@@ -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;
}
+17 -2
View File
@@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Shuffle, Dices } from 'lucide-react'; import { Shuffle, Dices, Sparkles } from 'lucide-react';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
interface MixCard { interface MixCard {
icon: React.ElementType; icon: React.ElementType;
@@ -28,11 +29,25 @@ const CARDS: MixCard[] = [
export default function RandomLanding() { export default function RandomLanding() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
// RandomLanding is only reachable in "hub" nav mode, so we don't need to
// gate on randomNavMode here — availability alone is enough.
const luckyMixAvailable = useLuckyMixAvailable();
const cards = luckyMixAvailable
? [
...CARDS,
{
icon: Sparkles,
labelKey: 'randomLanding.mixByLucky',
descKey: 'randomLanding.mixByLuckyDesc',
to: '/lucky-mix',
},
]
: CARDS;
return ( return (
<div className="random-landing"> <div className="random-landing">
<div className="random-landing-grid"> <div className="random-landing-grid">
{CARDS.map(({ icon: Icon, labelKey, descKey, to }) => ( {cards.map(({ icon: Icon, labelKey, descKey, to }) => (
<button <button
key={to} key={to}
className="mix-pick-card" className="mix-pick-card"
+24 -1
View File
@@ -21,6 +21,7 @@ import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, Las
import LastfmIcon from '../components/LastfmIcon'; import LastfmIcon from '../components/LastfmIcon';
import CustomSelect from '../components/CustomSelect'; import CustomSelect from '../components/CustomSelect';
import SettingsSubSection from '../components/SettingsSubSection'; import SettingsSubSection from '../components/SettingsSubSection';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker'; import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle, type LyricsSourceId, type LyricsSourceConfig, type LoggingMode } from '../store/authStore'; import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle, type LyricsSourceId, type LyricsSourceConfig, type LoggingMode } from '../store/authStore';
@@ -2658,6 +2659,25 @@ export default function Settings() {
<div className="divider" style={{ margin: '1rem 0' }} /> <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={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
{AUDIOBOOK_GENRES_DISPLAY.map(genre => ( {AUDIOBOOK_GENRES_DISPLAY.map(genre => (
@@ -4394,11 +4414,14 @@ function SidebarCustomizer() {
itemsRef.current = items; itemsRef.current = items;
const randomNavMode = useAuthStore(s => s.randomNavMode); const randomNavMode = useAuthStore(s => s.randomNavMode);
const setRandomNavMode = useAuthStore(s => s.setRandomNavMode); const setRandomNavMode = useAuthStore(s => s.setRandomNavMode);
const luckyMixBase = useLuckyMixAvailable();
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
const libraryItems = items.filter(cfg => { const libraryItems = items.filter(cfg => {
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false; 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 (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
return true; return true;
}); });
const systemItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system'); const systemItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system');
+5
View File
@@ -138,6 +138,8 @@ interface AuthState {
mixMinRatingAlbum: number; mixMinRatingAlbum: number;
/** 0 = ignore; artist rating from payload / nested OpenSubsonic fields or `getArtist`. */ /** 0 = ignore; artist rating from payload / nested OpenSubsonic fields or `getArtist`. */
mixMinRatingArtist: number; 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). */ /** Subsonic music folders for the active server (not persisted; refetched on login / server change). */
musicFolders: Array<{ id: string; name: string }>; musicFolders: Array<{ id: string; name: string }>;
@@ -251,6 +253,7 @@ interface AuthState {
setMixMinRatingSong: (v: number) => void; setMixMinRatingSong: (v: number) => void;
setMixMinRatingAlbum: (v: number) => void; setMixMinRatingAlbum: (v: number) => void;
setMixMinRatingArtist: (v: number) => void; setMixMinRatingArtist: (v: number) => void;
setShowLuckyMixMenu: (v: boolean) => void;
setMusicFolders: (folders: Array<{ id: string; name: string }>) => void; setMusicFolders: (folders: Array<{ id: string; name: string }>) => void;
setMusicLibraryFilter: (folderId: 'all' | string) => void; setMusicLibraryFilter: (folderId: 'all' | string) => void;
@@ -361,6 +364,7 @@ export const useAuthStore = create<AuthState>()(
mixMinRatingSong: 0, mixMinRatingSong: 0,
mixMinRatingAlbum: 0, mixMinRatingAlbum: 0,
mixMinRatingArtist: 0, mixMinRatingArtist: 0,
showLuckyMixMenu: true,
randomNavMode: 'hub', randomNavMode: 'hub',
musicFolders: [], musicFolders: [],
musicLibraryFilterByServer: {}, musicLibraryFilterByServer: {},
@@ -528,6 +532,7 @@ export const useAuthStore = create<AuthState>()(
setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }), setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }),
setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }), setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }),
setMixMinRatingArtist: (v) => set({ mixMinRatingArtist: clampMixFilterMinStars(v) }), setMixMinRatingArtist: (v) => set({ mixMinRatingArtist: clampMixFilterMinStars(v) }),
setShowLuckyMixMenu: (v) => set({ showLuckyMixMenu: v }),
setRandomNavMode: (v) => set({ randomNavMode: v }), setRandomNavMode: (v) => set({ randomNavMode: v }),
setMusicFolders: (folders) => { setMusicFolders: (folders) => {
+23
View File
@@ -0,0 +1,23 @@
import { create } from 'zustand';
interface LuckyMixState {
/** True while `buildAndPlayLuckyMix` is actively assembling a mix. */
isRolling: boolean;
/**
* Set by `cancel()` the build loop polls this between awaits and bails
* out silently when true. Reset to false on `start()` so a new build can
* run after a cancelled one.
*/
cancelRequested: boolean;
start: () => void;
stop: () => void;
cancel: () => void;
}
export const useLuckyMixStore = create<LuckyMixState>((set) => ({
isRolling: false,
cancelRequested: false,
start: () => set({ isRolling: true, cancelRequested: false }),
stop: () => set({ isRolling: false, cancelRequested: false }),
cancel: () => set({ cancelRequested: true }),
}));
+21
View File
@@ -197,6 +197,8 @@ interface PlayerState {
enqueueAt: (tracks: Track[], insertIndex: number) => void; enqueueAt: (tracks: Track[], insertIndex: number) => void;
enqueueRadio: (tracks: Track[], artistId?: string) => void; enqueueRadio: (tracks: Track[], artistId?: string) => void;
setRadioArtistId: (artistId: string) => void; setRadioArtistId: (artistId: string) => void;
/** For Lucky Mix: drop upcoming tail; keep the currently playing item only. */
pruneUpcomingToCurrent: () => void;
clearQueue: () => void; clearQueue: () => void;
isQueueVisible: boolean; isQueueVisible: boolean;
@@ -1362,6 +1364,25 @@ export const usePlayerStore = create<PlayerState>()(
if (!wasPlaying) get().resume(); 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 / resume / togglePlay ──────────────────────────────────────────
pause: () => { pause: () => {
clearAllPlaybackScheduleTimers(); clearAllPlaybackScheduleTimers();
+1
View File
@@ -15,6 +15,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
{ id: 'randomPicker', visible: true }, { id: 'randomPicker', visible: true },
{ id: 'randomMix', visible: true }, { id: 'randomMix', visible: true },
{ id: 'randomAlbums', visible: true }, { id: 'randomAlbums', visible: true },
{ id: 'luckyMix', visible: true },
{ id: 'artists', visible: true }, { id: 'artists', visible: true },
{ id: 'genres', visible: true }, { id: 'genres', visible: true },
{ id: 'favorites', visible: true }, { id: 'favorites', visible: true },
+110
View File
@@ -2427,6 +2427,116 @@
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
} }
/* ─ Lucky Mix pips (shared by the inline queue-lucky-cube indicator) ─ */
.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%);
}
.queue-lucky-loading {
margin: 2px 10px 4px;
display: flex;
justify-content: center;
padding: 7px 10px;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
cursor: pointer;
width: calc(100% - 20px);
transition: background 140ms ease, border-color 140ms ease;
}
.queue-lucky-loading:hover {
background: color-mix(in srgb, var(--danger) 8%, transparent);
border-color: color-mix(in srgb, var(--danger) 28%, transparent);
}
.queue-lucky-loading:hover .queue-lucky-cube {
opacity: 0.55;
}
.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 ─ */
.playlist-edit-modal { .playlist-edit-modal {
max-width: 560px; max-width: 560px;
+449
View File
@@ -0,0 +1,449 @@
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, type Track } from '../store/playerStore';
import { useLuckyMixStore } from '../store/luckyMixStore';
import { isLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import { showToast } from './toast';
/**
* Sentinel thrown inside the build loop when `useLuckyMixStore.cancelRequested`
* flips to true. The `catch` handler swallows it silently (no toast, no
* queue restore, no error state) the user already moved on.
*/
class LuckyMixCancelled extends Error {
constructor() {
super('lucky-mix-cancelled');
this.name = 'LuckyMixCancelled';
}
}
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 available = isLuckyMixAvailable({
activeServerId,
audiomuseByServer: auth.audiomuseNavidromeByServer,
showLuckyMixMenu: auth.showLuckyMixMenu,
});
logStep('init', {
activeServerId,
available,
showLuckyMixMenu: auth.showLuckyMixMenu,
libraryFilter: activeServerId ? (auth.musicLibraryFilterByServer[activeServerId] ?? 'all') : 'all',
});
if (!available) {
logStep('abort_unavailable');
showToast(i18n.t('luckyMix.unavailable'), 4000, 'warning');
return;
}
// Snapshot the current queue *before* we prune — so if the build fails
// before we ever play a track, we can put it back the way it was instead
// of leaving the user with an empty player.
const playerStateBefore = usePlayerStore.getState();
const queueSnapshot: { queue: Track[]; queueIndex: number } = {
queue: [...playerStateBefore.queue],
queueIndex: playerStateBefore.queueIndex,
};
// 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();
// Per-run handles. Live outside the try so `finally`/`catch` can read
// `startedPlayback` (drives the queue-restore decision) and clean up the
// player-store subscription unconditionally.
let unsubPlayer: (() => void) | null = null;
let startedPlayback = false;
try {
const queuedIds = new Set<string>();
let allSeedSongs: SubsonicSong[] = [];
const bailIfCancelled = () => {
if (useLuckyMixStore.getState().cancelRequested) throw new LuckyMixCancelled();
};
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,
});
// Auto-cancel: once we're playing, watch the player store. If the
// current track switches to something the user picked themselves (not
// in our queuedIds set), treat that as "user moved on" and cancel the
// build so we don't later overwrite their choice with our finalised mix.
if (!unsubPlayer) {
unsubPlayer = usePlayerStore.subscribe((state, prev) => {
const prevId = prev.currentTrack?.id ?? null;
const nextId = state.currentTrack?.id ?? null;
if (nextId === prevId) return;
if (!nextId) return;
if (queuedIds.has(nextId)) return;
useLuckyMixStore.getState().cancel();
});
}
};
const appendSongsToQueue = (songs: SubsonicSong[], reason: string): number => {
if (useLuckyMixStore.getState().cancelRequested) return 0;
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();
bailIfCancelled();
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) {
bailIfCancelled();
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) {
bailIfCancelled();
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),
});
}
bailIfCancelled();
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++) {
bailIfCancelled();
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++) {
bailIfCancelled();
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++) {
bailIfCancelled();
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;
}
bailIfCancelled();
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) {
// Cancellation is a user-initiated path, not an error. Silent teardown.
if (err instanceof LuckyMixCancelled) {
logStep('cancelled');
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(() => {});
}
return;
}
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(() => {});
}
// If we failed before ever calling playTrack, the queue-prune we did up
// front left the user with nothing. Restore the snapshot so they land
// back where they were pre-click instead of in an empty player.
// If playback did start, leave it alone — their current track plus
// whatever we managed to enqueue is more useful than the old queue.
if (!startedPlayback) {
usePlayerStore.setState({
queue: queueSnapshot.queue,
queueIndex: queueSnapshot.queueIndex,
});
logStep('queue_restored_after_failure', {
restoredCount: queueSnapshot.queue.length,
});
}
showToast(i18n.t('luckyMix.failed'), 5000, 'error');
} finally {
if (unsubPlayer) { try { unsubPlayer(); } catch { /* noop */ } }
useLuckyMixStore.getState().stop();
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ export function getLibraryItemsForReorder(
): SidebarItemConfig[] { ): SidebarItemConfig[] {
return items.filter(cfg => { return items.filter(cfg => {
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false; 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 (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
return true; return true;
}); });