mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
release: bump to v1.34.4
Song ratings in context menu + player bar, entity ratings (PR #130), 5 new seekbar styles, custom Linux title bar, album multi-select, mix rating filter, top-rated stats, compilation filter, scroll reset. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.34.4] - 2026-04-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Entity ratings** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Full star-rating support (1–5 ★) for songs, albums, and artists via the OpenSubsonic `setRating` API. Ratings are shown and editable in the album track list, artist detail page, and the Favorites song list. A new shared `StarRating` component is used consistently across all surfaces. Requires an OpenSubsonic-compatible server (e.g. Navidrome ≥ 0.53).
|
||||||
|
|
||||||
|
- **Song ratings — context menu & player bar**: Songs can additionally be rated directly from the **right-click context menu** and from the **player bar** (below the artist name), with optimistic updates reflected immediately across all views.
|
||||||
|
|
||||||
|
- **Skip-to-1★** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Automatically assigns a 1-star rating when a song is manually skipped before a configurable playback threshold (default: 20 s). Can be enabled and adjusted in Settings → Ratings.
|
||||||
|
|
||||||
|
- **Mix minimum rating filter** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Random Mix and Home Quick Mix can now be filtered by minimum rating per entity type (song / album / artist). Configure thresholds in Settings → Ratings.
|
||||||
|
|
||||||
|
- **Statistics — Top Rated Songs & Artists**: New "Top Rated Songs" and "Top Rated Artists" sections on the Statistics page, derived from starred items with a `userRating > 0`. Lists update live as ratings are changed without a page reload.
|
||||||
|
|
||||||
|
- **Seekbar styles — 5 new styles**: Added Neon Glow, Pulse Wave, Particle Trail, Liquid Fill, and Retro Tape. Animated styles run a dedicated `requestAnimationFrame` loop. The style picker in Settings shows an animated live preview for each style.
|
||||||
|
|
||||||
|
- **Custom title bar (Linux)**: Optional custom title bar with now-playing display (song title + artist, live-updating). Replaces the native GTK decoration when enabled. Automatically hides in native fullscreen (F11). Can be toggled in Settings → Appearance.
|
||||||
|
|
||||||
|
- **Album multi-select**: Albums, New Releases, and Random Albums pages now support multi-select mode. Selected albums can be batch-queued or enqueued.
|
||||||
|
|
||||||
|
- **Most Played — compilation filter**: New toggle on the Most Played page to hide compilation artists from the Top Artists list.
|
||||||
|
|
||||||
|
- **Scroll reset on navigation**: The content area now scrolls back to the top automatically on every route change.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Backup**: The `psysonic_home` key is now included in the settings backup export.
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
- New keys for seekbar styles, entity ratings, rating sections (Settings + Statistics), and entity rating support added to all 7 languages (EN, DE, FR, NL, ZH, NB, RU).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.34.3] - 2026-04-07
|
## [1.34.3] - 2026-04-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "psysonic",
|
"name": "psysonic",
|
||||||
"version": "1.34.3",
|
"version": "1.34.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Generated
+1
-1
@@ -3339,7 +3339,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psysonic"
|
name = "psysonic"
|
||||||
version = "1.34.3"
|
version = "1.34.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"biquad",
|
"biquad",
|
||||||
"discord-rich-presence",
|
"discord-rich-presence",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "psysonic"
|
name = "psysonic"
|
||||||
version = "1.34.3"
|
version = "1.34.4"
|
||||||
description = "Psysonic Desktop Music Player"
|
description = "Psysonic Desktop Music Player"
|
||||||
authors = []
|
authors = []
|
||||||
license = ""
|
license = ""
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Psysonic",
|
"productName": "Psysonic",
|
||||||
"version": "1.34.3",
|
"version": "1.34.4",
|
||||||
"identifier": "dev.psysonic.player",
|
"identifier": "dev.psysonic.player",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ interface AlbumCardProps {
|
|||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
selectionMode?: boolean;
|
selectionMode?: boolean;
|
||||||
onToggleSelect?: (id: string) => void;
|
onToggleSelect?: (id: string) => void;
|
||||||
|
showRating?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlbumCard({ album, selected, selectionMode, onToggleSelect }: AlbumCardProps) {
|
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false }: AlbumCardProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||||
@@ -103,6 +104,13 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect }: AlbumCard
|
|||||||
onClick={e => { if (album.artistId) { e.stopPropagation(); navigate(`/artist/${album.artistId}`); } }}
|
onClick={e => { if (album.artistId) { e.stopPropagation(); navigate(`/artist/${album.artistId}`); } }}
|
||||||
>{album.artist}</p>
|
>{album.artist}</p>
|
||||||
{album.year && <p className="album-card-year">{album.year}</p>}
|
{album.year && <p className="album-card-year">{album.year}</p>}
|
||||||
|
{showRating && (album.userRating ?? 0) > 0 && (
|
||||||
|
<div className="album-card-rating-row">
|
||||||
|
<span className="album-card-rating-stars">
|
||||||
|
{'★'.repeat(album.userRating!)}{'☆'.repeat(5 - album.userRating!)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ interface Props {
|
|||||||
moreLink?: string;
|
moreLink?: string;
|
||||||
moreText?: string;
|
moreText?: string;
|
||||||
onLoadMore?: () => Promise<void>;
|
onLoadMore?: () => Promise<void>;
|
||||||
|
showRating?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore }: Props) {
|
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore, showRating }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -89,7 +90,7 @@ export default function AlbumRow({ title, titleLink, albums, moreLink, moreText,
|
|||||||
|
|
||||||
<div className="album-grid-wrapper">
|
<div className="album-grid-wrapper">
|
||||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
{albums.map(a => <AlbumCard key={a.id} album={a} showRating={showRating} />)}
|
||||||
{loadingMore && (
|
{loadingMore && (
|
||||||
<div className="album-card-more" style={{ cursor: 'default' }}>
|
<div className="album-card-more" style={{ cursor: 'default' }}>
|
||||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
|
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info } from 'lucide-react';
|
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info } from 'lucide-react';
|
||||||
import LastfmIcon from './LastfmIcon';
|
import LastfmIcon from './LastfmIcon';
|
||||||
|
import StarRating from './StarRating';
|
||||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
||||||
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
|
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
|
||||||
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||||
@@ -163,7 +164,7 @@ function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone:
|
|||||||
|
|
||||||
export default function ContextMenu() {
|
export default function ContextMenu() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo } = usePlayerStore();
|
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -381,6 +382,13 @@ export default function ContextMenu() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
<div className="context-menu-divider" />
|
<div className="context-menu-divider" />
|
||||||
|
<div className="context-menu-rating-row" onClick={e => e.stopPropagation()}>
|
||||||
|
<StarRating
|
||||||
|
value={userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||||
|
onChange={r => { setUserRatingOverride(song.id, r); setRating(song.id, r).catch(() => {}); }}
|
||||||
|
ariaLabel={t('albumDetail.ratingLabel')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||||
</div>
|
</div>
|
||||||
@@ -501,6 +509,13 @@ export default function ContextMenu() {
|
|||||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-divider" />
|
<div className="context-menu-divider" />
|
||||||
|
<div className="context-menu-rating-row" onClick={e => e.stopPropagation()}>
|
||||||
|
<StarRating
|
||||||
|
value={userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||||
|
onChange={r => { setUserRatingOverride(song.id, r); setRating(song.id, r).catch(() => {}); }}
|
||||||
|
ariaLabel={t('albumDetail.ratingLabel')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
import { buildCoverArtUrl, coverArtCacheKey, star, unstar, setRating } from '../api/subsonic';
|
||||||
import CachedImage from './CachedImage';
|
import CachedImage from './CachedImage';
|
||||||
import WaveformSeek from './WaveformSeek';
|
import WaveformSeek from './WaveformSeek';
|
||||||
import Equalizer from './Equalizer';
|
import Equalizer from './Equalizer';
|
||||||
|
import StarRating from './StarRating';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useLyricsStore } from '../store/lyricsStore';
|
import { useLyricsStore } from '../store/lyricsStore';
|
||||||
@@ -37,6 +38,7 @@ export default function PlayerBar() {
|
|||||||
lastfmLoved, toggleLastfmLove,
|
lastfmLoved, toggleLastfmLove,
|
||||||
isQueueVisible, toggleQueue,
|
isQueueVisible, toggleQueue,
|
||||||
starredOverrides, setStarredOverride,
|
starredOverrides, setStarredOverride,
|
||||||
|
userRatingOverrides, setUserRatingOverride,
|
||||||
} = usePlayerStore();
|
} = usePlayerStore();
|
||||||
const { lastfmSessionKey } = useAuthStore();
|
const { lastfmSessionKey } = useAuthStore();
|
||||||
|
|
||||||
@@ -138,6 +140,14 @@ export default function PlayerBar() {
|
|||||||
style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }}
|
style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }}
|
||||||
onClick={() => !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
onClick={() => !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||||
/>
|
/>
|
||||||
|
{currentTrack && !isRadio && (
|
||||||
|
<StarRating
|
||||||
|
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
|
||||||
|
onChange={r => { setUserRatingOverride(currentTrack.id, r); setRating(currentTrack.id, r).catch(() => {}); }}
|
||||||
|
className="player-track-rating"
|
||||||
|
ariaLabel={t('albumDetail.ratingLabel')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{currentTrack && !isRadio && (
|
{currentTrack && !isRadio && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -729,6 +729,10 @@ export const deTranslation = {
|
|||||||
lfmMinutesAgo: 'vor {{n}} Min.',
|
lfmMinutesAgo: 'vor {{n}} Min.',
|
||||||
lfmHoursAgo: 'vor {{n}} Std.',
|
lfmHoursAgo: 'vor {{n}} Std.',
|
||||||
lfmDaysAgo: 'vor {{n}} Tagen',
|
lfmDaysAgo: 'vor {{n}} Tagen',
|
||||||
|
topRatedSongs: 'Bestbewertete Songs',
|
||||||
|
topRatedArtists: 'Bestbewertete Künstler',
|
||||||
|
noRatedSongs: 'Noch keine bewerteten Songs. Songs in Album- oder Playlist-Ansicht bewerten.',
|
||||||
|
noRatedArtists: 'Noch keine bewerteten Künstler.',
|
||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
regionLabel: 'Musikplayer',
|
regionLabel: 'Musikplayer',
|
||||||
|
|||||||
@@ -730,6 +730,10 @@ export const enTranslation = {
|
|||||||
lfmMinutesAgo: '{{n}}m ago',
|
lfmMinutesAgo: '{{n}}m ago',
|
||||||
lfmHoursAgo: '{{n}}h ago',
|
lfmHoursAgo: '{{n}}h ago',
|
||||||
lfmDaysAgo: '{{n}}d ago',
|
lfmDaysAgo: '{{n}}d ago',
|
||||||
|
topRatedSongs: 'Top Rated Songs',
|
||||||
|
topRatedArtists: 'Top Rated Artists',
|
||||||
|
noRatedSongs: 'No rated songs yet. Rate songs in album or playlist view.',
|
||||||
|
noRatedArtists: 'No rated artists yet.',
|
||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
regionLabel: 'Music Player',
|
regionLabel: 'Music Player',
|
||||||
|
|||||||
@@ -727,6 +727,10 @@ export const frTranslation = {
|
|||||||
lfmMinutesAgo: 'il y a {{n}} min',
|
lfmMinutesAgo: 'il y a {{n}} min',
|
||||||
lfmHoursAgo: 'il y a {{n}}h',
|
lfmHoursAgo: 'il y a {{n}}h',
|
||||||
lfmDaysAgo: 'il y a {{n}}j',
|
lfmDaysAgo: 'il y a {{n}}j',
|
||||||
|
topRatedSongs: 'Morceaux les mieux notés',
|
||||||
|
topRatedArtists: 'Artistes les mieux notés',
|
||||||
|
noRatedSongs: 'Aucun morceau noté. Notez des morceaux dans la vue album ou playlist.',
|
||||||
|
noRatedArtists: 'Aucun artiste noté.',
|
||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
regionLabel: 'Lecteur de musique',
|
regionLabel: 'Lecteur de musique',
|
||||||
|
|||||||
@@ -726,6 +726,10 @@ export const nbTranslation = {
|
|||||||
lfmMinutesAgo: 'For {{n}}m siden',
|
lfmMinutesAgo: 'For {{n}}m siden',
|
||||||
lfmHoursAgo: 'For {{n}}t siden',
|
lfmHoursAgo: 'For {{n}}t siden',
|
||||||
lfmDaysAgo: 'For {{n}}d siden',
|
lfmDaysAgo: 'For {{n}}d siden',
|
||||||
|
topRatedSongs: 'Høyest vurderte spor',
|
||||||
|
topRatedArtists: 'Høyest vurderte artister',
|
||||||
|
noRatedSongs: 'Ingen vurderte spor ennå. Vurder spor i album- eller spillelistevisning.',
|
||||||
|
noRatedArtists: 'Ingen vurderte artister ennå.',
|
||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
regionLabel: 'Musikkspiller',
|
regionLabel: 'Musikkspiller',
|
||||||
|
|||||||
@@ -727,6 +727,10 @@ export const nlTranslation = {
|
|||||||
lfmMinutesAgo: '{{n}} min geleden',
|
lfmMinutesAgo: '{{n}} min geleden',
|
||||||
lfmHoursAgo: '{{n}} uur geleden',
|
lfmHoursAgo: '{{n}} uur geleden',
|
||||||
lfmDaysAgo: '{{n}} dagen geleden',
|
lfmDaysAgo: '{{n}} dagen geleden',
|
||||||
|
topRatedSongs: 'Best beoordeelde nummers',
|
||||||
|
topRatedArtists: 'Best beoordeelde artiesten',
|
||||||
|
noRatedSongs: 'Nog geen beoordeelde nummers. Beoordeel nummers in album- of afspeellijstweergave.',
|
||||||
|
noRatedArtists: 'Nog geen beoordeelde artiesten.',
|
||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
regionLabel: 'Muziekspeler',
|
regionLabel: 'Muziekspeler',
|
||||||
|
|||||||
@@ -787,6 +787,10 @@ export const ruTranslation = {
|
|||||||
lfmMinutesAgo: '{{n}} мин назад',
|
lfmMinutesAgo: '{{n}} мин назад',
|
||||||
lfmHoursAgo: '{{n}} ч назад',
|
lfmHoursAgo: '{{n}} ч назад',
|
||||||
lfmDaysAgo: '{{n}} дн. назад',
|
lfmDaysAgo: '{{n}} дн. назад',
|
||||||
|
topRatedSongs: 'Лучшие по рейтингу треки',
|
||||||
|
topRatedArtists: 'Лучшие по рейтингу исполнители',
|
||||||
|
noRatedSongs: 'Нет оценённых треков. Ставьте оценки в альбомах или плейлистах.',
|
||||||
|
noRatedArtists: 'Нет оценённых исполнителей.',
|
||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
regionLabel: 'Плеер',
|
regionLabel: 'Плеер',
|
||||||
|
|||||||
@@ -723,6 +723,10 @@ export const zhTranslation = {
|
|||||||
lfmMinutesAgo: '{{n}} 分钟前',
|
lfmMinutesAgo: '{{n}} 分钟前',
|
||||||
lfmHoursAgo: '{{n}} 小时前',
|
lfmHoursAgo: '{{n}} 小时前',
|
||||||
lfmDaysAgo: '{{n}} 天前',
|
lfmDaysAgo: '{{n}} 天前',
|
||||||
|
topRatedSongs: '最高评分歌曲',
|
||||||
|
topRatedArtists: '最高评分艺人',
|
||||||
|
noRatedSongs: '暂无已评分歌曲。请在专辑或播放列表中为歌曲评分。',
|
||||||
|
noRatedArtists: '暂无已评分艺人。',
|
||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
regionLabel: '音乐播放器',
|
regionLabel: '音乐播放器',
|
||||||
|
|||||||
+20
-2
@@ -4,11 +4,12 @@ import AlbumRow from '../components/AlbumRow';
|
|||||||
import ArtistRow from '../components/ArtistRow';
|
import ArtistRow from '../components/ArtistRow';
|
||||||
import CachedImage from '../components/CachedImage';
|
import CachedImage from '../components/CachedImage';
|
||||||
import {
|
import {
|
||||||
getStarred, getInternetRadioStations,
|
getStarred, getInternetRadioStations, setRating,
|
||||||
SubsonicAlbum, SubsonicArtist, SubsonicSong, InternetRadioStation,
|
SubsonicAlbum, SubsonicArtist, SubsonicSong, InternetRadioStation,
|
||||||
buildCoverArtUrl, coverArtCacheKey,
|
buildCoverArtUrl, coverArtCacheKey,
|
||||||
} from '../api/subsonic';
|
} from '../api/subsonic';
|
||||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||||
|
import StarRating from '../components/StarRating';
|
||||||
import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, X } from 'lucide-react';
|
import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, X } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -20,6 +21,7 @@ const FAV_COLUMNS: readonly ColDef[] = [
|
|||||||
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
|
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
|
||||||
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
|
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
|
||||||
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
|
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
|
||||||
|
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
|
||||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
|
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
|
||||||
{ key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
|
{ key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
|
||||||
];
|
];
|
||||||
@@ -39,14 +41,23 @@ export default function Favorites() {
|
|||||||
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
|
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
|
||||||
} = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns');
|
} = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns');
|
||||||
|
|
||||||
|
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
const { playTrack, enqueue, playRadio, stop } = usePlayerStore();
|
const { playTrack, enqueue, playRadio, stop } = usePlayerStore();
|
||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
const currentRadio = usePlayerStore(s => s.currentRadio);
|
const currentRadio = usePlayerStore(s => s.currentRadio);
|
||||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||||
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 psyDrag = useDragDrop();
|
const psyDrag = useDragDrop();
|
||||||
|
|
||||||
|
const handleRate = (songId: string, rating: number) => {
|
||||||
|
setRatings(r => ({ ...r, [songId]: rating }));
|
||||||
|
usePlayerStore.getState().setUserRatingOverride(songId, rating);
|
||||||
|
setRating(songId, rating).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
function removeSong(id: string) {
|
function removeSong(id: string) {
|
||||||
unstar(id, 'song').catch(() => {});
|
unstar(id, 'song').catch(() => {});
|
||||||
setStarredOverride(id, false);
|
setStarredOverride(id, false);
|
||||||
@@ -179,7 +190,7 @@ export default function Favorites() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (key === 'remove') return <div key="remove" />;
|
if (key === 'remove') return <div key="remove" />;
|
||||||
const isCentered = key === 'duration';
|
const isCentered = key === 'duration' || key === 'rating';
|
||||||
return (
|
return (
|
||||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||||
<div
|
<div
|
||||||
@@ -264,6 +275,13 @@ export default function Favorites() {
|
|||||||
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}>{song.artist}</span>
|
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}>{song.artist}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
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 (
|
case 'duration': return (
|
||||||
<div key="duration" className="track-duration">
|
<div key="duration" className="track-duration">
|
||||||
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
|
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { getAlbumList, getArtists, getGenres, getRandomSongs, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
|
import { getAlbumList, getArtists, getGenres, getRandomSongs, getStarred, SubsonicAlbum, SubsonicArtist, SubsonicGenre, SubsonicSong } from '../api/subsonic';
|
||||||
import AlbumRow from '../components/AlbumRow';
|
import AlbumRow from '../components/AlbumRow';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { lastfmIsConfigured, lastfmGetTopArtists, lastfmGetTopAlbums, lastfmGetTopTracks, lastfmGetRecentTracks, LastfmPeriod, LastfmTopArtist, LastfmTopAlbum, LastfmTopTrack, LastfmRecentTrack } from '../api/lastfm';
|
import { lastfmIsConfigured, lastfmGetTopArtists, lastfmGetTopAlbums, lastfmGetTopTracks, lastfmGetRecentTracks, LastfmPeriod, LastfmTopArtist, LastfmTopAlbum, LastfmTopTrack, LastfmRecentTrack } from '../api/lastfm';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -32,11 +34,33 @@ const PERIODS: { key: LastfmPeriod; label: string }[] = [
|
|||||||
|
|
||||||
export default function Statistics() {
|
export default function Statistics() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { lastfmSessionKey, lastfmUsername } = useAuthStore();
|
const { lastfmSessionKey, lastfmUsername } = useAuthStore();
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||||
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
|
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
|
||||||
const [frequent, setFrequent] = useState<SubsonicAlbum[]>([]);
|
const [frequent, setFrequent] = useState<SubsonicAlbum[]>([]);
|
||||||
const [highest, setHighest] = useState<SubsonicAlbum[]>([]);
|
const [highest, setHighest] = useState<SubsonicAlbum[]>([]);
|
||||||
|
const [starredSongs, setStarredSongs] = useState<SubsonicSong[]>([]);
|
||||||
|
const [topArtists, setTopArtists] = useState<SubsonicArtist[]>([]);
|
||||||
|
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||||
|
const queue = usePlayerStore(s => s.queue);
|
||||||
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
|
|
||||||
|
const topSongs = useMemo(() => {
|
||||||
|
const map = new Map(starredSongs.map(s => [s.id, { ...s, userRating: userRatingOverrides[s.id] ?? s.userRating }]));
|
||||||
|
// Songs not yet in starredSongs but rated via override (e.g. from queue or currentTrack)
|
||||||
|
const candidates = currentTrack ? [currentTrack, ...queue] : queue;
|
||||||
|
for (const t of candidates) {
|
||||||
|
const r = userRatingOverrides[t.id];
|
||||||
|
if (r && !map.has(t.id)) {
|
||||||
|
map.set(t.id, { id: t.id, title: t.title, artist: t.artist, album: t.album, albumId: t.albumId, userRating: r } as SubsonicSong);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...map.values()]
|
||||||
|
.filter(s => (s.userRating ?? 0) > 0)
|
||||||
|
.sort((a, b) => (b.userRating ?? 0) - (a.userRating ?? 0))
|
||||||
|
.slice(0, 10);
|
||||||
|
}, [starredSongs, userRatingOverrides, queue, currentTrack]);
|
||||||
const [artistCount, setArtistCount] = useState<number | null>(null);
|
const [artistCount, setArtistCount] = useState<number | null>(null);
|
||||||
const [totalSongs, setTotalSongs] = useState<number | null>(null);
|
const [totalSongs, setTotalSongs] = useState<number | null>(null);
|
||||||
const [totalAlbums, setTotalAlbums] = useState<number | null>(null);
|
const [totalAlbums, setTotalAlbums] = useState<number | null>(null);
|
||||||
@@ -63,7 +87,8 @@ export default function Statistics() {
|
|||||||
getAlbumList('highest', 12).catch(() => []),
|
getAlbumList('highest', 12).catch(() => []),
|
||||||
getArtists().catch(() => []),
|
getArtists().catch(() => []),
|
||||||
getGenres().catch(() => []),
|
getGenres().catch(() => []),
|
||||||
]).then(([rc, fr, hi, a, g]) => {
|
getStarred().catch(() => ({ albums: [], artists: [], songs: [] })),
|
||||||
|
]).then(([rc, fr, hi, a, g, starred]) => {
|
||||||
setRecent(rc);
|
setRecent(rc);
|
||||||
setFrequent(fr);
|
setFrequent(fr);
|
||||||
setHighest(hi);
|
setHighest(hi);
|
||||||
@@ -72,6 +97,13 @@ export default function Statistics() {
|
|||||||
setTotalAlbums(g.reduce((acc: number, genre: SubsonicGenre) => acc + genre.albumCount, 0));
|
setTotalAlbums(g.reduce((acc: number, genre: SubsonicGenre) => acc + genre.albumCount, 0));
|
||||||
const sorted = [...g].sort((a, b) => b.songCount - a.songCount);
|
const sorted = [...g].sort((a, b) => b.songCount - a.songCount);
|
||||||
setGenres(sorted);
|
setGenres(sorted);
|
||||||
|
setStarredSongs(starred.songs);
|
||||||
|
setTopArtists(
|
||||||
|
[...starred.artists]
|
||||||
|
.filter(a => (a.userRating ?? 0) > 0)
|
||||||
|
.sort((a, b) => (b.userRating ?? 0) - (a.userRating ?? 0))
|
||||||
|
.slice(0, 10),
|
||||||
|
);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}).catch(() => setLoading(false));
|
}).catch(() => setLoading(false));
|
||||||
}, [musicLibraryFilterVersion]);
|
}, [musicLibraryFilterVersion]);
|
||||||
@@ -282,8 +314,70 @@ export default function Statistics() {
|
|||||||
albums={highest}
|
albums={highest}
|
||||||
onLoadMore={() => loadMore('highest', highest, setHighest)}
|
onLoadMore={() => loadMore('highest', highest, setHighest)}
|
||||||
moreText={t('statistics.loadMore')}
|
moreText={t('statistics.loadMore')}
|
||||||
|
showRating
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Top Rated Songs + Artists */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '1rem', marginBottom: '0.5rem' }}>
|
||||||
|
<div style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
|
||||||
|
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '1rem' }}>
|
||||||
|
{t('statistics.topRatedSongs')}
|
||||||
|
</h3>
|
||||||
|
{topSongs.length === 0 ? (
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{t('statistics.noRatedSongs')}</p>
|
||||||
|
) : (
|
||||||
|
<ol style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{topSongs.map((song, i) => (
|
||||||
|
<li key={song.id} style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', cursor: song.albumId ? 'pointer' : 'default' }}
|
||||||
|
onClick={() => song.albumId && navigate(`/album/${song.albumId}`)}>
|
||||||
|
<span style={{ fontSize: '1.1rem', fontWeight: 800, color: i === 0 ? 'var(--accent)' : 'var(--text-muted)', opacity: i === 0 ? 1 : 0.5, lineHeight: 1, flexShrink: 0, width: '1.5rem' }}>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: '0.875rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.title}</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.artist}</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '11px', color: 'var(--accent)', flexShrink: 0, letterSpacing: '1px' }}>
|
||||||
|
{'★'.repeat(song.userRating!)}{'☆'.repeat(5 - song.userRating!)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
|
||||||
|
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '1rem' }}>
|
||||||
|
{t('statistics.topRatedArtists')}
|
||||||
|
</h3>
|
||||||
|
{topArtists.length === 0 ? (
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{t('statistics.noRatedArtists')}</p>
|
||||||
|
) : (
|
||||||
|
<ol style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{topArtists.map((artist, i) => (
|
||||||
|
<li key={artist.id} style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate(`/artist/${artist.id}`)}>
|
||||||
|
<span style={{ fontSize: '1.1rem', fontWeight: 800, color: i === 0 ? 'var(--accent)' : 'var(--text-muted)', opacity: i === 0 ? 1 : 0.5, lineHeight: 1, flexShrink: 0, width: '1.5rem' }}>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: '0.875rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{artist.name}</div>
|
||||||
|
{(artist.albumCount ?? 0) > 0 && (
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
||||||
|
{t('artistDetail.albumCount_other', { count: artist.albumCount })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '11px', color: 'var(--accent)', flexShrink: 0, letterSpacing: '1px' }}>
|
||||||
|
{'★'.repeat(artist.userRating!)}{'☆'.repeat(5 - artist.userRating!)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Last.fm Stats */}
|
{/* Last.fm Stats */}
|
||||||
{lastfmIsConfigured() && (
|
{lastfmIsConfigured() && (
|
||||||
<section style={{ marginTop: '2rem' }}>
|
<section style={{ marginTop: '2rem' }}>
|
||||||
@@ -363,7 +457,7 @@ export default function Statistics() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||||
{lfmRecentTracks.map((track, i) => (
|
{lfmRecentTracks.slice(0, 3).map((track, i) => (
|
||||||
<div key={`${track.name}-${i}`} style={{ display: 'flex', alignItems: 'center', gap: '1rem', padding: '0.5rem 0.75rem', borderRadius: '8px', background: track.nowPlaying ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', border: track.nowPlaying ? '1px solid color-mix(in srgb, var(--accent) 20%, transparent)' : '1px solid transparent' }}>
|
<div key={`${track.name}-${i}`} style={{ display: 'flex', alignItems: 'center', gap: '1rem', padding: '0.5rem 0.75rem', borderRadius: '8px', background: track.nowPlaying ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', border: track.nowPlaying ? '1px solid color-mix(in srgb, var(--accent) 20%, transparent)' : '1px solid transparent' }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
|||||||
@@ -589,6 +589,20 @@
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.album-card-rating-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card-rating-stars {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─ Live Search ─ */
|
/* ─ Live Search ─ */
|
||||||
.live-search {
|
.live-search {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -3621,6 +3635,15 @@
|
|||||||
margin: var(--space-1) 0;
|
margin: var(--space-1) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.context-menu-rating-row {
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-rating-row .star-rating {
|
||||||
|
width: auto;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─ CSS Tooltips ─ */
|
/* ─ CSS Tooltips ─ */
|
||||||
/* Tooltips are handled by TooltipPortal (React portal) — no CSS pseudo-elements needed */
|
/* Tooltips are handled by TooltipPortal (React portal) — no CSS pseudo-elements needed */
|
||||||
[data-tooltip] {
|
[data-tooltip] {
|
||||||
|
|||||||
@@ -934,6 +934,17 @@
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-track-rating {
|
||||||
|
margin-top: 4px;
|
||||||
|
width: auto;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-track-rating .star {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Marquee (PlayerBar track name / artist) ── */
|
/* ── Marquee (PlayerBar track name / artist) ── */
|
||||||
.marquee-wrap {
|
.marquee-wrap {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
Reference in New Issue
Block a user