mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
refactor(lucky-mix): review follow-ups from Psychotoxical
- Drop the ~110 lines of dead full-screen overlay CSS that was never
referenced in JSX (.lucky-mix-overlay*, .lucky-mix-cube*, full-screen
@keyframes). Keep the .lucky-mix-pip* rules since the inline queue
variant re-uses them.
- Extract availability gate into hooks/useLuckyMixAvailable.ts (pure
predicate + hook). Replaces five duplicated inline checks in Sidebar,
MobileMoreOverlay, RandomLanding, Settings/SidebarCustomizer, and the
internal re-check in buildAndPlayLuckyMix.
- Add a cancel mechanic:
* luckyMixStore gets a cancelRequested flag + cancel() method
* LuckyMixCancelled sentinel is thrown from a bailIfCancelled() helper
sprinkled between await boundaries in the build loop
* catch-block swallows the sentinel silently (no toast, no error state)
* auto-cancel subscription on playerStore detects manual user track
changes (anything not in queuedIds) and flips cancelRequested so the
finished mix does not later overwrite the user's choice
* inline .queue-lucky-loading dice indicator is now a button — click
triggers explicit cancel, hover fades the dice to signal the action
- Restore the queue snapshot when the build fails before ever starting
playback, so the user does not land in an empty player after an error.
If playTrack already ran, leave current state alone (their track plus
whatever was enqueued is more useful than a stale pre-click queue).
- Rework locale labels to consistently carry the "Mix" suffix so the
entry reads well next to "Random Mix" / "Random Albums" in the
sidebar: DE Glücks-Mix, ES Mezcla Suerte, FR Mix Chance, NB Lykkemiks,
NL Geluksmix, ZH 好运混音. EN stays "Lucky Mix", RU stays
«Мне повезёт». Toast/settings/randomNavSplit copy aligned to the new
names. New luckyMix.cancelTooltip key added in all 8 locales.
AudioMuse gating stays — as Cuca confirmed, the feature's quality relies
on a correct similar-track selection, so the requirement is intentional.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+90
-5
@@ -11,10 +11,23 @@ import {
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import i18n from '../i18n';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { songToTrack, usePlayerStore } from '../store/playerStore';
|
||||
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;
|
||||
@@ -141,28 +154,49 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
||||
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]);
|
||||
const available = isLuckyMixAvailable({
|
||||
activeServerId,
|
||||
audiomuseByServer: auth.audiomuseNavidromeByServer,
|
||||
showLuckyMixMenu: auth.showLuckyMixMenu,
|
||||
});
|
||||
logStep('init', {
|
||||
activeServerId,
|
||||
audiomuseEnabled,
|
||||
available,
|
||||
showLuckyMixMenu: auth.showLuckyMixMenu,
|
||||
libraryFilter: activeServerId ? (auth.musicLibraryFilterByServer[activeServerId] ?? 'all') : 'all',
|
||||
});
|
||||
if (!auth.showLuckyMixMenu || !audiomuseEnabled) {
|
||||
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[] = [];
|
||||
let startedPlayback = false;
|
||||
|
||||
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;
|
||||
@@ -180,9 +214,25 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
||||
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));
|
||||
@@ -211,6 +261,7 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
||||
};
|
||||
|
||||
const frequentAlbums = await fetchFrequentAlbumsPool();
|
||||
bailIfCancelled();
|
||||
const albumsWithPlays = frequentAlbums.filter(a => (a.playCount ?? 0) > 0);
|
||||
logStep('fetch_frequent_albums', {
|
||||
fetched: frequentAlbums.length,
|
||||
@@ -224,6 +275,7 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
||||
});
|
||||
|
||||
for (const artist of pickedArtists) {
|
||||
bailIfCancelled();
|
||||
const songs = await pickSongsForArtist(artist, 3);
|
||||
allSeedSongs = uniqueAppend(allSeedSongs, songs);
|
||||
const firstPlayable = songs.find(s => !isBlockedByRating(s));
|
||||
@@ -241,6 +293,7 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
||||
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));
|
||||
@@ -252,6 +305,7 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
bailIfCancelled();
|
||||
const rated = await pickGoodRatedSongs(new Set(allSeedSongs.map(s => s.id)), 3);
|
||||
logStep('pick_rated_songs_4plus_only', {
|
||||
ratedPickedCount: rated.length,
|
||||
@@ -267,6 +321,7 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
||||
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);
|
||||
@@ -296,6 +351,7 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
||||
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);
|
||||
@@ -317,6 +373,7 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
||||
});
|
||||
|
||||
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}`);
|
||||
@@ -328,6 +385,7 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
||||
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', {
|
||||
@@ -348,6 +406,18 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
||||
}).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) {
|
||||
@@ -357,8 +427,23 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user