mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(seekbar+ratings): 5 new seekbar styles and entity/mix rating system (PR #130)
Seekbar styles (5 new, by Psychotoxical): - Neon Glow: transparent track, played section as multi-layer glowing neon tube - Pulse Wave: flat line with animated gaussian sine pulse at playhead (rAF) - Particle Trail: particles spawn at playhead and drift with glow (rAF) - Liquid Fill: glass tube fills with liquid and animated wave surface (rAF) - Retro Tape: two spinning reels connected by tape, shrink/grow with progress (rAF) Ratings system (PR #130 by cucadmuh): - Shared StarRating component with pulse/clear animations, disabled/locked states - Entity ratings for albums and artists via OpenSubsonic probe (setEntityRatingSupport) - Skip→1★: after N consecutive manual skips, set unrated track to 1★ (persisted per server) - Mix rating filter: exclude low-rated songs/albums/artists from RandomMix, RandomAlbums, Hero, Home - Batch refill in fetchRandomMixSongsUntilFull to fill list despite strict filters - Prefetch artist/album ratings via getArtist/getAlbum for incomplete list payloads - Settings: new Ratings section for skip threshold and per-axis mix filter stars - i18n: all 7 languages (en, de, fr, nl, zh, nb, ru) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+129
-1
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { EntityRatingSupportLevel } from '../api/subsonic';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
|
||||
export interface ServerProfile {
|
||||
@@ -11,7 +12,7 @@ export interface ServerProfile {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented';
|
||||
export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
|
||||
|
||||
interface AuthState {
|
||||
// Multi-server
|
||||
@@ -63,6 +64,29 @@ interface AuthState {
|
||||
/** Parent directory; actual cache is `<dir>/psysonic-hot-cache/`. Empty = app data. */
|
||||
hotCacheDownloadDir: string;
|
||||
|
||||
/** After this many manual skips of the same track, set track rating to 1 if still unrated (below 1 star). */
|
||||
skipStarOnManualSkipsEnabled: boolean;
|
||||
/** Manual skips per track before applying rating 1 (when enabled). */
|
||||
skipStarManualSkipThreshold: number;
|
||||
/**
|
||||
* Manual Next-count per track for skip→1★. Key = `${serverId}\\u001f${trackId}`
|
||||
* (empty serverId when none). Persisted; cleared when the track finishes naturally or when threshold is reached.
|
||||
*/
|
||||
skipStarManualSkipCountsByKey: Record<string, number>;
|
||||
/** Increment skip count for current server + track; clears stored count when threshold reached. */
|
||||
recordSkipStarManualAdvance: (trackId: string) => { crossedThreshold: boolean } | null;
|
||||
/** Drop persisted skip count for this track on the active server (e.g. natural playback end). */
|
||||
clearSkipStarManualCountForTrack: (trackId: string) => void;
|
||||
|
||||
/** Random mixes, random albums, home hero: drop non‑zero ratings at or below per‑axis thresholds (0 = unrated, kept). */
|
||||
mixMinRatingFilterEnabled: boolean;
|
||||
/** 0 = ignore; 1–3 = cutoff (UI); exclude track rating r when 0 < r ≤ cutoff. */
|
||||
mixMinRatingSong: number;
|
||||
/** 0 = ignore; album entity rating from payload or `getAlbum` when missing. */
|
||||
mixMinRatingAlbum: number;
|
||||
/** 0 = ignore; artist rating from payload / nested OpenSubsonic fields or `getArtist`. */
|
||||
mixMinRatingArtist: number;
|
||||
|
||||
/** Subsonic music folders for the active server (not persisted; refetched on login / server change). */
|
||||
musicFolders: Array<{ id: string; name: string }>;
|
||||
/**
|
||||
@@ -73,6 +97,13 @@ interface AuthState {
|
||||
/** Bumps when `setMusicLibraryFilter` runs so pages refetch catalog data. */
|
||||
musicLibraryFilterVersion: number;
|
||||
|
||||
/**
|
||||
* Per server: whether `setRating` is assumed to work for album/artist ids (OpenSubsonic-style).
|
||||
* Absent key = not probed yet (`unknown` in UI).
|
||||
*/
|
||||
entityRatingSupportByServer: Record<string, EntityRatingSupportLevel>;
|
||||
setEntityRatingSupport: (serverId: string, level: EntityRatingSupportLevel) => void;
|
||||
|
||||
// Status
|
||||
isLoggedIn: boolean;
|
||||
isConnecting: boolean;
|
||||
@@ -122,6 +153,12 @@ interface AuthState {
|
||||
setHotCacheMaxMb: (v: number) => void;
|
||||
setHotCacheDebounceSec: (v: number) => void;
|
||||
setHotCacheDownloadDir: (v: string) => void;
|
||||
setSkipStarOnManualSkipsEnabled: (v: boolean) => void;
|
||||
setSkipStarManualSkipThreshold: (v: number) => void;
|
||||
setMixMinRatingFilterEnabled: (v: boolean) => void;
|
||||
setMixMinRatingSong: (v: number) => void;
|
||||
setMixMinRatingAlbum: (v: number) => void;
|
||||
setMixMinRatingArtist: (v: number) => void;
|
||||
setMusicFolders: (folders: Array<{ id: string; name: string }>) => void;
|
||||
setMusicLibraryFilter: (folderId: 'all' | string) => void;
|
||||
logout: () => void;
|
||||
@@ -135,6 +172,33 @@ function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
/** Upper bound for mix min-rating thresholds (UI shows five stars, only 1…this many are selectable). */
|
||||
export const MIX_MIN_RATING_FILTER_MAX_STARS = 3;
|
||||
|
||||
function clampMixFilterMinStars(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
return Math.max(0, Math.min(MIX_MIN_RATING_FILTER_MAX_STARS, Math.round(v)));
|
||||
}
|
||||
|
||||
function clampSkipStarThreshold(v: number): number {
|
||||
if (!Number.isFinite(v)) return 3;
|
||||
return Math.max(1, Math.min(99, Math.round(v)));
|
||||
}
|
||||
|
||||
function skipStarCountStorageKey(serverId: string | null | undefined, trackId: string): string {
|
||||
return `${serverId ?? ''}\u001f${trackId}`;
|
||||
}
|
||||
|
||||
function sanitizeSkipStarCounts(raw: unknown): Record<string, number> {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||
const next: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n) && n > 0) next[k] = Math.min(Math.floor(n), 1_000_000);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -175,9 +239,17 @@ export const useAuthStore = create<AuthState>()(
|
||||
hotCacheMaxMb: 256,
|
||||
hotCacheDebounceSec: 30,
|
||||
hotCacheDownloadDir: '',
|
||||
skipStarOnManualSkipsEnabled: false,
|
||||
skipStarManualSkipThreshold: 3,
|
||||
skipStarManualSkipCountsByKey: {},
|
||||
mixMinRatingFilterEnabled: false,
|
||||
mixMinRatingSong: 0,
|
||||
mixMinRatingAlbum: 0,
|
||||
mixMinRatingArtist: 0,
|
||||
musicFolders: [],
|
||||
musicLibraryFilterByServer: {},
|
||||
musicLibraryFilterVersion: 0,
|
||||
entityRatingSupportByServer: {},
|
||||
isLoggedIn: false,
|
||||
isConnecting: false,
|
||||
connectionError: null,
|
||||
@@ -199,10 +271,12 @@ export const useAuthStore = create<AuthState>()(
|
||||
set(s => {
|
||||
const newServers = s.servers.filter(srv => srv.id !== id);
|
||||
const switchedAway = s.activeServerId === id;
|
||||
const { [id]: _r, ...entityRatingRest } = s.entityRatingSupportByServer;
|
||||
return {
|
||||
servers: newServers,
|
||||
activeServerId: switchedAway ? (newServers[0]?.id ?? null) : s.activeServerId,
|
||||
isLoggedIn: switchedAway ? false : s.isLoggedIn,
|
||||
entityRatingSupportByServer: entityRatingRest,
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -263,6 +337,44 @@ export const useAuthStore = create<AuthState>()(
|
||||
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
|
||||
setHotCacheDownloadDir: (v) => set({ hotCacheDownloadDir: v }),
|
||||
|
||||
setSkipStarOnManualSkipsEnabled: (v) =>
|
||||
set({
|
||||
skipStarOnManualSkipsEnabled: v,
|
||||
...(v ? {} : { skipStarManualSkipCountsByKey: {} }),
|
||||
}),
|
||||
setSkipStarManualSkipThreshold: (v) => set({ skipStarManualSkipThreshold: clampSkipStarThreshold(v) }),
|
||||
|
||||
recordSkipStarManualAdvance: (trackId: string) => {
|
||||
const s = get();
|
||||
if (!s.skipStarOnManualSkipsEnabled || s.skipStarManualSkipThreshold < 1) return null;
|
||||
const key = skipStarCountStorageKey(s.activeServerId, trackId);
|
||||
const prev = s.skipStarManualSkipCountsByKey[key] ?? 0;
|
||||
const threshold = s.skipStarManualSkipThreshold;
|
||||
const next = prev + 1;
|
||||
if (next >= threshold) {
|
||||
const { [key]: _removed, ...rest } = s.skipStarManualSkipCountsByKey;
|
||||
set({ skipStarManualSkipCountsByKey: rest });
|
||||
return { crossedThreshold: true };
|
||||
}
|
||||
set({
|
||||
skipStarManualSkipCountsByKey: { ...s.skipStarManualSkipCountsByKey, [key]: next },
|
||||
});
|
||||
return { crossedThreshold: false };
|
||||
},
|
||||
|
||||
clearSkipStarManualCountForTrack: (trackId: string) => {
|
||||
const s = get();
|
||||
const key = skipStarCountStorageKey(s.activeServerId, trackId);
|
||||
if (s.skipStarManualSkipCountsByKey[key] === undefined) return;
|
||||
const { [key]: _removed, ...rest } = s.skipStarManualSkipCountsByKey;
|
||||
set({ skipStarManualSkipCountsByKey: rest });
|
||||
},
|
||||
|
||||
setMixMinRatingFilterEnabled: (v) => set({ mixMinRatingFilterEnabled: v }),
|
||||
setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }),
|
||||
setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }),
|
||||
setMixMinRatingArtist: (v) => set({ mixMinRatingArtist: clampMixFilterMinStars(v) }),
|
||||
|
||||
setMusicFolders: (folders) => {
|
||||
const sid = get().activeServerId;
|
||||
set(s => {
|
||||
@@ -286,6 +398,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
}));
|
||||
},
|
||||
|
||||
setEntityRatingSupport: (serverId, level) =>
|
||||
set(s => ({
|
||||
entityRatingSupportByServer: { ...s.entityRatingSupportByServer, [serverId]: level },
|
||||
})),
|
||||
|
||||
logout: () => set({ isLoggedIn: false, musicFolders: [] }),
|
||||
|
||||
getBaseUrl: () => {
|
||||
@@ -307,6 +424,17 @@ export const useAuthStore = create<AuthState>()(
|
||||
const { musicFolders: _mf, musicLibraryFilterVersion: _fv, ...rest } = state;
|
||||
return rest;
|
||||
},
|
||||
onRehydrateStorage: () => (state, error) => {
|
||||
if (error || !state) return;
|
||||
useAuthStore.setState({
|
||||
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
|
||||
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
|
||||
mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number),
|
||||
skipStarManualSkipCountsByKey: sanitizeSkipStarCounts(
|
||||
(state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey,
|
||||
),
|
||||
});
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation } from '../api/subsonic';
|
||||
import { buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating } from '../api/subsonic';
|
||||
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
|
||||
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
||||
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||
@@ -76,6 +76,9 @@ interface PlayerState {
|
||||
lastfmLovedCache: Record<string, boolean>;
|
||||
starredOverrides: Record<string, boolean>;
|
||||
setStarredOverride: (id: string, starred: boolean) => void;
|
||||
/** Optimistic track ratings (e.g. skip→1★ while UI lists still have stale `song.userRating`). */
|
||||
userRatingOverrides: Record<string, number>;
|
||||
setUserRatingOverride: (id: string, rating: number) => void;
|
||||
|
||||
playRadio: (station: InternetRadioStation) => void;
|
||||
playTrack: (track: Track, queue?: Track[], manual?: boolean) => void;
|
||||
@@ -161,6 +164,35 @@ let seekTarget: number | null = null;
|
||||
// to the Rust backend before it has finished the previous one.
|
||||
let togglePlayLock = false;
|
||||
|
||||
/**
|
||||
* Skip → 1★: counts in `authStore.skipStarManualSkipCountsByKey` (persisted).
|
||||
* Only user-initiated `next()` increments. Natural track end (incl. gapless) clears the count;
|
||||
* threshold reached clears count and sets 1★ if still unrated.
|
||||
*/
|
||||
function applySkipStarOnManualNext(skippedTrack: Track | null, manual: boolean): void {
|
||||
if (!manual || !skippedTrack) return;
|
||||
const id = skippedTrack.id;
|
||||
const adv = useAuthStore.getState().recordSkipStarManualAdvance(id);
|
||||
if (!adv?.crossedThreshold) return;
|
||||
const live = usePlayerStore.getState();
|
||||
const fromQueue = live.queue.find(t => t.id === id);
|
||||
const cur =
|
||||
live.userRatingOverrides[id] ??
|
||||
fromQueue?.userRating ??
|
||||
skippedTrack.userRating ??
|
||||
0;
|
||||
if (cur >= 1) return;
|
||||
setRating(id, 1)
|
||||
.then(() => {
|
||||
usePlayerStore.setState(s => ({
|
||||
queue: s.queue.map(t => (t.id === id ? { ...t, userRating: 1 } : t)),
|
||||
currentTrack: s.currentTrack?.id === id ? { ...s.currentTrack, userRating: 1 } : s.currentTrack,
|
||||
userRatingOverrides: { ...s.userRatingOverrides, [id]: 1 },
|
||||
}));
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// ── HTML5 Radio Player ────────────────────────────────────────────────────────
|
||||
// Internet radio streams are played via a native <audio> element instead of
|
||||
// the Rust/Symphonia engine. This gives us browser-native reconnect logic,
|
||||
@@ -357,6 +389,9 @@ function handleAudioTrackSwitched(duration: number) {
|
||||
isAudioPaused = false;
|
||||
|
||||
const store = usePlayerStore.getState();
|
||||
if (store.currentTrack?.id) {
|
||||
useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id);
|
||||
}
|
||||
const { queue, queueIndex, repeatMode } = store;
|
||||
const nextIdx = queueIndex + 1;
|
||||
let nextTrack: Track | null = null;
|
||||
@@ -576,6 +611,19 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
lastfmLovedCache: {},
|
||||
starredOverrides: {},
|
||||
setStarredOverride: (id, starred) => set(s => ({ starredOverrides: { ...s.starredOverrides, [id]: starred } })),
|
||||
userRatingOverrides: {},
|
||||
setUserRatingOverride: (id, rating) =>
|
||||
set(s => {
|
||||
const nextOverrides = { ...s.userRatingOverrides };
|
||||
if (rating === 0) delete nextOverrides[id];
|
||||
else nextOverrides[id] = rating;
|
||||
return {
|
||||
userRatingOverrides: nextOverrides,
|
||||
queue: s.queue.map(t => (t.id === id ? { ...t, userRating: rating } : t)),
|
||||
currentTrack:
|
||||
s.currentTrack?.id === id ? { ...s.currentTrack, userRating: rating } : s.currentTrack,
|
||||
};
|
||||
}),
|
||||
isQueueVisible: true,
|
||||
isFullscreenOpen: false,
|
||||
repeatMode: 'off',
|
||||
@@ -887,6 +935,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
// ── next / previous ──────────────────────────────────────────────────────
|
||||
next: (manual = true) => {
|
||||
const { queue, queueIndex, repeatMode, currentTrack } = get();
|
||||
applySkipStarOnManualNext(currentTrack, manual);
|
||||
const nextIdx = queueIndex + 1;
|
||||
if (nextIdx < queue.length) {
|
||||
get().playTrack(queue[nextIdx], queue, manual);
|
||||
|
||||
Reference in New Issue
Block a user