mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(ratings): mix cutoff filter for random flows and home, i18n
Apply the same per-axis star cutoff to random mix (multi-batch fetch until full), random albums, Hero, and Home hero/discover rows. Prefetch artist and album ratings via Subsonic when list payloads omit them. Exclude items when 0 < rating ≤ threshold; 0 or missing counts as unrated. Settings: rating filter description interpolates sidebar menu labels; Russian strings for custom title bar; short axis labels aligned across locales.
This commit is contained in:
@@ -68,6 +68,13 @@ export interface SubsonicAlbum {
|
|||||||
userRating?: number;
|
userRating?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */
|
||||||
|
export interface SubsonicOpenArtistRef {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
userRating?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SubsonicSong {
|
export interface SubsonicSong {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -84,6 +91,8 @@ export interface SubsonicSong {
|
|||||||
/** Some OpenSubsonic responses attach parent ratings on child songs. */
|
/** Some OpenSubsonic responses attach parent ratings on child songs. */
|
||||||
albumUserRating?: number;
|
albumUserRating?: number;
|
||||||
artistUserRating?: number;
|
artistUserRating?: number;
|
||||||
|
artists?: SubsonicOpenArtistRef[];
|
||||||
|
albumArtists?: SubsonicOpenArtistRef[];
|
||||||
// Audio technical info
|
// Audio technical info
|
||||||
bitRate?: number;
|
bitRate?: number;
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
@@ -260,6 +269,71 @@ export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; song
|
|||||||
return { album, songs: song ?? [] };
|
return { album, songs: song ?? [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
|
||||||
|
|
||||||
|
function parseEntityUserRating(v: unknown): number | undefined {
|
||||||
|
if (v === null || v === undefined) return undefined;
|
||||||
|
const n = typeof v === 'number' ? v : Number(v);
|
||||||
|
if (!Number.isFinite(n)) return undefined;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parallel `getArtist` calls to fill mix/album filters when list endpoints omit ratings. */
|
||||||
|
export async function prefetchArtistUserRatings(
|
||||||
|
ids: string[],
|
||||||
|
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
|
||||||
|
): Promise<Map<string, number>> {
|
||||||
|
const unique = [...new Set(ids.filter(Boolean))];
|
||||||
|
const out = new Map<string, number>();
|
||||||
|
if (!unique.length) return out;
|
||||||
|
let next = 0;
|
||||||
|
async function worker() {
|
||||||
|
for (;;) {
|
||||||
|
const i = next++;
|
||||||
|
if (i >= unique.length) return;
|
||||||
|
const id = unique[i];
|
||||||
|
try {
|
||||||
|
const { artist } = await getArtist(id);
|
||||||
|
const r = parseEntityUserRating(artist.userRating);
|
||||||
|
if (r !== undefined) out.set(id, r);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nWorkers = Math.min(concurrency, unique.length);
|
||||||
|
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parallel `getAlbum` calls when `albumList2` entries lack `userRating`. */
|
||||||
|
export async function prefetchAlbumUserRatings(
|
||||||
|
ids: string[],
|
||||||
|
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
|
||||||
|
): Promise<Map<string, number>> {
|
||||||
|
const unique = [...new Set(ids.filter(Boolean))];
|
||||||
|
const out = new Map<string, number>();
|
||||||
|
if (!unique.length) return out;
|
||||||
|
let next = 0;
|
||||||
|
async function worker() {
|
||||||
|
for (;;) {
|
||||||
|
const i = next++;
|
||||||
|
if (i >= unique.length) return;
|
||||||
|
const id = unique[i];
|
||||||
|
try {
|
||||||
|
const { album } = await getAlbum(id);
|
||||||
|
const r = parseEntityUserRating(album.userRating);
|
||||||
|
if (r !== undefined) out.set(id, r);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nWorkers = Math.min(concurrency, unique.length);
|
||||||
|
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getArtists(): Promise<SubsonicArtist[]> {
|
export async function getArtists(): Promise<SubsonicArtist[]> {
|
||||||
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
|
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
|
||||||
...libraryFilterParams(),
|
...libraryFilterParams(),
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ interface AlbumTrackListProps {
|
|||||||
currentTrack: Track | null;
|
currentTrack: Track | null;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
ratings: Record<string, number>;
|
ratings: Record<string, number>;
|
||||||
|
/** Merged after local `ratings` (e.g. skip→1★ optimistic updates). */
|
||||||
|
userRatingOverrides: Record<string, number>;
|
||||||
starredSongs: Set<string>;
|
starredSongs: Set<string>;
|
||||||
onPlaySong: (song: SubsonicSong) => void;
|
onPlaySong: (song: SubsonicSong) => void;
|
||||||
onRate: (songId: string, rating: number) => void;
|
onRate: (songId: string, rating: number) => void;
|
||||||
@@ -67,6 +69,7 @@ export default function AlbumTrackList({
|
|||||||
currentTrack,
|
currentTrack,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
ratings,
|
ratings,
|
||||||
|
userRatingOverrides,
|
||||||
starredSongs,
|
starredSongs,
|
||||||
onPlaySong,
|
onPlaySong,
|
||||||
onRate,
|
onRate,
|
||||||
@@ -252,7 +255,7 @@ export default function AlbumTrackList({
|
|||||||
return (
|
return (
|
||||||
<StarRating
|
<StarRating
|
||||||
key="rating"
|
key="rating"
|
||||||
value={ratings[song.id] ?? song.userRating ?? 0}
|
value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||||
onChange={r => onRate(song.id, r)}
|
onChange={r => onRate(song.id, r)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
+25
-2
@@ -8,8 +8,12 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { playAlbum } from '../utils/playAlbum';
|
import { playAlbum } from '../utils/playAlbum';
|
||||||
import { useIsMobile } from '../hooks/useIsMobile';
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
|
||||||
|
|
||||||
const INTERVAL_MS = 10000;
|
const INTERVAL_MS = 10000;
|
||||||
|
const HERO_ALBUM_COUNT = 8;
|
||||||
|
/** Larger pool when mix rating filter is on so we can still fill the hero strip. */
|
||||||
|
const HERO_RANDOM_POOL = 32;
|
||||||
|
|
||||||
// Crossfading background — same layer pattern as FullscreenPlayer
|
// Crossfading background — same layer pattern as FullscreenPlayer
|
||||||
function HeroBg({ url }: { url: string }) {
|
function HeroBg({ url }: { url: string }) {
|
||||||
@@ -54,14 +58,33 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||||
|
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
|
||||||
|
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
|
||||||
|
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
|
||||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||||
const [activeIdx, setActiveIdx] = useState(0);
|
const [activeIdx, setActiveIdx] = useState(0);
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (albumsProp?.length) { setAlbums(albumsProp); return; }
|
if (albumsProp?.length) { setAlbums(albumsProp); return; }
|
||||||
getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {});
|
const cfg = { ...getMixMinRatingsConfigFromAuth(), minSong: 0 };
|
||||||
}, [albumsProp, musicLibraryFilterVersion]);
|
const albumMix = cfg.enabled && (cfg.minAlbum > 0 || cfg.minArtist > 0);
|
||||||
|
const pool = albumMix ? HERO_RANDOM_POOL : HERO_ALBUM_COUNT;
|
||||||
|
getRandomAlbums(pool)
|
||||||
|
.then(async raw => {
|
||||||
|
const list = albumMix
|
||||||
|
? (await filterAlbumsByMixRatings(raw, cfg)).slice(0, HERO_ALBUM_COUNT)
|
||||||
|
: raw;
|
||||||
|
setAlbums(list);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [
|
||||||
|
albumsProp,
|
||||||
|
musicLibraryFilterVersion,
|
||||||
|
mixMinRatingFilterEnabled,
|
||||||
|
mixMinRatingAlbum,
|
||||||
|
mixMinRatingArtist,
|
||||||
|
]);
|
||||||
|
|
||||||
// Start / restart auto-advance timer
|
// Start / restart auto-advance timer
|
||||||
const startTimer = useCallback((len: number) => {
|
const startTimer = useCallback((len: number) => {
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ export default function QueuePanel() {
|
|||||||
const queue = usePlayerStore(s => s.queue);
|
const queue = usePlayerStore(s => s.queue);
|
||||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
|
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||||
const currentCoverFetchUrl = useMemo(
|
const currentCoverFetchUrl = useMemo(
|
||||||
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '',
|
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '',
|
||||||
[currentTrack?.coverArt]
|
[currentTrack?.coverArt]
|
||||||
@@ -447,7 +448,7 @@ export default function QueuePanel() {
|
|||||||
{currentTrack.year && (
|
{currentTrack.year && (
|
||||||
<div className="queue-current-sub">{currentTrack.year}</div>
|
<div className="queue-current-sub">{currentTrack.year}</div>
|
||||||
)}
|
)}
|
||||||
{renderStars(currentTrack.userRating)}
|
{renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-6
@@ -511,17 +511,15 @@ export const deTranslation = {
|
|||||||
ratingsSectionTitle: 'Bewertungen',
|
ratingsSectionTitle: 'Bewertungen',
|
||||||
ratingsSkipStarTitle: 'Überspringen für 1 Stern',
|
ratingsSkipStarTitle: 'Überspringen für 1 Stern',
|
||||||
ratingsSkipStarDesc:
|
ratingsSkipStarDesc:
|
||||||
'Nach N manuellen Überspringen desselben Titels: Server-Bewertung 1 Stern, sofern noch unbewertet.',
|
'Bei N Überspringen hintereinander: Titel auf 1 Stern setzen. Nur für zuvor unbewertete Titel.',
|
||||||
ratingsSkipStarThresholdLabel: 'Überspringer bis 1★',
|
ratingsSkipStarThresholdLabel: 'Überspringer bis 1★',
|
||||||
ratingsSkipStarThresholdHint:
|
|
||||||
'Nur wenn Sie den Titel mit Weiter / Medientaste verlassen — nicht wenn er von selbst endet. Keine Änderung, wenn der Titel schon mindestens 1 Stern hat.',
|
|
||||||
ratingsMixFilterTitle: 'Filter nach Bewertung',
|
ratingsMixFilterTitle: 'Filter nach Bewertung',
|
||||||
ratingsMixFilterDesc:
|
ratingsMixFilterDesc:
|
||||||
'Inhalte mit niedriger Bewertung in Zufallsmixen und zufälligen Alben filtern. Gleichen Stern erneut klicken, um die Schwelle auszuschalten.',
|
'Inhalte mit niedriger Bewertung in {{mix}} und {{albums}} filtern. Erneut auf den gewählten Stern klicken, um die Schwelle zu deaktivieren.',
|
||||||
ratingsMixMinSong: 'Titel (Titelbewertung)',
|
ratingsMixMinSong: 'Titel',
|
||||||
ratingsMixMinAlbum: 'Alben',
|
ratingsMixMinAlbum: 'Alben',
|
||||||
ratingsMixMinArtist: 'Interpreten',
|
ratingsMixMinArtist: 'Interpreten',
|
||||||
ratingsMixMinThresholdAria: 'Mindest-Sterne ({{label}})',
|
ratingsMixMinThresholdAria: 'Mindest-Sterne: {{label}}',
|
||||||
backupTitle: 'Backup & Wiederherstellung',
|
backupTitle: 'Backup & Wiederherstellung',
|
||||||
backupExport: 'Einstellungen exportieren',
|
backupExport: 'Einstellungen exportieren',
|
||||||
backupExportDesc: 'Speichert alle Einstellungen, Serverprofile, Last.fm-Konfiguration, Theme, EQ und Tastenkürzel in eine .psybkp-Datei. Passwörter werden im Klartext gespeichert — Datei sicher aufbewahren.',
|
backupExportDesc: 'Speichert alle Einstellungen, Serverprofile, Last.fm-Konfiguration, Theme, EQ und Tastenkürzel in eine .psybkp-Datei. Passwörter werden im Klartext gespeichert — Datei sicher aufbewahren.',
|
||||||
|
|||||||
+4
-5
@@ -496,16 +496,15 @@ export const enTranslation = {
|
|||||||
ratingsSectionTitle: 'Ratings',
|
ratingsSectionTitle: 'Ratings',
|
||||||
ratingsSkipStarTitle: 'Skip for 1 star',
|
ratingsSkipStarTitle: 'Skip for 1 star',
|
||||||
ratingsSkipStarDesc:
|
ratingsSkipStarDesc:
|
||||||
'After N manual skips of the same track, set server rating to 1 star if the track is still unrated.',
|
'After N skips in a row, set the track to 1★. Only for tracks not rated before.',
|
||||||
ratingsSkipStarThresholdLabel: 'Skips before 1★',
|
ratingsSkipStarThresholdLabel: 'Skips before 1★',
|
||||||
ratingsSkipStarThresholdHint: 'Only counts when you leave the track with Next / media “next”, not when it ends on its own. Does nothing if the track is already rated 1 star or higher.',
|
|
||||||
ratingsMixFilterTitle: 'Filter by rating',
|
ratingsMixFilterTitle: 'Filter by rating',
|
||||||
ratingsMixFilterDesc:
|
ratingsMixFilterDesc:
|
||||||
'Filter low-rated items in random mixes and random albums. Click the same star again to turn off that column’s threshold.',
|
'Filter low-rated items in {{mix}} and {{albums}}. Click the selected star again to turn off the threshold.',
|
||||||
ratingsMixMinSong: 'Songs (track rating)',
|
ratingsMixMinSong: 'Songs',
|
||||||
ratingsMixMinAlbum: 'Albums',
|
ratingsMixMinAlbum: 'Albums',
|
||||||
ratingsMixMinArtist: 'Artists',
|
ratingsMixMinArtist: 'Artists',
|
||||||
ratingsMixMinThresholdAria: 'Minimum stars ({{label}})',
|
ratingsMixMinThresholdAria: 'Minimum stars: {{label}}',
|
||||||
backupTitle: 'Backup & Restore',
|
backupTitle: 'Backup & Restore',
|
||||||
backupExport: 'Export settings',
|
backupExport: 'Export settings',
|
||||||
backupExportDesc: 'Saves all settings, server profiles, Last.fm config, theme, EQ and keybindings to a .psybkp file. Passwords are stored in plaintext — keep the file secure.',
|
backupExportDesc: 'Saves all settings, server profiles, Last.fm config, theme, EQ and keybindings to a .psybkp file. Passwords are stored in plaintext — keep the file secure.',
|
||||||
|
|||||||
+4
-6
@@ -509,17 +509,15 @@ export const frTranslation = {
|
|||||||
ratingsSectionTitle: 'Notes',
|
ratingsSectionTitle: 'Notes',
|
||||||
ratingsSkipStarTitle: 'Passer pour 1 étoile',
|
ratingsSkipStarTitle: 'Passer pour 1 étoile',
|
||||||
ratingsSkipStarDesc:
|
ratingsSkipStarDesc:
|
||||||
'Après N sauts manuels du même morceau : note serveur 1 étoile si encore sans note.',
|
'Après N sauts d’affilée : mettre le morceau à 1 étoile. Uniquement s’il n’était pas encore noté.',
|
||||||
ratingsSkipStarThresholdLabel: 'Sauts avant 1★',
|
ratingsSkipStarThresholdLabel: 'Sauts avant 1★',
|
||||||
ratingsSkipStarThresholdHint:
|
|
||||||
'Uniquement si vous quittez le morceau avec Suivant / touche média « suivant », pas en fin de lecture. Aucun effet si le morceau a déjà au moins 1 étoile.',
|
|
||||||
ratingsMixFilterTitle: 'Filtrage par note',
|
ratingsMixFilterTitle: 'Filtrage par note',
|
||||||
ratingsMixFilterDesc:
|
ratingsMixFilterDesc:
|
||||||
'Filtrer le contenu peu noté dans les mix aléatoires et les albums aléatoires. Cliquer de nouveau sur la même étoile désactive le seuil.',
|
'Filtrer le contenu peu noté dans {{mix}} et {{albums}}. Cliquer de nouveau sur l’étoile choisie désactive le seuil.',
|
||||||
ratingsMixMinSong: 'Morceaux (note du titre)',
|
ratingsMixMinSong: 'Morceaux',
|
||||||
ratingsMixMinAlbum: 'Albums',
|
ratingsMixMinAlbum: 'Albums',
|
||||||
ratingsMixMinArtist: 'Artistes',
|
ratingsMixMinArtist: 'Artistes',
|
||||||
ratingsMixMinThresholdAria: 'Étoiles minimum ({{label}})',
|
ratingsMixMinThresholdAria: 'Étoiles minimum : {{label}}',
|
||||||
backupTitle: 'Sauvegarde & Restauration',
|
backupTitle: 'Sauvegarde & Restauration',
|
||||||
backupExport: 'Exporter les paramètres',
|
backupExport: 'Exporter les paramètres',
|
||||||
backupExportDesc: 'Enregistre tous les paramètres, profils serveur, configuration Last.fm, thème, EQ et raccourcis dans un fichier .psybkp. Les mots de passe sont stockés en clair — conservez le fichier en sécurité.',
|
backupExportDesc: 'Enregistre tous les paramètres, profils serveur, configuration Last.fm, thème, EQ et raccourcis dans un fichier .psybkp. Les mots de passe sont stockés en clair — conservez le fichier en sécurité.',
|
||||||
|
|||||||
+4
-6
@@ -492,17 +492,15 @@ export const nbTranslation = {
|
|||||||
ratingsSectionTitle: 'Vurderinger',
|
ratingsSectionTitle: 'Vurderinger',
|
||||||
ratingsSkipStarTitle: 'Hopp for 1 stjerne',
|
ratingsSkipStarTitle: 'Hopp for 1 stjerne',
|
||||||
ratingsSkipStarDesc:
|
ratingsSkipStarDesc:
|
||||||
'Etter N manuelle hopp på samme spor: servervurdering 1 stjerne hvis fortsatt uvurdert.',
|
'Etter N hopp på rad: sett sporet til 1 stjerne. Bare for spor som ikke var vurdert før.',
|
||||||
ratingsSkipStarThresholdLabel: 'Hopp før 1★',
|
ratingsSkipStarThresholdLabel: 'Hopp før 1★',
|
||||||
ratingsSkipStarThresholdHint:
|
|
||||||
'Bare når du forlater sporet med Neste / medietast «neste», ikke når det slutter av seg selv. Ingenting skjer hvis sporet allerede har minst 1 stjerne.',
|
|
||||||
ratingsMixFilterTitle: 'Filtrering etter vurdering',
|
ratingsMixFilterTitle: 'Filtrering etter vurdering',
|
||||||
ratingsMixFilterDesc:
|
ratingsMixFilterDesc:
|
||||||
'Filtrer innhold med lav vurdering i tilfeldige mikser og tilfeldige album. Klikk samme stjerne igjen for å slå av terskelen.',
|
'Filtrer innhold med lav vurdering i {{mix}} og {{albums}}. Klikk på den valgte stjerna igjen for å slå av terskelen.',
|
||||||
ratingsMixMinSong: 'Spor (sporvurdering)',
|
ratingsMixMinSong: 'Spor',
|
||||||
ratingsMixMinAlbum: 'Album',
|
ratingsMixMinAlbum: 'Album',
|
||||||
ratingsMixMinArtist: 'Artister',
|
ratingsMixMinArtist: 'Artister',
|
||||||
ratingsMixMinThresholdAria: 'Minimum stjerner ({{label}})',
|
ratingsMixMinThresholdAria: 'Minimum stjerner: {{label}}',
|
||||||
backupTitle: 'Sikkerhetskopiering og gjenoppretting',
|
backupTitle: 'Sikkerhetskopiering og gjenoppretting',
|
||||||
backupExport: 'Eksporter innstillinger',
|
backupExport: 'Eksporter innstillinger',
|
||||||
backupExportDesc: 'Lagrer alle innstillinger, tjenerprofiler, Last.fm-konfigurasjon, tema, jevnstiller og tastebindinger til en .psybkp-fil. Passordet lagres i klartekst – hold filen sikker.',
|
backupExportDesc: 'Lagrer alle innstillinger, tjenerprofiler, Last.fm-konfigurasjon, tema, jevnstiller og tastebindinger til en .psybkp-fil. Passordet lagres i klartekst – hold filen sikker.',
|
||||||
|
|||||||
+4
-6
@@ -509,17 +509,15 @@ export const nlTranslation = {
|
|||||||
ratingsSectionTitle: 'Beoordelingen',
|
ratingsSectionTitle: 'Beoordelingen',
|
||||||
ratingsSkipStarTitle: 'Overslaan voor 1 ster',
|
ratingsSkipStarTitle: 'Overslaan voor 1 ster',
|
||||||
ratingsSkipStarDesc:
|
ratingsSkipStarDesc:
|
||||||
'Na N handmatige overslagen van hetzelfde nummer: serverbeoordeling 1 ster als nog niet beoordeeld.',
|
'Na N overslagen op rij: nummer op 1 ster zetten. Alleen voor nummers die nog niet beoordeeld waren.',
|
||||||
ratingsSkipStarThresholdLabel: 'Overslagen voor 1★',
|
ratingsSkipStarThresholdLabel: 'Overslagen voor 1★',
|
||||||
ratingsSkipStarThresholdHint:
|
|
||||||
'Alleen als je het nummer verlaat met Volgende / mediatoets «volgende», niet als het vanzelf stopt. Geen wijziging als het nummer al minstens 1 ster heeft.',
|
|
||||||
ratingsMixFilterTitle: 'Filter op beoordeling',
|
ratingsMixFilterTitle: 'Filter op beoordeling',
|
||||||
ratingsMixFilterDesc:
|
ratingsMixFilterDesc:
|
||||||
'Content met lage beoordeling filteren in willekeurige mixen en willekeurige albums. Klik dezelfde ster opnieuw om de drempel uit te zetten.',
|
'Content met lage beoordeling filteren in {{mix}} en {{albums}}. Klik opnieuw op de gekozen ster om de drempel uit te zetten.',
|
||||||
ratingsMixMinSong: 'Nummers (nummerbeoordeling)',
|
ratingsMixMinSong: 'Nummers',
|
||||||
ratingsMixMinAlbum: 'Albums',
|
ratingsMixMinAlbum: 'Albums',
|
||||||
ratingsMixMinArtist: 'Artiesten',
|
ratingsMixMinArtist: 'Artiesten',
|
||||||
ratingsMixMinThresholdAria: 'Minimum sterren ({{label}})',
|
ratingsMixMinThresholdAria: 'Minimum sterren: {{label}}',
|
||||||
backupTitle: 'Back-up & Herstel',
|
backupTitle: 'Back-up & Herstel',
|
||||||
backupExport: 'Instellingen exporteren',
|
backupExport: 'Instellingen exporteren',
|
||||||
backupExportDesc: 'Slaat alle instellingen, serverprofielen, Last.fm-configuratie, thema, EQ en sneltoetsen op in een .psybkp-bestand. Wachtwoorden worden opgeslagen als leesbare tekst — bewaar het bestand veilig.',
|
backupExportDesc: 'Slaat alle instellingen, serverprofielen, Last.fm-configuratie, thema, EQ en sneltoetsen op in een .psybkp-bestand. Wachtwoorden worden opgeslagen als leesbare tekst — bewaar het bestand veilig.',
|
||||||
|
|||||||
+5
-4
@@ -460,6 +460,9 @@ export const ruTranslation = {
|
|||||||
showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.',
|
showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.',
|
||||||
minimizeToTray: 'Сворачивать в трей',
|
minimizeToTray: 'Сворачивать в трей',
|
||||||
minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.',
|
minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.',
|
||||||
|
useCustomTitlebar: 'Своя строка заголовка',
|
||||||
|
useCustomTitlebarDesc:
|
||||||
|
'Заменить системную строку заголовка встроенной, в стиле темы приложения. Отключите, чтобы использовать родную строку GNOME/GTK.',
|
||||||
discordRichPresence: 'Статус в Discord',
|
discordRichPresence: 'Статус в Discord',
|
||||||
discordRichPresenceDesc:
|
discordRichPresenceDesc:
|
||||||
'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
|
'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
|
||||||
@@ -514,13 +517,11 @@ export const ruTranslation = {
|
|||||||
ratingsSectionTitle: 'Рейтинги',
|
ratingsSectionTitle: 'Рейтинги',
|
||||||
ratingsSkipStarTitle: 'Скипнуть для 1 звезды',
|
ratingsSkipStarTitle: 'Скипнуть для 1 звезды',
|
||||||
ratingsSkipStarDesc:
|
ratingsSkipStarDesc:
|
||||||
'N ручных скипов трека — 1 звезда рейтинга, если трек ещё без оценки.',
|
'При N скипов подряд ставить 1★ треку. Только для не оцененных ранее.',
|
||||||
ratingsSkipStarThresholdLabel: 'Скипов',
|
ratingsSkipStarThresholdLabel: 'Скипов',
|
||||||
ratingsSkipStarThresholdHint:
|
|
||||||
'Считаются только переходы «Далее» / медиаклавиша «следующий», не окончание трека само по себе. Не меняет оценку, если у трека уже не ниже 1★.',
|
|
||||||
ratingsMixFilterTitle: 'Фильтрация по рейтингу',
|
ratingsMixFilterTitle: 'Фильтрация по рейтингу',
|
||||||
ratingsMixFilterDesc:
|
ratingsMixFilterDesc:
|
||||||
'Фильтровать с низким рейтингом в Случайных миксах и Случайных альбомах. Повторный клик по выбранной звезде отключает порог.',
|
'Фильтровать с низким рейтингом в «{{mix}}» и «{{albums}}». Повторный клик по выбранной звезде отключает порог.',
|
||||||
ratingsMixMinSong: 'Песни',
|
ratingsMixMinSong: 'Песни',
|
||||||
ratingsMixMinAlbum: 'Альбомы',
|
ratingsMixMinAlbum: 'Альбомы',
|
||||||
ratingsMixMinArtist: 'Исполнители',
|
ratingsMixMinArtist: 'Исполнители',
|
||||||
|
|||||||
+4
-6
@@ -489,17 +489,15 @@ export const zhTranslation = {
|
|||||||
ratingsSectionTitle: '评分',
|
ratingsSectionTitle: '评分',
|
||||||
ratingsSkipStarTitle: '跳过以评 1 星',
|
ratingsSkipStarTitle: '跳过以评 1 星',
|
||||||
ratingsSkipStarDesc:
|
ratingsSkipStarDesc:
|
||||||
'同一曲目手动跳过 N 次后,若尚未评分则在服务器上设为 1 星。',
|
'连续跳过 N 次后将曲目设为 1 星。仅适用于此前未评分的曲目。',
|
||||||
ratingsSkipStarThresholdLabel: '跳过次数(至 1★)',
|
ratingsSkipStarThresholdLabel: '跳过次数(至 1★)',
|
||||||
ratingsSkipStarThresholdHint:
|
|
||||||
'仅统计用“下一曲”或媒体键离开该曲的情况;自然播放结束不计入。曲目已有 1 星或以上时不改变。',
|
|
||||||
ratingsMixFilterTitle: '按评分筛选',
|
ratingsMixFilterTitle: '按评分筛选',
|
||||||
ratingsMixFilterDesc:
|
ratingsMixFilterDesc:
|
||||||
'在随机混合与随机专辑中筛选低评分内容。再次点击同一颗星可关闭该列阈值。',
|
'在{{mix}}与{{albums}}中筛选低评分内容。再次点击所选星标可关闭阈值。',
|
||||||
ratingsMixMinSong: '歌曲(曲目评分)',
|
ratingsMixMinSong: '歌曲',
|
||||||
ratingsMixMinAlbum: '专辑',
|
ratingsMixMinAlbum: '专辑',
|
||||||
ratingsMixMinArtist: '艺人',
|
ratingsMixMinArtist: '艺人',
|
||||||
ratingsMixMinThresholdAria: '最低星数({{label}})',
|
ratingsMixMinThresholdAria: '最低星数:{{label}}',
|
||||||
backupTitle: '备份与恢复',
|
backupTitle: '备份与恢复',
|
||||||
backupExport: '导出设置',
|
backupExport: '导出设置',
|
||||||
backupExportDesc: '将所有设置、服务器配置、Last.fm 配置、主题、均衡器和快捷键保存到 .psybkp 文件。密码以明文存储——请妥善保管该文件。',
|
backupExportDesc: '将所有设置、服务器配置、Last.fm 配置、主题、均衡器和快捷键保存到 .psybkp 文件。密码以明文存储——请妥善保管该文件。',
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export default function AlbumDetail() {
|
|||||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||||
|
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||||
|
|
||||||
@@ -137,6 +138,7 @@ const handleEnqueueAll = () => {
|
|||||||
|
|
||||||
const handleRate = async (songId: string, rating: number) => {
|
const handleRate = async (songId: string, rating: number) => {
|
||||||
setRatings(r => ({ ...r, [songId]: rating }));
|
setRatings(r => ({ ...r, [songId]: rating }));
|
||||||
|
usePlayerStore.getState().setUserRatingOverride(songId, rating);
|
||||||
await setRating(songId, rating);
|
await setRating(songId, rating);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -331,6 +333,7 @@ const handleEnqueueAll = () => {
|
|||||||
currentTrack={currentTrack}
|
currentTrack={currentTrack}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
ratings={ratings}
|
ratings={ratings}
|
||||||
|
userRatingOverrides={userRatingOverrides}
|
||||||
starredSongs={new Set([
|
starredSongs={new Set([
|
||||||
...[...starredSongs].filter(id => starredOverrides[id] !== false),
|
...[...starredSongs].filter(id => starredOverrides[id] !== false),
|
||||||
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
|
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
|
||||||
|
|||||||
+56
-24
@@ -7,10 +7,19 @@ import { NavLink, useNavigate } from 'react-router-dom';
|
|||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
import { useHomeStore } from '../store/homeStore';
|
import { useHomeStore } from '../store/homeStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
|
||||||
|
|
||||||
|
/** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */
|
||||||
|
const HOME_RANDOM_FETCH = 100;
|
||||||
|
const HOME_HERO_COUNT = 8;
|
||||||
|
const HOME_DISCOVER_SLICE = 20;
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const homeSections = useHomeStore(s => s.sections);
|
const homeSections = useHomeStore(s => s.sections);
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||||
|
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
|
||||||
|
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
|
||||||
|
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
|
||||||
const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true;
|
const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true;
|
||||||
|
|
||||||
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
|
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
|
||||||
@@ -23,30 +32,50 @@ export default function Home() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
let cancelled = false;
|
||||||
getAlbumList('starred', 12).catch(() => []),
|
setLoading(true);
|
||||||
getAlbumList('newest', 12).catch(() => []),
|
(async () => {
|
||||||
getAlbumList('random', 20).catch(() => []),
|
try {
|
||||||
getAlbumList('frequent', 12).catch(() => []),
|
const mixCfg = getMixMinRatingsConfigFromAuth();
|
||||||
getAlbumList('recent', 12).catch(() => []),
|
const albumMix =
|
||||||
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
|
mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
|
||||||
]).then(([s, n, r, f, rp, artists]) => {
|
const randomSize = albumMix ? HOME_RANDOM_FETCH : HOME_DISCOVER_SLICE;
|
||||||
setStarred(s);
|
const [s, n, rRaw, f, rp, artists] = await Promise.all([
|
||||||
setRecent(n);
|
getAlbumList('starred', 12).catch(() => []),
|
||||||
setHeroAlbums(r.slice(0, 8));
|
getAlbumList('newest', 12).catch(() => []),
|
||||||
setRandom(r.slice(8));
|
getAlbumList('random', randomSize).catch(() => []),
|
||||||
setMostPlayed(f);
|
getAlbumList('frequent', 12).catch(() => []),
|
||||||
setRecentlyPlayed(rp);
|
getAlbumList('recent', 12).catch(() => []),
|
||||||
// Pick 16 random artists via Fisher-Yates shuffle
|
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
|
||||||
const shuffled = [...artists];
|
]);
|
||||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
if (cancelled) return;
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
const r = await filterAlbumsByMixRatings(rRaw, mixCfg);
|
||||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
setStarred(s);
|
||||||
|
setRecent(n);
|
||||||
|
setHeroAlbums(r.slice(0, HOME_HERO_COUNT));
|
||||||
|
setRandom(r.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE));
|
||||||
|
setMostPlayed(f);
|
||||||
|
setRecentlyPlayed(rp);
|
||||||
|
const shuffled = [...artists];
|
||||||
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||||
|
}
|
||||||
|
setRandomArtists(shuffled.slice(0, 16));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
setRandomArtists(shuffled.slice(0, 16));
|
})();
|
||||||
setLoading(false);
|
return () => { cancelled = true; };
|
||||||
}).catch(() => setLoading(false));
|
}, [
|
||||||
}, [musicLibraryFilterVersion, homeSections]);
|
musicLibraryFilterVersion,
|
||||||
|
homeSections,
|
||||||
|
mixMinRatingFilterEnabled,
|
||||||
|
mixMinRatingAlbum,
|
||||||
|
mixMinRatingArtist,
|
||||||
|
]);
|
||||||
|
|
||||||
const loadMore = async (
|
const loadMore = async (
|
||||||
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
|
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
|
||||||
@@ -55,7 +84,10 @@ export default function Home() {
|
|||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const more = await getAlbumList(type, 12, currentList.length);
|
const more = await getAlbumList(type, 12, currentList.length);
|
||||||
const newItems = more.filter(m => !currentList.find(c => c.id === m.id));
|
const mixCfg = getMixMinRatingsConfigFromAuth();
|
||||||
|
const batch =
|
||||||
|
type === 'random' ? await filterAlbumsByMixRatings(more, mixCfg) : more;
|
||||||
|
const newItems = batch.filter(m => !currentList.find(c => c.id === m.id));
|
||||||
if (newItems.length > 0) setter(prev => [...prev, ...newItems]);
|
if (newItems.length > 0) setter(prev => [...prev, ...newItems]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load more', e);
|
console.error('Failed to load more', e);
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ export default function NowPlaying() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
|
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||||
const showLyrics = useLyricsStore(s => s.showLyrics);
|
const showLyrics = useLyricsStore(s => s.showLyrics);
|
||||||
const activeTab = useLyricsStore(s => s.activeTab);
|
const activeTab = useLyricsStore(s => s.activeTab);
|
||||||
@@ -292,7 +293,7 @@ export default function NowPlaying() {
|
|||||||
{currentTrack.suffix && <span className="np-badge">{currentTrack.suffix.toUpperCase()}</span>}
|
{currentTrack.suffix && <span className="np-badge">{currentTrack.suffix.toUpperCase()}</span>}
|
||||||
{currentTrack.bitRate && <span className="np-badge">{currentTrack.bitRate} kbps</span>}
|
{currentTrack.bitRate && <span className="np-badge">{currentTrack.bitRate} kbps</span>}
|
||||||
{currentTrack.duration && <span className="np-badge">{formatTime(currentTrack.duration)}</span>}
|
{currentTrack.duration && <span className="np-badge">{formatTime(currentTrack.duration)}</span>}
|
||||||
{renderStars(currentTrack.userRating)}
|
{renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
|
||||||
<button onClick={toggleStar} className="np-star-btn"
|
<button onClick={toggleStar} className="np-star-btn"
|
||||||
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function PlaylistDetail() {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride } = usePlayerStore(
|
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride, userRatingOverrides } = usePlayerStore(
|
||||||
useShallow(s => ({
|
useShallow(s => ({
|
||||||
playTrack: s.playTrack,
|
playTrack: s.playTrack,
|
||||||
enqueue: s.enqueue,
|
enqueue: s.enqueue,
|
||||||
@@ -78,6 +78,7 @@ export default function PlaylistDetail() {
|
|||||||
isPlaying: s.isPlaying,
|
isPlaying: s.isPlaying,
|
||||||
starredOverrides: s.starredOverrides,
|
starredOverrides: s.starredOverrides,
|
||||||
setStarredOverride: s.setStarredOverride,
|
setStarredOverride: s.setStarredOverride,
|
||||||
|
userRatingOverrides: s.userRatingOverrides,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
|
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
|
||||||
@@ -354,6 +355,7 @@ export default function PlaylistDetail() {
|
|||||||
// ── Rating / Star ─────────────────────────────────────────────
|
// ── Rating / Star ─────────────────────────────────────────────
|
||||||
const handleRate = (songId: string, rating: number) => {
|
const handleRate = (songId: string, rating: number) => {
|
||||||
setRatings(prev => ({ ...prev, [songId]: rating }));
|
setRatings(prev => ({ ...prev, [songId]: rating }));
|
||||||
|
usePlayerStore.getState().setUserRatingOverride(songId, rating);
|
||||||
setRating(songId, rating).catch(() => {});
|
setRating(songId, rating).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -844,7 +846,7 @@ export default function PlaylistDetail() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'rating': return <StarRating key="rating" value={ratings[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />;
|
case 'rating': return <StarRating key="rating" value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />;
|
||||||
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
|
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
|
||||||
case 'format': return (
|
case 'format': return (
|
||||||
<div key="format" className="track-meta">
|
<div key="format" className="track-meta">
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ import AlbumCard from '../components/AlbumCard';
|
|||||||
import GenreFilterBar from '../components/GenreFilterBar';
|
import GenreFilterBar from '../components/GenreFilterBar';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
|
||||||
|
|
||||||
const ALBUM_COUNT = 30;
|
const ALBUM_COUNT = 30;
|
||||||
|
/** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */
|
||||||
|
const ALBUM_FETCH_OVERSHOOT = 100;
|
||||||
|
/** Cap genre-union size before rating prefetch (avoids hundreds of `getArtist` calls). */
|
||||||
|
const GENRE_UNION_PREFILTER_CAP = 250;
|
||||||
|
|
||||||
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||||
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
|
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
|
||||||
@@ -17,12 +22,17 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
|||||||
const j = Math.floor(Math.random() * (i + 1));
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
[union[i], union[j]] = [union[j], union[i]];
|
[union[i], union[j]] = [union[j], union[i]];
|
||||||
}
|
}
|
||||||
return union.slice(0, ALBUM_COUNT);
|
const pool = union.slice(0, GENRE_UNION_PREFILTER_CAP);
|
||||||
|
const filtered = await filterAlbumsByMixRatings(pool, getMixMinRatingsConfigFromAuth());
|
||||||
|
return filtered.slice(0, ALBUM_COUNT);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RandomAlbums() {
|
export default function RandomAlbums() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||||
|
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
|
||||||
|
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
|
||||||
|
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
|
||||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
|
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
|
||||||
@@ -34,9 +44,13 @@ export default function RandomAlbums() {
|
|||||||
loadingRef.current = true;
|
loadingRef.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const mixCfg = getMixMinRatingsConfigFromAuth();
|
||||||
|
const albumMixActive =
|
||||||
|
mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
|
||||||
|
const randomSize = albumMixActive ? Math.max(ALBUM_COUNT * 3, ALBUM_FETCH_OVERSHOOT) : ALBUM_COUNT;
|
||||||
const data = genres.length > 0
|
const data = genres.length > 0
|
||||||
? await fetchByGenres(genres)
|
? await fetchByGenres(genres)
|
||||||
: await getAlbumList('random', ALBUM_COUNT);
|
: (await filterAlbumsByMixRatings(await getAlbumList('random', randomSize), mixCfg)).slice(0, ALBUM_COUNT);
|
||||||
setAlbums(data);
|
setAlbums(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -44,7 +58,12 @@ export default function RandomAlbums() {
|
|||||||
loadingRef.current = false;
|
loadingRef.current = false;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [musicLibraryFilterVersion]);
|
}, [
|
||||||
|
musicLibraryFilterVersion,
|
||||||
|
mixMinRatingFilterEnabled,
|
||||||
|
mixMinRatingAlbum,
|
||||||
|
mixMinRatingArtist,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => { load(selectedGenres); }, [selectedGenres, load]);
|
useEffect(() => { load(selectedGenres); }, [selectedGenres, load]);
|
||||||
|
|
||||||
|
|||||||
+15
-23
@@ -1,11 +1,15 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
|
import { getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
|
||||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { Play, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react';
|
import { Play, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useDragDrop } from '../contexts/DragDropContext';
|
import { useDragDrop } from '../contexts/DragDropContext';
|
||||||
import { passesMixMinRatings } from '../utils/mixRatingFilter';
|
import {
|
||||||
|
fetchRandomMixSongsUntilFull,
|
||||||
|
getMixMinRatingsConfigFromAuth,
|
||||||
|
passesMixMinRatings,
|
||||||
|
} from '../utils/mixRatingFilter';
|
||||||
|
|
||||||
const AUDIOBOOK_GENRES = [
|
const AUDIOBOOK_GENRES = [
|
||||||
'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel',
|
'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel',
|
||||||
@@ -76,19 +80,11 @@ export default function RandomMix() {
|
|||||||
const fetchSongs = () => {
|
const fetchSongs = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setSongs([]);
|
setSongs([]);
|
||||||
getRandomSongs(50)
|
fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth())
|
||||||
.then(fetched => {
|
.then(list => {
|
||||||
const cfg = useAuthStore.getState();
|
setSongs(list);
|
||||||
const mixCfg = {
|
|
||||||
enabled: cfg.mixMinRatingFilterEnabled,
|
|
||||||
minSong: cfg.mixMinRatingSong,
|
|
||||||
minAlbum: cfg.mixMinRatingAlbum,
|
|
||||||
minArtist: cfg.mixMinRatingArtist,
|
|
||||||
};
|
|
||||||
const filtered = fetched.filter(s => passesMixMinRatings(s, mixCfg));
|
|
||||||
setSongs(filtered);
|
|
||||||
const st = new Set<string>();
|
const st = new Set<string>();
|
||||||
filtered.forEach(s => { if (s.starred) st.add(s.id); });
|
list.forEach(s => { if (s.starred) st.add(s.id); });
|
||||||
setStarredSongs(st);
|
setStarredSongs(st);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
@@ -161,15 +157,11 @@ export default function RandomMix() {
|
|||||||
setGenreMixComplete(false);
|
setGenreMixComplete(false);
|
||||||
setGenreMixSongs([]);
|
setGenreMixSongs([]);
|
||||||
try {
|
try {
|
||||||
const fetched = await getRandomSongs(50, genre, 45000);
|
const list = await fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth(), {
|
||||||
const cfg = useAuthStore.getState();
|
genre,
|
||||||
const mixCfg = {
|
timeout: 45000,
|
||||||
enabled: cfg.mixMinRatingFilterEnabled,
|
});
|
||||||
minSong: cfg.mixMinRatingSong,
|
setGenreMixSongs(list);
|
||||||
minAlbum: cfg.mixMinRatingAlbum,
|
|
||||||
minArtist: cfg.mixMinRatingArtist,
|
|
||||||
};
|
|
||||||
setGenreMixSongs(fetched.filter(s => passesMixMinRatings(s, mixCfg)));
|
|
||||||
} catch {}
|
} catch {}
|
||||||
setGenreMixLoading(false);
|
setGenreMixLoading(false);
|
||||||
setGenreMixComplete(true);
|
setGenreMixComplete(true);
|
||||||
|
|||||||
@@ -819,18 +819,18 @@ export default function Settings() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{auth.skipStarOnManualSkipsEnabled && (
|
|
||||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 10, lineHeight: 1.45 }}>
|
|
||||||
{t('settings.ratingsSkipStarThresholdHint')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="settings-section-divider" />
|
<div className="settings-section-divider" />
|
||||||
|
|
||||||
<div className="settings-toggle-row">
|
<div className="settings-toggle-row">
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 500 }}>{t('settings.ratingsMixFilterTitle')}</div>
|
<div style={{ fontWeight: 500 }}>{t('settings.ratingsMixFilterTitle')}</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.ratingsMixFilterDesc')}</div>
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
{t('settings.ratingsMixFilterDesc', {
|
||||||
|
mix: t('sidebar.randomMix'),
|
||||||
|
albums: t('sidebar.randomAlbums'),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="toggle-switch" aria-label={t('settings.ratingsMixFilterTitle')}>
|
<label className="toggle-switch" aria-label={t('settings.ratingsMixFilterTitle')}>
|
||||||
<input
|
<input
|
||||||
|
|||||||
+63
-4
@@ -64,13 +64,23 @@ interface AuthState {
|
|||||||
skipStarOnManualSkipsEnabled: boolean;
|
skipStarOnManualSkipsEnabled: boolean;
|
||||||
/** Manual skips per track before applying rating 1 (when enabled). */
|
/** Manual skips per track before applying rating 1 (when enabled). */
|
||||||
skipStarManualSkipThreshold: number;
|
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;
|
||||||
|
|
||||||
/** Planned / active filter: random mixes (and later album flows) by min stars per axis. */
|
/** Random mixes, random albums, home hero: drop non‑zero ratings at or below per‑axis thresholds (0 = unrated, kept). */
|
||||||
mixMinRatingFilterEnabled: boolean;
|
mixMinRatingFilterEnabled: boolean;
|
||||||
/** 0 = off; 1–3 = require at least that many stars on the song (UI capped at 3). */
|
/** 0 = ignore; 1–3 = cutoff (UI); exclude track rating r when 0 < r ≤ cutoff. */
|
||||||
mixMinRatingSong: number;
|
mixMinRatingSong: number;
|
||||||
/** 0–3; uses `albumUserRating` on song payload when present (OpenSubsonic). */
|
/** 0 = ignore; album entity rating from payload or `getAlbum` when missing. */
|
||||||
mixMinRatingAlbum: number;
|
mixMinRatingAlbum: number;
|
||||||
|
/** 0 = ignore; artist rating from payload / nested OpenSubsonic fields or `getArtist`. */
|
||||||
mixMinRatingArtist: number;
|
mixMinRatingArtist: number;
|
||||||
|
|
||||||
/** 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). */
|
||||||
@@ -170,6 +180,20 @@ function clampSkipStarThreshold(v: number): number {
|
|||||||
return Math.max(1, Math.min(99, Math.round(v)));
|
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>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
@@ -211,6 +235,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
hotCacheDownloadDir: '',
|
hotCacheDownloadDir: '',
|
||||||
skipStarOnManualSkipsEnabled: false,
|
skipStarOnManualSkipsEnabled: false,
|
||||||
skipStarManualSkipThreshold: 3,
|
skipStarManualSkipThreshold: 3,
|
||||||
|
skipStarManualSkipCountsByKey: {},
|
||||||
mixMinRatingFilterEnabled: false,
|
mixMinRatingFilterEnabled: false,
|
||||||
mixMinRatingSong: 0,
|
mixMinRatingSong: 0,
|
||||||
mixMinRatingAlbum: 0,
|
mixMinRatingAlbum: 0,
|
||||||
@@ -305,8 +330,39 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
|
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
|
||||||
setHotCacheDownloadDir: (v) => set({ hotCacheDownloadDir: v }),
|
setHotCacheDownloadDir: (v) => set({ hotCacheDownloadDir: v }),
|
||||||
|
|
||||||
setSkipStarOnManualSkipsEnabled: (v) => set({ skipStarOnManualSkipsEnabled: v }),
|
setSkipStarOnManualSkipsEnabled: (v) =>
|
||||||
|
set({
|
||||||
|
skipStarOnManualSkipsEnabled: v,
|
||||||
|
...(v ? {} : { skipStarManualSkipCountsByKey: {} }),
|
||||||
|
}),
|
||||||
setSkipStarManualSkipThreshold: (v) => set({ skipStarManualSkipThreshold: clampSkipStarThreshold(v) }),
|
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 }),
|
setMixMinRatingFilterEnabled: (v) => set({ mixMinRatingFilterEnabled: v }),
|
||||||
setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }),
|
setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }),
|
||||||
setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }),
|
setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }),
|
||||||
@@ -367,6 +423,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
|
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
|
||||||
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
|
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
|
||||||
mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number),
|
mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number),
|
||||||
|
skipStarManualSkipCountsByKey: sanitizeSkipStarCounts(
|
||||||
|
(state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-22
@@ -76,6 +76,9 @@ interface PlayerState {
|
|||||||
lastfmLovedCache: Record<string, boolean>;
|
lastfmLovedCache: Record<string, boolean>;
|
||||||
starredOverrides: Record<string, boolean>;
|
starredOverrides: Record<string, boolean>;
|
||||||
setStarredOverride: (id: string, starred: boolean) => void;
|
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;
|
playRadio: (station: InternetRadioStation) => void;
|
||||||
playTrack: (track: Track, queue?: Track[], manual?: boolean) => void;
|
playTrack: (track: Track, queue?: Track[], manual?: boolean) => void;
|
||||||
@@ -161,30 +164,33 @@ let seekTarget: number | null = null;
|
|||||||
// to the Rust backend before it has finished the previous one.
|
// to the Rust backend before it has finished the previous one.
|
||||||
let togglePlayLock = false;
|
let togglePlayLock = false;
|
||||||
|
|
||||||
/** Manual skip counts per track id toward optional “skip → rating 1” (in-memory). */
|
/**
|
||||||
const manualSkipStarCounts = new Map<string, number>();
|
* 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 {
|
function applySkipStarOnManualNext(skippedTrack: Track | null, manual: boolean): void {
|
||||||
if (!manual || !skippedTrack) return;
|
if (!manual || !skippedTrack) return;
|
||||||
const { skipStarOnManualSkipsEnabled, skipStarManualSkipThreshold } = useAuthStore.getState();
|
|
||||||
if (!skipStarOnManualSkipsEnabled || skipStarManualSkipThreshold < 1) return;
|
|
||||||
const id = skippedTrack.id;
|
const id = skippedTrack.id;
|
||||||
const n = (manualSkipStarCounts.get(id) ?? 0) + 1;
|
const adv = useAuthStore.getState().recordSkipStarManualAdvance(id);
|
||||||
if (n >= skipStarManualSkipThreshold) {
|
if (!adv?.crossedThreshold) return;
|
||||||
manualSkipStarCounts.delete(id);
|
const live = usePlayerStore.getState();
|
||||||
const cur = skippedTrack.userRating ?? 0;
|
const fromQueue = live.queue.find(t => t.id === id);
|
||||||
if (cur >= 1) return;
|
const cur =
|
||||||
setRating(id, 1)
|
live.userRatingOverrides[id] ??
|
||||||
.then(() => {
|
fromQueue?.userRating ??
|
||||||
usePlayerStore.setState(s => ({
|
skippedTrack.userRating ??
|
||||||
queue: s.queue.map(t => (t.id === id ? { ...t, userRating: 1 } : t)),
|
0;
|
||||||
currentTrack: s.currentTrack?.id === id ? { ...s.currentTrack, userRating: 1 } : s.currentTrack,
|
if (cur >= 1) return;
|
||||||
}));
|
setRating(id, 1)
|
||||||
})
|
.then(() => {
|
||||||
.catch(() => {});
|
usePlayerStore.setState(s => ({
|
||||||
} else {
|
queue: s.queue.map(t => (t.id === id ? { ...t, userRating: 1 } : t)),
|
||||||
manualSkipStarCounts.set(id, n);
|
currentTrack: s.currentTrack?.id === id ? { ...s.currentTrack, userRating: 1 } : s.currentTrack,
|
||||||
}
|
userRatingOverrides: { ...s.userRatingOverrides, [id]: 1 },
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── HTML5 Radio Player ────────────────────────────────────────────────────────
|
// ── HTML5 Radio Player ────────────────────────────────────────────────────────
|
||||||
@@ -361,7 +367,6 @@ function handleAudioEnded() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
|
const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
|
||||||
if (currentTrack?.id) manualSkipStarCounts.delete(currentTrack.id);
|
|
||||||
isAudioPaused = false;
|
isAudioPaused = false;
|
||||||
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
|
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -384,6 +389,9 @@ function handleAudioTrackSwitched(duration: number) {
|
|||||||
isAudioPaused = false;
|
isAudioPaused = false;
|
||||||
|
|
||||||
const store = usePlayerStore.getState();
|
const store = usePlayerStore.getState();
|
||||||
|
if (store.currentTrack?.id) {
|
||||||
|
useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id);
|
||||||
|
}
|
||||||
const { queue, queueIndex, repeatMode } = store;
|
const { queue, queueIndex, repeatMode } = store;
|
||||||
const nextIdx = queueIndex + 1;
|
const nextIdx = queueIndex + 1;
|
||||||
let nextTrack: Track | null = null;
|
let nextTrack: Track | null = null;
|
||||||
@@ -603,6 +611,19 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
lastfmLovedCache: {},
|
lastfmLovedCache: {},
|
||||||
starredOverrides: {},
|
starredOverrides: {},
|
||||||
setStarredOverride: (id, starred) => set(s => ({ starredOverrides: { ...s.starredOverrides, [id]: starred } })),
|
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,
|
isQueueVisible: true,
|
||||||
isFullscreenOpen: false,
|
isFullscreenOpen: false,
|
||||||
repeatMode: 'off',
|
repeatMode: 'off',
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
import type { SubsonicSong } from '../api/subsonic';
|
import {
|
||||||
|
getRandomSongs,
|
||||||
|
prefetchAlbumUserRatings,
|
||||||
|
prefetchArtistUserRatings,
|
||||||
|
type SubsonicAlbum,
|
||||||
|
type SubsonicSong,
|
||||||
|
} from '../api/subsonic';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
|
||||||
|
/** Target list size for Random Mix after rating filter. */
|
||||||
|
export const RANDOM_MIX_TARGET_SIZE = 50;
|
||||||
|
const RANDOM_MIX_BATCH_SIZE = 50;
|
||||||
|
/** Upper bound on `getRandomSongs` calls (avoids infinite loop if the library is tiny or the filter is extreme). */
|
||||||
|
const RANDOM_MIX_MAX_BATCHES = 40;
|
||||||
|
/** Stop if several batches in a row bring no new track ids (server keeps repeating the same set). */
|
||||||
|
const RANDOM_MIX_MAX_DUP_STREAK = 6;
|
||||||
|
|
||||||
export interface MixMinRatingsConfig {
|
export interface MixMinRatingsConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -7,21 +22,206 @@ export interface MixMinRatingsConfig {
|
|||||||
minArtist: number;
|
minArtist: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMixMinRatingsConfigFromAuth(): MixMinRatingsConfig {
|
||||||
|
const s = useAuthStore.getState();
|
||||||
|
return {
|
||||||
|
enabled: s.mixMinRatingFilterEnabled,
|
||||||
|
minSong: s.mixMinRatingSong,
|
||||||
|
minAlbum: s.mixMinRatingAlbum,
|
||||||
|
minArtist: s.mixMinRatingArtist,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function numRating(v: unknown): number | undefined {
|
||||||
|
if (v === null || v === undefined) return undefined;
|
||||||
|
const n = typeof v === 'number' ? v : Number(v);
|
||||||
|
if (!Number.isFinite(n)) return undefined;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratingFromArtistRefs(
|
||||||
|
list: Array<{ id?: string; userRating?: unknown }> | undefined,
|
||||||
|
preferId?: string,
|
||||||
|
): number | undefined {
|
||||||
|
if (!list?.length) return undefined;
|
||||||
|
if (preferId) {
|
||||||
|
const m = list.find(a => a.id === preferId);
|
||||||
|
const r = numRating(m?.userRating);
|
||||||
|
if (r !== undefined) return r;
|
||||||
|
}
|
||||||
|
for (const a of list) {
|
||||||
|
const r = numRating(a.userRating);
|
||||||
|
if (r !== undefined) return r;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Song-level artist rating: explicit field, then OpenSubsonic `artists` / `albumArtists` on the child. */
|
||||||
|
function effectiveArtistRatingForFilter(song: SubsonicSong): number | undefined {
|
||||||
|
const d = numRating(song.artistUserRating);
|
||||||
|
if (d !== undefined) return d;
|
||||||
|
const fromArtists = ratingFromArtistRefs(song.artists, song.artistId);
|
||||||
|
if (fromArtists !== undefined) return fromArtists;
|
||||||
|
return ratingFromArtistRefs(song.albumArtists, song.artistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Song-level album (parent) rating when the server puts it on the child payload. */
|
||||||
|
function effectiveAlbumRatingOnSong(song: SubsonicSong): number | undefined {
|
||||||
|
return numRating(song.albumUserRating);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Random (and future album) flows: drop songs that are below per-axis thresholds when enabled.
|
* Random mixes: when enabled, drop items with a **non-zero** rating that is **at or below** the
|
||||||
* Song axis uses `userRating` (missing = 0). Album/artist use optional OpenSubsonic-style
|
* chosen threshold (inclusive). `0` / missing = unrated, never excluded.
|
||||||
* fields on the song payload; if a threshold is positive but the value is absent, the song is kept.
|
|
||||||
*/
|
*/
|
||||||
export function passesMixMinRatings(song: SubsonicSong, c: MixMinRatingsConfig): boolean {
|
export function passesMixMinRatings(song: SubsonicSong, c: MixMinRatingsConfig): boolean {
|
||||||
if (!c.enabled) return true;
|
if (!c.enabled) return true;
|
||||||
if (c.minSong > 0 && (song.userRating ?? 0) < c.minSong) return false;
|
if (c.minSong > 0) {
|
||||||
|
const r = numRating(song.userRating);
|
||||||
|
if (r !== undefined && r > 0 && r <= c.minSong) return false;
|
||||||
|
}
|
||||||
if (c.minAlbum > 0) {
|
if (c.minAlbum > 0) {
|
||||||
const r = song.albumUserRating;
|
const r = effectiveAlbumRatingOnSong(song);
|
||||||
if (r !== undefined && r < c.minAlbum) return false;
|
if (r !== undefined && r > 0 && r <= c.minAlbum) return false;
|
||||||
}
|
}
|
||||||
if (c.minArtist > 0) {
|
if (c.minArtist > 0) {
|
||||||
const r = song.artistUserRating;
|
const r = effectiveArtistRatingForFilter(song);
|
||||||
if (r !== undefined && r < c.minArtist) return false;
|
if (r !== undefined && r > 0 && r <= c.minArtist) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MixAlbumFilterExtra {
|
||||||
|
/** From `getArtist` when list payloads omit artist rating. */
|
||||||
|
artistUserRating?: number;
|
||||||
|
/** From `getAlbum` when list payloads omit album `userRating`. */
|
||||||
|
albumUserRating?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Random album lists: album `userRating` when present; optional extra from entity fetches.
|
||||||
|
* Song axis is not on this payload. `0` / missing = unrated, keep.
|
||||||
|
*/
|
||||||
|
export function passesMixMinRatingsForAlbum(
|
||||||
|
album: SubsonicAlbum,
|
||||||
|
c: MixMinRatingsConfig,
|
||||||
|
extra?: MixAlbumFilterExtra,
|
||||||
|
): boolean {
|
||||||
|
if (!c.enabled) return true;
|
||||||
|
if (c.minAlbum > 0) {
|
||||||
|
const r = numRating(album.userRating ?? extra?.albumUserRating);
|
||||||
|
if (r !== undefined && r > 0 && r <= c.minAlbum) return false;
|
||||||
|
}
|
||||||
|
if (c.minArtist > 0) {
|
||||||
|
const r = numRating(extra?.artistUserRating);
|
||||||
|
if (r !== undefined && r > 0 && r <= c.minArtist) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches missing entity ratings (bounded concurrency) then filters. Used for random album grids / hero.
|
||||||
|
*/
|
||||||
|
export async function filterAlbumsByMixRatings(
|
||||||
|
albums: SubsonicAlbum[],
|
||||||
|
c: MixMinRatingsConfig,
|
||||||
|
): Promise<SubsonicAlbum[]> {
|
||||||
|
if (!c.enabled) return albums;
|
||||||
|
if (c.minAlbum <= 0 && c.minArtist <= 0) return albums;
|
||||||
|
const needArtist = c.minArtist > 0;
|
||||||
|
const needAlbum = c.minAlbum > 0;
|
||||||
|
let byArtist = new Map<string, number>();
|
||||||
|
let byAlbum = new Map<string, number>();
|
||||||
|
if (needArtist) {
|
||||||
|
const ids = [...new Set(albums.map(a => a.artistId).filter(Boolean))] as string[];
|
||||||
|
byArtist = await prefetchArtistUserRatings(ids);
|
||||||
|
}
|
||||||
|
if (needAlbum) {
|
||||||
|
const ids = [...new Set(albums.filter(a => a.userRating === undefined).map(a => a.id))];
|
||||||
|
if (ids.length) byAlbum = await prefetchAlbumUserRatings(ids);
|
||||||
|
}
|
||||||
|
return albums.filter(a =>
|
||||||
|
passesMixMinRatingsForAlbum(a, c, {
|
||||||
|
artistUserRating: a.artistId ? byArtist.get(a.artistId) : undefined,
|
||||||
|
albumUserRating: byAlbum.get(a.id),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge `getArtist` / `getAlbum` ratings into songs before `passesMixMinRatings` when list payloads omit them.
|
||||||
|
*/
|
||||||
|
export async function enrichSongsForMixRatingFilter(
|
||||||
|
songs: SubsonicSong[],
|
||||||
|
c: MixMinRatingsConfig,
|
||||||
|
): Promise<SubsonicSong[]> {
|
||||||
|
if (!c.enabled || (c.minArtist <= 0 && c.minAlbum <= 0)) return songs;
|
||||||
|
const artistIds =
|
||||||
|
c.minArtist > 0
|
||||||
|
? [...new Set(songs.filter(s => s.artistUserRating === undefined && effectiveArtistRatingForFilter(s) === undefined && s.artistId).map(s => s.artistId!))]
|
||||||
|
: [];
|
||||||
|
const albumIds =
|
||||||
|
c.minAlbum > 0
|
||||||
|
? [...new Set(songs.filter(s => s.albumUserRating === undefined && s.albumId).map(s => s.albumId!))]
|
||||||
|
: [];
|
||||||
|
const [byArtist, byAlbum] = await Promise.all([
|
||||||
|
artistIds.length ? prefetchArtistUserRatings(artistIds) : Promise.resolve(new Map<string, number>()),
|
||||||
|
albumIds.length ? prefetchAlbumUserRatings(albumIds) : Promise.resolve(new Map<string, number>()),
|
||||||
|
]);
|
||||||
|
if (!byArtist.size && !byAlbum.size) return songs;
|
||||||
|
return songs.map(s => ({
|
||||||
|
...s,
|
||||||
|
...(s.artistUserRating === undefined &&
|
||||||
|
s.artistId &&
|
||||||
|
byArtist.has(s.artistId) && { artistUserRating: byArtist.get(s.artistId)! }),
|
||||||
|
...(s.albumUserRating === undefined &&
|
||||||
|
s.albumId &&
|
||||||
|
byAlbum.has(s.albumId) && { albumUserRating: byAlbum.get(s.albumId)! }),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads random songs in batches until `RANDOM_MIX_TARGET_SIZE` pass `passesMixMinRatings` (after enrich),
|
||||||
|
* or limits are hit. When the mix rating filter is off, a single batch is used.
|
||||||
|
*/
|
||||||
|
export async function fetchRandomMixSongsUntilFull(
|
||||||
|
c: MixMinRatingsConfig,
|
||||||
|
opts?: { genre?: string; timeout?: number },
|
||||||
|
): Promise<SubsonicSong[]> {
|
||||||
|
const timeout = opts?.timeout ?? 15000;
|
||||||
|
const genre = opts?.genre;
|
||||||
|
|
||||||
|
if (!c.enabled) {
|
||||||
|
const raw = await getRandomSongs(RANDOM_MIX_BATCH_SIZE, genre, timeout);
|
||||||
|
return raw.slice(0, RANDOM_MIX_TARGET_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: SubsonicSong[] = [];
|
||||||
|
const outIds = new Set<string>();
|
||||||
|
const seenFromApi = new Set<string>();
|
||||||
|
let dupStreak = 0;
|
||||||
|
|
||||||
|
for (let b = 0; b < RANDOM_MIX_MAX_BATCHES && out.length < RANDOM_MIX_TARGET_SIZE; b++) {
|
||||||
|
const raw = await getRandomSongs(RANDOM_MIX_BATCH_SIZE, genre, timeout);
|
||||||
|
if (!raw.length) break;
|
||||||
|
|
||||||
|
const novel = raw.filter(s => !seenFromApi.has(s.id));
|
||||||
|
for (const s of raw) seenFromApi.add(s.id);
|
||||||
|
|
||||||
|
if (!novel.length) {
|
||||||
|
if (++dupStreak >= RANDOM_MIX_MAX_DUP_STREAK) break;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dupStreak = 0;
|
||||||
|
|
||||||
|
const enriched = await enrichSongsForMixRatingFilter(novel, c);
|
||||||
|
for (const s of enriched) {
|
||||||
|
if (!passesMixMinRatings(s, c) || outIds.has(s.id)) continue;
|
||||||
|
outIds.add(s.id);
|
||||||
|
out.push(s);
|
||||||
|
if (out.length >= RANDOM_MIX_TARGET_SIZE) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user