mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(ratings): skip-to-1★ threshold and mix min-rating filter
Add Ratings section under General: manual skip count before setting 1★, and per-axis minimum stars for random mixes/albums (song, album, artist). StarRating shows five stars with selection capped at three; shared max in authStore. Queue filtering via passesMixMinRatings; OpenSubsonic-style entity rating fields on types.
This commit is contained in:
@@ -81,6 +81,9 @@ export interface SubsonicSong {
|
|||||||
coverArt?: string;
|
coverArt?: string;
|
||||||
year?: number;
|
year?: number;
|
||||||
userRating?: number;
|
userRating?: number;
|
||||||
|
/** Some OpenSubsonic responses attach parent ratings on child songs. */
|
||||||
|
albumUserRating?: number;
|
||||||
|
artistUserRating?: number;
|
||||||
// Audio technical info
|
// Audio technical info
|
||||||
bitRate?: number;
|
bitRate?: number;
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
|
|||||||
@@ -5,34 +5,50 @@ export default function StarRating({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
maxStars = 5,
|
||||||
|
maxSelectable: maxSelectableProp,
|
||||||
labelKey = 'albumDetail.ratingLabel',
|
labelKey = 'albumDetail.ratingLabel',
|
||||||
|
ariaLabel,
|
||||||
className = '',
|
className = '',
|
||||||
}: {
|
}: {
|
||||||
value: number;
|
value: number;
|
||||||
onChange: (rating: number) => void;
|
onChange: (rating: number) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** Number of star buttons (1…maxStars). Default 5. */
|
||||||
|
maxStars?: number;
|
||||||
|
/** Highest selectable star (inclusive); higher stars are shown but disabled. */
|
||||||
|
maxSelectable?: number;
|
||||||
labelKey?: string;
|
labelKey?: string;
|
||||||
|
/** Overrides `t(labelKey)` for the radiogroup `aria-label` when set. */
|
||||||
|
ariaLabel?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const stars = React.useMemo(
|
||||||
|
() => Array.from({ length: Math.max(1, Math.min(5, maxStars)) }, (_, i) => i + 1),
|
||||||
|
[maxStars]
|
||||||
|
);
|
||||||
|
const selectCap = Math.min(maxSelectableProp ?? stars.length, stars.length);
|
||||||
const [hover, setHover] = React.useState(0);
|
const [hover, setHover] = React.useState(0);
|
||||||
const [pulseStar, setPulseStar] = React.useState<number | null>(null);
|
const [pulseStar, setPulseStar] = React.useState<number | null>(null);
|
||||||
const [clearShrinkStar, setClearShrinkStar] = React.useState<number | null>(null);
|
const [clearShrinkStar, setClearShrinkStar] = React.useState<number | null>(null);
|
||||||
/** After clear: ignore hover so stars stay grey until pointer leaves widget or next click */
|
/** After clear: ignore hover so stars stay grey until pointer leaves widget or next click */
|
||||||
const [suppressHoverPreview, setSuppressHoverPreview] = React.useState(false);
|
const [suppressHoverPreview, setSuppressHoverPreview] = React.useState(false);
|
||||||
|
|
||||||
|
const cappedValue = Math.min(Math.max(0, value), selectCap);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (value > 0) setSuppressHoverPreview(false);
|
if (value > 0) setSuppressHoverPreview(false);
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
const effectiveHover = suppressHoverPreview ? 0 : hover;
|
const effectiveHover = suppressHoverPreview ? 0 : Math.min(hover, selectCap);
|
||||||
const filled = (n: number) => (effectiveHover || value) >= n;
|
const filled = (n: number) => (effectiveHover || cappedValue) >= n;
|
||||||
|
|
||||||
const handleStarClick = (n: number) => {
|
const handleStarClick = (n: number) => {
|
||||||
if (disabled) return;
|
if (disabled || n > selectCap) return;
|
||||||
setSuppressHoverPreview(false);
|
setSuppressHoverPreview(false);
|
||||||
|
|
||||||
const next = value === n ? 0 : n;
|
const next = cappedValue === n ? 0 : n;
|
||||||
onChange(next);
|
onChange(next);
|
||||||
setHover(0);
|
setHover(0);
|
||||||
|
|
||||||
@@ -60,35 +76,40 @@ export default function StarRating({
|
|||||||
<div
|
<div
|
||||||
className={`star-rating${disabled ? ' star-rating--disabled' : ''}${suppressHoverPreview ? ' star-rating--suppress-hover' : ''} ${className}`.trim()}
|
className={`star-rating${disabled ? ' star-rating--disabled' : ''}${suppressHoverPreview ? ' star-rating--suppress-hover' : ''} ${className}`.trim()}
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label={t(labelKey)}
|
aria-label={ariaLabel ?? t(labelKey)}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
onMouseLeave={disabled ? undefined : handleContainerLeave}
|
onMouseLeave={disabled ? undefined : handleContainerLeave}
|
||||||
>
|
>
|
||||||
{[1, 2, 3, 4, 5].map(n => (
|
{stars.map(n => {
|
||||||
<button
|
const locked = n > selectCap;
|
||||||
key={n}
|
return (
|
||||||
type="button"
|
<button
|
||||||
className={`star ${filled(n) ? 'filled' : ''}${pulseStar === n ? ' star--pulse' : ''}${clearShrinkStar === n ? ' star--clear-shrink' : ''}`}
|
key={n}
|
||||||
onMouseEnter={() => !disabled && !suppressHoverPreview && setHover(n)}
|
type="button"
|
||||||
onClick={() => handleStarClick(n)}
|
className={`star ${filled(n) ? 'filled' : ''}${pulseStar === n ? ' star--pulse' : ''}${clearShrinkStar === n ? ' star--clear-shrink' : ''}${locked ? ' star--locked' : ''}`}
|
||||||
onAnimationEnd={e => {
|
onMouseEnter={() =>
|
||||||
if (e.currentTarget !== e.target) return;
|
!disabled && !suppressHoverPreview && !locked && setHover(n)
|
||||||
const name = e.animationName;
|
|
||||||
if (name === 'star-rating-star-pulse') {
|
|
||||||
setPulseStar(s => (s === n ? null : s));
|
|
||||||
}
|
}
|
||||||
if (name === 'star-rating-star-clear-shrink') {
|
onClick={() => handleStarClick(n)}
|
||||||
setClearShrinkStar(s => (s === n ? null : s));
|
onAnimationEnd={e => {
|
||||||
}
|
if (e.currentTarget !== e.target) return;
|
||||||
}}
|
const name = e.animationName;
|
||||||
disabled={disabled}
|
if (name === 'star-rating-star-pulse') {
|
||||||
aria-label={`${n}`}
|
setPulseStar(s => (s === n ? null : s));
|
||||||
role="radio"
|
}
|
||||||
aria-checked={filled(n)}
|
if (name === 'star-rating-star-clear-shrink') {
|
||||||
>
|
setClearShrinkStar(s => (s === n ? null : s));
|
||||||
★
|
}
|
||||||
</button>
|
}}
|
||||||
))}
|
disabled={disabled || locked}
|
||||||
|
aria-label={`${n}`}
|
||||||
|
role="radio"
|
||||||
|
aria-checked={filled(n)}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -508,6 +508,20 @@ export const deTranslation = {
|
|||||||
shortcutNativeFullscreen: 'Nativer Vollbildmodus',
|
shortcutNativeFullscreen: 'Nativer Vollbildmodus',
|
||||||
tabSystem: 'System',
|
tabSystem: 'System',
|
||||||
tabGeneral: 'Allgemein',
|
tabGeneral: 'Allgemein',
|
||||||
|
ratingsSectionTitle: 'Bewertungen',
|
||||||
|
ratingsSkipStarTitle: 'Überspringen für 1 Stern',
|
||||||
|
ratingsSkipStarDesc:
|
||||||
|
'Nach N manuellen Überspringen desselben Titels: Server-Bewertung 1 Stern, sofern noch unbewertet.',
|
||||||
|
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',
|
||||||
|
ratingsMixFilterDesc:
|
||||||
|
'Inhalte mit niedriger Bewertung in Zufallsmixen und zufälligen Alben filtern. Gleichen Stern erneut klicken, um die Schwelle auszuschalten.',
|
||||||
|
ratingsMixMinSong: 'Titel (Titelbewertung)',
|
||||||
|
ratingsMixMinAlbum: 'Alben',
|
||||||
|
ratingsMixMinArtist: 'Interpreten',
|
||||||
|
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.',
|
||||||
|
|||||||
@@ -493,6 +493,19 @@ export const enTranslation = {
|
|||||||
tabServer: 'Server',
|
tabServer: 'Server',
|
||||||
tabSystem: 'System',
|
tabSystem: 'System',
|
||||||
tabGeneral: 'General',
|
tabGeneral: 'General',
|
||||||
|
ratingsSectionTitle: 'Ratings',
|
||||||
|
ratingsSkipStarTitle: 'Skip for 1 star',
|
||||||
|
ratingsSkipStarDesc:
|
||||||
|
'After N manual skips of the same track, set server rating to 1 star if the track is still unrated.',
|
||||||
|
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',
|
||||||
|
ratingsMixFilterDesc:
|
||||||
|
'Filter low-rated items in random mixes and random albums. Click the same star again to turn off that column’s threshold.',
|
||||||
|
ratingsMixMinSong: 'Songs (track rating)',
|
||||||
|
ratingsMixMinAlbum: 'Albums',
|
||||||
|
ratingsMixMinArtist: 'Artists',
|
||||||
|
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.',
|
||||||
|
|||||||
@@ -506,6 +506,20 @@ export const frTranslation = {
|
|||||||
shortcutNativeFullscreen: 'Plein écran natif',
|
shortcutNativeFullscreen: 'Plein écran natif',
|
||||||
tabSystem: 'Système',
|
tabSystem: 'Système',
|
||||||
tabGeneral: 'Général',
|
tabGeneral: 'Général',
|
||||||
|
ratingsSectionTitle: 'Notes',
|
||||||
|
ratingsSkipStarTitle: 'Passer pour 1 étoile',
|
||||||
|
ratingsSkipStarDesc:
|
||||||
|
'Après N sauts manuels du même morceau : note serveur 1 étoile si encore sans note.',
|
||||||
|
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',
|
||||||
|
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.',
|
||||||
|
ratingsMixMinSong: 'Morceaux (note du titre)',
|
||||||
|
ratingsMixMinAlbum: 'Albums',
|
||||||
|
ratingsMixMinArtist: 'Artistes',
|
||||||
|
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é.',
|
||||||
|
|||||||
@@ -489,6 +489,20 @@ export const nbTranslation = {
|
|||||||
tabServer: 'Tjener',
|
tabServer: 'Tjener',
|
||||||
tabSystem: 'System',
|
tabSystem: 'System',
|
||||||
tabGeneral: 'Generelt',
|
tabGeneral: 'Generelt',
|
||||||
|
ratingsSectionTitle: 'Vurderinger',
|
||||||
|
ratingsSkipStarTitle: 'Hopp for 1 stjerne',
|
||||||
|
ratingsSkipStarDesc:
|
||||||
|
'Etter N manuelle hopp på samme spor: servervurdering 1 stjerne hvis fortsatt uvurdert.',
|
||||||
|
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',
|
||||||
|
ratingsMixFilterDesc:
|
||||||
|
'Filtrer innhold med lav vurdering i tilfeldige mikser og tilfeldige album. Klikk samme stjerne igjen for å slå av terskelen.',
|
||||||
|
ratingsMixMinSong: 'Spor (sporvurdering)',
|
||||||
|
ratingsMixMinAlbum: 'Album',
|
||||||
|
ratingsMixMinArtist: 'Artister',
|
||||||
|
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.',
|
||||||
|
|||||||
@@ -506,6 +506,20 @@ export const nlTranslation = {
|
|||||||
shortcutNativeFullscreen: 'Systeemvolledig scherm',
|
shortcutNativeFullscreen: 'Systeemvolledig scherm',
|
||||||
tabSystem: 'Systeem',
|
tabSystem: 'Systeem',
|
||||||
tabGeneral: 'Algemeen',
|
tabGeneral: 'Algemeen',
|
||||||
|
ratingsSectionTitle: 'Beoordelingen',
|
||||||
|
ratingsSkipStarTitle: 'Overslaan voor 1 ster',
|
||||||
|
ratingsSkipStarDesc:
|
||||||
|
'Na N handmatige overslagen van hetzelfde nummer: serverbeoordeling 1 ster als nog niet beoordeeld.',
|
||||||
|
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',
|
||||||
|
ratingsMixFilterDesc:
|
||||||
|
'Content met lage beoordeling filteren in willekeurige mixen en willekeurige albums. Klik dezelfde ster opnieuw om de drempel uit te zetten.',
|
||||||
|
ratingsMixMinSong: 'Nummers (nummerbeoordeling)',
|
||||||
|
ratingsMixMinAlbum: 'Albums',
|
||||||
|
ratingsMixMinArtist: 'Artiesten',
|
||||||
|
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.',
|
||||||
|
|||||||
@@ -511,6 +511,20 @@ export const ruTranslation = {
|
|||||||
tabServer: 'Сервер',
|
tabServer: 'Сервер',
|
||||||
tabSystem: 'Система',
|
tabSystem: 'Система',
|
||||||
tabGeneral: 'Общие',
|
tabGeneral: 'Общие',
|
||||||
|
ratingsSectionTitle: 'Рейтинги',
|
||||||
|
ratingsSkipStarTitle: 'Скипнуть для 1 звезды',
|
||||||
|
ratingsSkipStarDesc:
|
||||||
|
'N ручных скипов трека — 1 звезда рейтинга, если трек ещё без оценки.',
|
||||||
|
ratingsSkipStarThresholdLabel: 'Скипов',
|
||||||
|
ratingsSkipStarThresholdHint:
|
||||||
|
'Считаются только переходы «Далее» / медиаклавиша «следующий», не окончание трека само по себе. Не меняет оценку, если у трека уже не ниже 1★.',
|
||||||
|
ratingsMixFilterTitle: 'Фильтрация по рейтингу',
|
||||||
|
ratingsMixFilterDesc:
|
||||||
|
'Фильтровать с низким рейтингом в Случайных миксах и Случайных альбомах. Повторный клик по выбранной звезде отключает порог.',
|
||||||
|
ratingsMixMinSong: 'Песни',
|
||||||
|
ratingsMixMinAlbum: 'Альбомы',
|
||||||
|
ratingsMixMinArtist: 'Исполнители',
|
||||||
|
ratingsMixMinThresholdAria: 'Минимум звёзд: {{label}}',
|
||||||
backupTitle: 'Резервная копия',
|
backupTitle: 'Резервная копия',
|
||||||
backupExport: 'Экспорт настроек',
|
backupExport: 'Экспорт настроек',
|
||||||
backupExportDesc:
|
backupExportDesc:
|
||||||
|
|||||||
@@ -486,6 +486,20 @@ export const zhTranslation = {
|
|||||||
tabServer: '服务器',
|
tabServer: '服务器',
|
||||||
tabSystem: '系统',
|
tabSystem: '系统',
|
||||||
tabGeneral: '通用',
|
tabGeneral: '通用',
|
||||||
|
ratingsSectionTitle: '评分',
|
||||||
|
ratingsSkipStarTitle: '跳过以评 1 星',
|
||||||
|
ratingsSkipStarDesc:
|
||||||
|
'同一曲目手动跳过 N 次后,若尚未评分则在服务器上设为 1 星。',
|
||||||
|
ratingsSkipStarThresholdLabel: '跳过次数(至 1★)',
|
||||||
|
ratingsSkipStarThresholdHint:
|
||||||
|
'仅统计用“下一曲”或媒体键离开该曲的情况;自然播放结束不计入。曲目已有 1 星或以上时不改变。',
|
||||||
|
ratingsMixFilterTitle: '按评分筛选',
|
||||||
|
ratingsMixFilterDesc:
|
||||||
|
'在随机混合与随机专辑中筛选低评分内容。再次点击同一颗星可关闭该列阈值。',
|
||||||
|
ratingsMixMinSong: '歌曲(曲目评分)',
|
||||||
|
ratingsMixMinAlbum: '专辑',
|
||||||
|
ratingsMixMinArtist: '艺人',
|
||||||
|
ratingsMixMinThresholdAria: '最低星数({{label}})',
|
||||||
backupTitle: '备份与恢复',
|
backupTitle: '备份与恢复',
|
||||||
backupExport: '导出设置',
|
backupExport: '导出设置',
|
||||||
backupExportDesc: '将所有设置、服务器配置、Last.fm 配置、主题、均衡器和快捷键保存到 .psybkp 文件。密码以明文存储——请妥善保管该文件。',
|
backupExportDesc: '将所有设置、服务器配置、Last.fm 配置、主题、均衡器和快捷键保存到 .psybkp 文件。密码以明文存储——请妥善保管该文件。',
|
||||||
|
|||||||
+41
-5
@@ -1,10 +1,11 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
|
import { getRandomSongs, 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';
|
||||||
|
|
||||||
const AUDIOBOOK_GENRES = [
|
const AUDIOBOOK_GENRES = [
|
||||||
'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel',
|
'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel',
|
||||||
@@ -35,7 +36,26 @@ export default function RandomMix() {
|
|||||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||||
const psyDrag = useDragDrop();
|
const psyDrag = useDragDrop();
|
||||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||||
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
|
const {
|
||||||
|
excludeAudiobooks,
|
||||||
|
setExcludeAudiobooks,
|
||||||
|
customGenreBlacklist,
|
||||||
|
setCustomGenreBlacklist,
|
||||||
|
mixMinRatingFilterEnabled,
|
||||||
|
mixMinRatingSong,
|
||||||
|
mixMinRatingAlbum,
|
||||||
|
mixMinRatingArtist,
|
||||||
|
} = useAuthStore();
|
||||||
|
|
||||||
|
const mixRatingCfg = useMemo(
|
||||||
|
() => ({
|
||||||
|
enabled: mixMinRatingFilterEnabled,
|
||||||
|
minSong: mixMinRatingSong,
|
||||||
|
minAlbum: mixMinRatingAlbum,
|
||||||
|
minArtist: mixMinRatingArtist,
|
||||||
|
}),
|
||||||
|
[mixMinRatingFilterEnabled, mixMinRatingSong, mixMinRatingAlbum, mixMinRatingArtist]
|
||||||
|
);
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||||
const [addedGenre, setAddedGenre] = useState<string | null>(null);
|
const [addedGenre, setAddedGenre] = useState<string | null>(null);
|
||||||
const [addedArtist, setAddedArtist] = useState<string | null>(null);
|
const [addedArtist, setAddedArtist] = useState<string | null>(null);
|
||||||
@@ -58,9 +78,17 @@ export default function RandomMix() {
|
|||||||
setSongs([]);
|
setSongs([]);
|
||||||
getRandomSongs(50)
|
getRandomSongs(50)
|
||||||
.then(fetched => {
|
.then(fetched => {
|
||||||
setSongs(fetched);
|
const cfg = useAuthStore.getState();
|
||||||
|
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>();
|
||||||
fetched.forEach(s => { if (s.starred) st.add(s.id); });
|
filtered.forEach(s => { if (s.starred) st.add(s.id); });
|
||||||
setStarredSongs(st);
|
setStarredSongs(st);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
@@ -97,6 +125,7 @@ export default function RandomMix() {
|
|||||||
if (song.title && checkText(song.title)) return false;
|
if (song.title && checkText(song.title)) return false;
|
||||||
if (song.album && checkText(song.album)) return false;
|
if (song.album && checkText(song.album)) return false;
|
||||||
if (song.artist && checkText(song.artist)) return false;
|
if (song.artist && checkText(song.artist)) return false;
|
||||||
|
if (!passesMixMinRatings(song, mixRatingCfg)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,7 +162,14 @@ export default function RandomMix() {
|
|||||||
setGenreMixSongs([]);
|
setGenreMixSongs([]);
|
||||||
try {
|
try {
|
||||||
const fetched = await getRandomSongs(50, genre, 45000);
|
const fetched = await getRandomSongs(50, genre, 45000);
|
||||||
setGenreMixSongs(fetched);
|
const cfg = useAuthStore.getState();
|
||||||
|
const mixCfg = {
|
||||||
|
enabled: cfg.mixMinRatingFilterEnabled,
|
||||||
|
minSong: cfg.mixMinRatingSong,
|
||||||
|
minAlbum: cfg.mixMinRatingAlbum,
|
||||||
|
minArtist: cfg.mixMinRatingArtist,
|
||||||
|
};
|
||||||
|
setGenreMixSongs(fetched.filter(s => passesMixMinRatings(s, mixCfg)));
|
||||||
} catch {}
|
} catch {}
|
||||||
setGenreMixLoading(false);
|
setGenreMixLoading(false);
|
||||||
setGenreMixComplete(true);
|
setGenreMixComplete(true);
|
||||||
|
|||||||
+108
-2
@@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
||||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
||||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves
|
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { exportBackup, importBackup } from '../utils/backup';
|
import { exportBackup, importBackup } from '../utils/backup';
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
@@ -18,7 +18,7 @@ import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, Las
|
|||||||
import LastfmIcon from '../components/LastfmIcon';
|
import LastfmIcon from '../components/LastfmIcon';
|
||||||
import CustomSelect from '../components/CustomSelect';
|
import CustomSelect from '../components/CustomSelect';
|
||||||
import ThemePicker from '../components/ThemePicker';
|
import ThemePicker from '../components/ThemePicker';
|
||||||
import { useAuthStore, ServerProfile } from '../store/authStore';
|
import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS } from '../store/authStore';
|
||||||
import { IS_LINUX } from '../utils/platform';
|
import { IS_LINUX } from '../utils/platform';
|
||||||
import { useThemeStore } from '../store/themeStore';
|
import { useThemeStore } from '../store/themeStore';
|
||||||
import { useFontStore, FontId } from '../store/fontStore';
|
import { useFontStore, FontId } from '../store/fontStore';
|
||||||
@@ -32,6 +32,7 @@ import { pingWithCredentials } from '../api/subsonic';
|
|||||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Equalizer from '../components/Equalizer';
|
import Equalizer from '../components/Equalizer';
|
||||||
|
import StarRating from '../components/StarRating';
|
||||||
|
|
||||||
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
|
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
|
||||||
|
|
||||||
@@ -777,6 +778,111 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Ratings (single block under Random Mix) */}
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<Star size={18} />
|
||||||
|
<h2>{t('settings.ratingsSectionTitle')}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="settings-card">
|
||||||
|
<div className="settings-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{t('settings.ratingsSkipStarTitle')}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.ratingsSkipStarDesc')}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||||
|
{auth.skipStarOnManualSkipsEnabled && (
|
||||||
|
<>
|
||||||
|
<label htmlFor="settings-skip-star-threshold" style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>
|
||||||
|
{t('settings.ratingsSkipStarThresholdLabel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="settings-skip-star-threshold"
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={99}
|
||||||
|
value={auth.skipStarManualSkipThreshold}
|
||||||
|
onChange={e => auth.setSkipStarManualSkipThreshold(Number(e.target.value))}
|
||||||
|
style={{ width: 72, padding: '6px 10px', fontSize: 13 }}
|
||||||
|
aria-label={t('settings.ratingsSkipStarThresholdLabel')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<label className="toggle-switch" aria-label={t('settings.ratingsSkipStarTitle')}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={auth.skipStarOnManualSkipsEnabled}
|
||||||
|
onChange={e => auth.setSkipStarOnManualSkipsEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</label>
|
||||||
|
</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-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{t('settings.ratingsMixFilterTitle')}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.ratingsMixFilterDesc')}</div>
|
||||||
|
</div>
|
||||||
|
<label className="toggle-switch" aria-label={t('settings.ratingsMixFilterTitle')}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={auth.mixMinRatingFilterEnabled}
|
||||||
|
onChange={e => auth.setMixMinRatingFilterEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{auth.mixMinRatingFilterEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="settings-section-divider" />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||||||
|
gap: '1rem 0.75rem',
|
||||||
|
alignItems: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{([
|
||||||
|
{ key: 'song', label: t('settings.ratingsMixMinSong'), value: auth.mixMinRatingSong, set: auth.setMixMinRatingSong },
|
||||||
|
{ key: 'album', label: t('settings.ratingsMixMinAlbum'), value: auth.mixMinRatingAlbum, set: auth.setMixMinRatingAlbum },
|
||||||
|
{ key: 'artist', label: t('settings.ratingsMixMinArtist'), value: auth.mixMinRatingArtist, set: auth.setMixMinRatingArtist },
|
||||||
|
] as const).map(row => (
|
||||||
|
<div
|
||||||
|
key={row.key}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
minWidth: 0,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)' }}>{row.label}</span>
|
||||||
|
<StarRating
|
||||||
|
maxSelectable={MIX_MIN_RATING_FILTER_MAX_STARS}
|
||||||
|
value={row.value}
|
||||||
|
onChange={row.set}
|
||||||
|
ariaLabel={t('settings.ratingsMixMinThresholdAria', { label: row.label })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<HomeCustomizer />
|
<HomeCustomizer />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -60,6 +60,19 @@ interface AuthState {
|
|||||||
/** Parent directory; actual cache is `<dir>/psysonic-hot-cache/`. Empty = app data. */
|
/** Parent directory; actual cache is `<dir>/psysonic-hot-cache/`. Empty = app data. */
|
||||||
hotCacheDownloadDir: string;
|
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;
|
||||||
|
|
||||||
|
/** Planned / active filter: random mixes (and later album flows) by min stars per axis. */
|
||||||
|
mixMinRatingFilterEnabled: boolean;
|
||||||
|
/** 0 = off; 1–3 = require at least that many stars on the song (UI capped at 3). */
|
||||||
|
mixMinRatingSong: number;
|
||||||
|
/** 0–3; uses `albumUserRating` on song payload when present (OpenSubsonic). */
|
||||||
|
mixMinRatingAlbum: 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). */
|
||||||
musicFolders: Array<{ id: string; name: string }>;
|
musicFolders: Array<{ id: string; name: string }>;
|
||||||
/**
|
/**
|
||||||
@@ -125,6 +138,12 @@ interface AuthState {
|
|||||||
setHotCacheMaxMb: (v: number) => void;
|
setHotCacheMaxMb: (v: number) => void;
|
||||||
setHotCacheDebounceSec: (v: number) => void;
|
setHotCacheDebounceSec: (v: number) => void;
|
||||||
setHotCacheDownloadDir: (v: string) => 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;
|
setMusicFolders: (folders: Array<{ id: string; name: string }>) => void;
|
||||||
setMusicLibraryFilter: (folderId: 'all' | string) => void;
|
setMusicLibraryFilter: (folderId: 'all' | string) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
@@ -138,6 +157,19 @@ function generateId(): string {
|
|||||||
return Date.now().toString(36) + Math.random().toString(36).slice(2);
|
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)));
|
||||||
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
@@ -177,6 +209,12 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
hotCacheMaxMb: 256,
|
hotCacheMaxMb: 256,
|
||||||
hotCacheDebounceSec: 30,
|
hotCacheDebounceSec: 30,
|
||||||
hotCacheDownloadDir: '',
|
hotCacheDownloadDir: '',
|
||||||
|
skipStarOnManualSkipsEnabled: false,
|
||||||
|
skipStarManualSkipThreshold: 3,
|
||||||
|
mixMinRatingFilterEnabled: false,
|
||||||
|
mixMinRatingSong: 0,
|
||||||
|
mixMinRatingAlbum: 0,
|
||||||
|
mixMinRatingArtist: 0,
|
||||||
musicFolders: [],
|
musicFolders: [],
|
||||||
musicLibraryFilterByServer: {},
|
musicLibraryFilterByServer: {},
|
||||||
musicLibraryFilterVersion: 0,
|
musicLibraryFilterVersion: 0,
|
||||||
@@ -267,6 +305,13 @@ 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 }),
|
||||||
|
setSkipStarManualSkipThreshold: (v) => set({ skipStarManualSkipThreshold: clampSkipStarThreshold(v) }),
|
||||||
|
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) => {
|
setMusicFolders: (folders) => {
|
||||||
const sid = get().activeServerId;
|
const sid = get().activeServerId;
|
||||||
set(s => {
|
set(s => {
|
||||||
@@ -316,6 +361,14 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const { musicFolders: _mf, musicLibraryFilterVersion: _fv, ...rest } = state;
|
const { musicFolders: _mf, musicLibraryFilterVersion: _fv, ...rest } = state;
|
||||||
return rest;
|
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),
|
||||||
|
});
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware';
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { showToast } from '../utils/toast';
|
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 { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
|
||||||
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
||||||
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||||
@@ -161,6 +161,32 @@ 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>();
|
||||||
|
|
||||||
|
function applySkipStarOnManualNext(skippedTrack: Track | null, manual: boolean): void {
|
||||||
|
if (!manual || !skippedTrack) return;
|
||||||
|
const { skipStarOnManualSkipsEnabled, skipStarManualSkipThreshold } = useAuthStore.getState();
|
||||||
|
if (!skipStarOnManualSkipsEnabled || skipStarManualSkipThreshold < 1) return;
|
||||||
|
const id = skippedTrack.id;
|
||||||
|
const n = (manualSkipStarCounts.get(id) ?? 0) + 1;
|
||||||
|
if (n >= skipStarManualSkipThreshold) {
|
||||||
|
manualSkipStarCounts.delete(id);
|
||||||
|
const cur = 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,
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
} else {
|
||||||
|
manualSkipStarCounts.set(id, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── HTML5 Radio Player ────────────────────────────────────────────────────────
|
// ── HTML5 Radio Player ────────────────────────────────────────────────────────
|
||||||
// Internet radio streams are played via a native <audio> element instead of
|
// Internet radio streams are played via a native <audio> element instead of
|
||||||
// the Rust/Symphonia engine. This gives us browser-native reconnect logic,
|
// the Rust/Symphonia engine. This gives us browser-native reconnect logic,
|
||||||
@@ -335,6 +361,7 @@ 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(() => {
|
||||||
@@ -887,6 +914,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
// ── next / previous ──────────────────────────────────────────────────────
|
// ── next / previous ──────────────────────────────────────────────────────
|
||||||
next: (manual = true) => {
|
next: (manual = true) => {
|
||||||
const { queue, queueIndex, repeatMode, currentTrack } = get();
|
const { queue, queueIndex, repeatMode, currentTrack } = get();
|
||||||
|
applySkipStarOnManualNext(currentTrack, manual);
|
||||||
const nextIdx = queueIndex + 1;
|
const nextIdx = queueIndex + 1;
|
||||||
if (nextIdx < queue.length) {
|
if (nextIdx < queue.length) {
|
||||||
get().playTrack(queue[nextIdx], queue, manual);
|
get().playTrack(queue[nextIdx], queue, manual);
|
||||||
|
|||||||
@@ -3818,6 +3818,16 @@ select.input.input:focus {
|
|||||||
transform-origin: center center;
|
transform-origin: center center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.star-rating .star.star--locked {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-rating .star.star--locked:hover {
|
||||||
|
color: var(--ctp-overlay1);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes star-rating-star-pulse {
|
@keyframes star-rating-star-pulse {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { SubsonicSong } from '../api/subsonic';
|
||||||
|
|
||||||
|
export interface MixMinRatingsConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
minSong: number;
|
||||||
|
minAlbum: number;
|
||||||
|
minArtist: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Random (and future album) flows: drop songs that are below per-axis thresholds when enabled.
|
||||||
|
* Song axis uses `userRating` (missing = 0). Album/artist use optional OpenSubsonic-style
|
||||||
|
* 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 {
|
||||||
|
if (!c.enabled) return true;
|
||||||
|
if (c.minSong > 0 && (song.userRating ?? 0) < c.minSong) return false;
|
||||||
|
if (c.minAlbum > 0) {
|
||||||
|
const r = song.albumUserRating;
|
||||||
|
if (r !== undefined && r < c.minAlbum) return false;
|
||||||
|
}
|
||||||
|
if (c.minArtist > 0) {
|
||||||
|
const r = song.artistUserRating;
|
||||||
|
if (r !== undefined && r < c.minArtist) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user