mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat: v1.28.0 — Infinite Queue, Start Radio, Single-click Play, Performance
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,24 @@ 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.28.0] - 2026-04-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Infinite Queue** *(requested by [@netherguy4](https://github.com/netherguy4))*: When the queue runs out with Repeat off, Psysonic automatically appends 25 random tracks (optionally filtered by the last-played track's genre) so playback never stops. Toggle in Settings → Audio → "Infinite Queue". Auto-added tracks appear below a divider in the Queue panel.
|
||||||
|
- **Start Radio plays immediately** *(requested by [@netherguy4](https://github.com/netherguy4))*: "Start Radio" from the song/queue context menu now starts the seed track instantly while similar and top tracks load in the background — no waiting for the fetch to complete before music plays.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Single-click to play everywhere** *(reported by [@netherguy4](https://github.com/netherguy4))*: Song rows in Album Detail, Playlist Detail, Artist Detail (Top Tracks), Favorites, and Random Mix previously required a double-click. All rows now play on a single click. The track-number cell and the full row are both click targets; buttons and links inside the row still work independently.
|
||||||
|
- **Artist page Play All / Shuffle used Top Tracks only** *(reported by [@smirnoffjr](https://github.com/smirnoffjr))*: "Play All" and "Shuffle" on the Artist detail page only sent the loaded top songs to the queue, not the full discography. Now fetches all albums in parallel and plays songs in chronological album order with correct track-number ordering within each album. Buttons show a spinner while albums are loading.
|
||||||
|
- **Last.fm icon clipped in player bar**: The Last.fm logo button in the player bar was cut off on the right side. Fixed by correcting the SVG `viewBox` from `0 0 24 24` to `0 0 26 22` to match the actual path extents.
|
||||||
|
- **Playlist empty state UX** *(reported by [@netherguy4](https://github.com/netherguy4))*: Empty playlists (on creation, or after deleting all tracks) now show an "Add your first song" CTA button that opens the search panel directly, rather than a plain text message with no action.
|
||||||
|
- **Playlist search rows required "+" button click** *(reported by [@netherguy4](https://github.com/netherguy4))*: Search result rows in the song search panel now add the song on a full-row click — the separate "+" button was redundant and easy to miss.
|
||||||
|
- **Large playlist performance**: Playlists with hundreds of songs would freeze during mouse movement. Root cause: `hoveredSongId` state triggered a full React re-render of every row on every `mouseenter`/`mouseleave` event. Fixed by removing the JS hover state and replacing it with a CSS `.track-row:hover .bulk-check` rule. Also memoized `songs.map(songToTrack)` and the `existingIds` set to avoid recomputation per render. Same fix applied to `AlbumTrackList`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.27.4] - 2026-04-02
|
## [1.27.4] - 2026-04-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "psysonic",
|
"name": "psysonic",
|
||||||
"version": "1.27.4",
|
"version": "1.28.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||||
pkgname=psysonic
|
pkgname=psysonic
|
||||||
pkgver=1.27.4
|
pkgver=1.28.0
|
||||||
pkgrel=2
|
pkgrel=1
|
||||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://github.com/Psychotoxical/psysonic"
|
url="https://github.com/Psychotoxical/psysonic"
|
||||||
|
|||||||
Generated
+1
-1
@@ -3470,7 +3470,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psysonic"
|
name = "psysonic"
|
||||||
version = "1.27.2"
|
version = "1.28.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"biquad",
|
"biquad",
|
||||||
"md5",
|
"md5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "psysonic"
|
name = "psysonic"
|
||||||
version = "1.27.4"
|
version = "1.28.0"
|
||||||
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.27.4",
|
"version": "1.28.0",
|
||||||
"identifier": "dev.psysonic.player",
|
"identifier": "dev.psysonic.player",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ export default function AlbumTrackList({
|
|||||||
onContextMenu,
|
onContextMenu,
|
||||||
}: AlbumTrackListProps) {
|
}: AlbumTrackListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [hoveredSongId, setHoveredSongId] = useState<string | null>(null);
|
|
||||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||||
const psyDrag = useDragDrop();
|
const psyDrag = useDragDrop();
|
||||||
@@ -187,12 +186,12 @@ export default function AlbumTrackList({
|
|||||||
<div
|
<div
|
||||||
key={song.id}
|
key={song.id}
|
||||||
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
||||||
onMouseEnter={() => setHoveredSongId(song.id)}
|
|
||||||
onMouseLeave={() => setHoveredSongId(null)}
|
|
||||||
onDoubleClick={() => onPlaySong(song)}
|
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
if (inSelectMode && !(e.target as HTMLElement).closest('button, input')) {
|
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||||
|
if (inSelectMode) {
|
||||||
toggleSelect(song.id, globalIdx, e.shiftKey);
|
toggleSelect(song.id, globalIdx, e.shiftKey);
|
||||||
|
} else {
|
||||||
|
onPlaySong(song);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onContextMenu={e => {
|
onContextMenu={e => {
|
||||||
@@ -220,27 +219,16 @@ export default function AlbumTrackList({
|
|||||||
<div
|
<div
|
||||||
className="track-num"
|
className="track-num"
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={e => {
|
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
|
||||||
e.stopPropagation();
|
|
||||||
if (inSelectMode || hoveredSongId === song.id) {
|
|
||||||
toggleSelect(song.id, globalIdx, e.shiftKey);
|
|
||||||
} else {
|
|
||||||
onPlaySong(song);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${(inSelectMode || hoveredSongId === song.id) ? ' bulk-check-visible' : ''}`}
|
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||||
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }}
|
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }}
|
||||||
/>
|
/>
|
||||||
<span style={{ color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : 'var(--text-muted)' }}>
|
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||||
{hoveredSongId === song.id && currentTrack?.id !== song.id && !inSelectMode
|
{currentTrack?.id === song.id && isPlaying
|
||||||
? <Play size={13} fill="currentColor" />
|
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||||
: currentTrack?.id === song.id && isPlaying
|
: <Play size={13} fill="currentColor" />}
|
||||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
|
||||||
: currentTrack?.id === song.id
|
|
||||||
? <Play size={13} fill="currentColor" />
|
|
||||||
: (song.track ?? globalIdx + 1)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="track-info">
|
<div className="track-info">
|
||||||
|
|||||||
@@ -207,16 +207,33 @@ export default function ContextMenu() {
|
|||||||
await action();
|
await action();
|
||||||
};
|
};
|
||||||
|
|
||||||
const startRadio = async (artistId: string, artistName: string) => {
|
const startRadio = async (artistId: string, artistName: string, seedTrack?: Track) => {
|
||||||
try {
|
if (seedTrack) {
|
||||||
const similar = await getSimilarSongs2(artistId);
|
// Start playback immediately based on current state
|
||||||
if (similar.length > 0) {
|
const state = usePlayerStore.getState();
|
||||||
const top = await getTopSongs(artistName);
|
if (state.currentTrack?.id === seedTrack.id) {
|
||||||
const radioTracks = [...top, ...similar].map(songToTrack);
|
if (!state.isPlaying) state.resume();
|
||||||
playTrack(radioTracks[0], radioTracks);
|
// Already playing this track — don't restart
|
||||||
}
|
} else {
|
||||||
} catch (e) {
|
playTrack(seedTrack, [seedTrack]);
|
||||||
console.error('Failed to start radio', e);
|
}
|
||||||
|
// Load radio queue in background
|
||||||
|
try {
|
||||||
|
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
|
||||||
|
const radioTracks = [...top, ...similar].map(songToTrack).filter(t => t.id !== seedTrack.id);
|
||||||
|
if (radioTracks.length > 0) enqueue(radioTracks);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load radio queue', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Artist radio: no seed track, fetch in parallel and play when ready
|
||||||
|
try {
|
||||||
|
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
|
||||||
|
const radioTracks = [...top, ...similar].map(songToTrack);
|
||||||
|
if (radioTracks.length > 0) playTrack(radioTracks[0], radioTracks);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to start radio', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -297,7 +314,7 @@ export default function ContextMenu() {
|
|||||||
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
|
||||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
@@ -440,7 +457,7 @@ export default function ContextMenu() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
|
||||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-divider" />
|
<div className="context-menu-divider" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export default function LastfmIcon({ size = 16 }: { size?: number }) {
|
export default function LastfmIcon({ size = 16 }: { size?: number }) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
<svg width={size} height={size} viewBox="0 0 26 22" fill="currentColor" aria-hidden="true">
|
||||||
<path d="M11.344 16.143l-.917-2.494s-1.485 1.662-3.716 1.662c-1.97 0-3.373-1.714-3.373-4.46 0-3.514 1.773-4.777 3.52-4.777 2.508 0 3.306 1.625 3.997 3.714l.918 2.88c.918 2.8 2.642 5.047 7.615 5.047 3.563 0 5.98-1.094 5.98-3.972 0-2.326-1.327-3.53-3.797-4.11l-1.836-.41c-1.27-.29-1.645-.82-1.645-1.693 0-.987.778-1.56 2.047-1.56 1.384 0 2.132.52 2.245 1.756l2.878-.347C24.883 5.116 23.3 4 20.5 4c-3.26 0-4.945 1.537-4.945 3.824 0 1.843.91 3.008 3.2 3.562l1.947.46c1.404.327 1.97.874 1.97 1.894 0 1.13-.988 1.593-2.948 1.593-2.858 0-4.052-1.497-4.742-3.634l-.943-2.887C13.22 6.162 11.73 4 7.897 4 3.847 4 1 6.61 1 11.022c0 4.235 2.617 6.638 6.19 6.638 2.566 0 4.154-1.517 4.154-1.517z"/>
|
<path d="M11.344 16.143l-.917-2.494s-1.485 1.662-3.716 1.662c-1.97 0-3.373-1.714-3.373-4.46 0-3.514 1.773-4.777 3.52-4.777 2.508 0 3.306 1.625 3.997 3.714l.918 2.88c.918 2.8 2.642 5.047 7.615 5.047 3.563 0 5.98-1.094 5.98-3.972 0-2.326-1.327-3.53-3.797-4.11l-1.836-.41c-1.27-.29-1.645-.82-1.645-1.693 0-.987.778-1.56 2.047-1.56 1.384 0 2.132.52 2.245 1.756l2.878-.347C24.883 5.116 23.3 4 20.5 4c-3.26 0-4.945 1.537-4.945 3.824 0 1.843.91 3.008 3.2 3.562l1.947.46c1.404.327 1.97.874 1.97 1.894 0 1.13-.988 1.593-2.948 1.593-2.858 0-4.052-1.497-4.742-3.634l-.943-2.887C13.22 6.162 11.73 4 7.897 4 3.847 4 1 6.61 1 11.022c0 4.235 2.617 6.638 6.19 6.638 2.566 0 4.154-1.517 4.154-1.517z"/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useRef, useMemo } from 'react';
|
import React, { useState, useRef, useMemo } from 'react';
|
||||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus } from 'lucide-react';
|
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, Radio } from 'lucide-react';
|
||||||
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||||
import { useCachedUrl } from './CachedImage';
|
import { useCachedUrl } from './CachedImage';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
@@ -183,9 +183,11 @@ export default function QueuePanel() {
|
|||||||
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
|
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
|
||||||
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
|
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
|
||||||
const gaplessEnabled = useAuthStore(s => s.gaplessEnabled);
|
const gaplessEnabled = useAuthStore(s => s.gaplessEnabled);
|
||||||
|
const infiniteQueueEnabled = useAuthStore(s => s.infiniteQueueEnabled);
|
||||||
const setCrossfadeEnabled = useAuthStore(s => s.setCrossfadeEnabled);
|
const setCrossfadeEnabled = useAuthStore(s => s.setCrossfadeEnabled);
|
||||||
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
|
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
|
||||||
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
|
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
|
||||||
|
const setInfiniteQueueEnabled = useAuthStore(s => s.setInfiniteQueueEnabled);
|
||||||
|
|
||||||
const activeTab = useLyricsStore(s => s.activeTab);
|
const activeTab = useLyricsStore(s => s.activeTab);
|
||||||
const setTab = useLyricsStore(s => s.setTab);
|
const setTab = useLyricsStore(s => s.setTab);
|
||||||
@@ -488,6 +490,14 @@ export default function QueuePanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
className={`queue-round-btn${infiniteQueueEnabled ? ' active' : ''}`}
|
||||||
|
onClick={() => setInfiniteQueueEnabled(!infiniteQueueEnabled)}
|
||||||
|
data-tooltip={t('queue.infiniteQueue')}
|
||||||
|
aria-label={t('queue.infiniteQueue')}
|
||||||
|
>
|
||||||
|
<Radio size={13} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
|
{currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
|
||||||
@@ -500,6 +510,7 @@ export default function QueuePanel() {
|
|||||||
) : (
|
) : (
|
||||||
queue.map((track, idx) => {
|
queue.map((track, idx) => {
|
||||||
const isPlaying = idx === queueIndex;
|
const isPlaying = idx === queueIndex;
|
||||||
|
const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
|
||||||
|
|
||||||
let dragStyle: React.CSSProperties = {};
|
let dragStyle: React.CSSProperties = {};
|
||||||
if (isPsyDragging && psyDragFromIdxRef.current === idx) {
|
if (isPsyDragging && psyDragFromIdxRef.current === idx) {
|
||||||
@@ -513,8 +524,13 @@ export default function QueuePanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<React.Fragment key={`${track.id}-${idx}`}>
|
||||||
|
{isFirstAutoAdded && (
|
||||||
|
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
||||||
|
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.autoAdded')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
key={`${track.id}-${idx}`}
|
|
||||||
data-queue-idx={idx}
|
data-queue-idx={idx}
|
||||||
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
|
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
|
||||||
onClick={() => playTrack(track, queue)}
|
onClick={() => playTrack(track, queue)}
|
||||||
@@ -555,6 +571,7 @@ export default function QueuePanel() {
|
|||||||
{formatTime(track.duration)}
|
{formatTime(track.duration)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|||||||
+25
@@ -444,6 +444,8 @@ const enTranslation = {
|
|||||||
notWithCrossfade: 'Not available while Crossfade is active',
|
notWithCrossfade: 'Not available while Crossfade is active',
|
||||||
gapless: 'Gapless Playback',
|
gapless: 'Gapless Playback',
|
||||||
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
|
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
|
||||||
|
infiniteQueue: 'Infinite Queue',
|
||||||
|
infiniteQueueDesc: 'Automatically append random tracks when the queue runs out',
|
||||||
experimental: 'Experimental',
|
experimental: 'Experimental',
|
||||||
},
|
},
|
||||||
changelog: {
|
changelog: {
|
||||||
@@ -547,6 +549,8 @@ const enTranslation = {
|
|||||||
shuffle: 'Shuffle queue',
|
shuffle: 'Shuffle queue',
|
||||||
gapless: 'Gapless',
|
gapless: 'Gapless',
|
||||||
crossfade: 'Crossfade',
|
crossfade: 'Crossfade',
|
||||||
|
infiniteQueue: 'Infinite Queue',
|
||||||
|
autoAdded: '— Added automatically —',
|
||||||
hide: 'Hide',
|
hide: 'Hide',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
nextTracks: 'Next Tracks',
|
nextTracks: 'Next Tracks',
|
||||||
@@ -648,6 +652,7 @@ const enTranslation = {
|
|||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
empty: 'No playlists yet.',
|
empty: 'No playlists yet.',
|
||||||
emptyPlaylist: 'This playlist is empty.',
|
emptyPlaylist: 'This playlist is empty.',
|
||||||
|
addFirstSong: 'Add your first song',
|
||||||
notFound: 'Playlist not found.',
|
notFound: 'Playlist not found.',
|
||||||
songs: '{{n}} songs',
|
songs: '{{n}} songs',
|
||||||
playAll: 'Play All',
|
playAll: 'Play All',
|
||||||
@@ -1111,6 +1116,8 @@ const deTranslation = {
|
|||||||
notWithCrossfade: 'Nicht verfügbar wenn Crossfade aktiv ist',
|
notWithCrossfade: 'Nicht verfügbar wenn Crossfade aktiv ist',
|
||||||
gapless: 'Nahtlose Wiedergabe',
|
gapless: 'Nahtlose Wiedergabe',
|
||||||
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
|
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
|
||||||
|
infiniteQueue: 'Endlose Warteschlange',
|
||||||
|
infiniteQueueDesc: 'Automatisch Zufallstitel anhängen wenn die Warteschlange leer wird',
|
||||||
experimental: 'Experimentell',
|
experimental: 'Experimentell',
|
||||||
},
|
},
|
||||||
changelog: {
|
changelog: {
|
||||||
@@ -1214,6 +1221,8 @@ const deTranslation = {
|
|||||||
shuffle: 'Warteschlange mischen',
|
shuffle: 'Warteschlange mischen',
|
||||||
gapless: 'Nahtlos',
|
gapless: 'Nahtlos',
|
||||||
crossfade: 'Crossfade',
|
crossfade: 'Crossfade',
|
||||||
|
infiniteQueue: 'Endlose Warteschlange',
|
||||||
|
autoAdded: '— Automatisch hinzugefügt —',
|
||||||
hide: 'Verbergen',
|
hide: 'Verbergen',
|
||||||
close: 'Schließen',
|
close: 'Schließen',
|
||||||
nextTracks: 'Nächste Titel',
|
nextTracks: 'Nächste Titel',
|
||||||
@@ -1315,6 +1324,7 @@ const deTranslation = {
|
|||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
empty: 'Noch keine Playlists.',
|
empty: 'Noch keine Playlists.',
|
||||||
emptyPlaylist: 'Diese Playlist ist leer.',
|
emptyPlaylist: 'Diese Playlist ist leer.',
|
||||||
|
addFirstSong: 'Ersten Song hinzufügen',
|
||||||
notFound: 'Playlist nicht gefunden.',
|
notFound: 'Playlist nicht gefunden.',
|
||||||
songs: '{{n}} Songs',
|
songs: '{{n}} Songs',
|
||||||
playAll: 'Alle abspielen',
|
playAll: 'Alle abspielen',
|
||||||
@@ -1778,6 +1788,8 @@ const frTranslation = {
|
|||||||
notWithCrossfade: 'Non disponible quand le fondu enchaîné est actif',
|
notWithCrossfade: 'Non disponible quand le fondu enchaîné est actif',
|
||||||
gapless: 'Lecture sans blanc',
|
gapless: 'Lecture sans blanc',
|
||||||
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
|
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
|
||||||
|
infiniteQueue: 'File infinie',
|
||||||
|
infiniteQueueDesc: 'Ajouter automatiquement des morceaux aléatoires quand la file est épuisée',
|
||||||
experimental: 'Expérimental',
|
experimental: 'Expérimental',
|
||||||
},
|
},
|
||||||
changelog: {
|
changelog: {
|
||||||
@@ -1881,6 +1893,8 @@ const frTranslation = {
|
|||||||
shuffle: 'Mélanger la file',
|
shuffle: 'Mélanger la file',
|
||||||
gapless: 'Sans blanc',
|
gapless: 'Sans blanc',
|
||||||
crossfade: 'Fondu',
|
crossfade: 'Fondu',
|
||||||
|
infiniteQueue: 'File infinie',
|
||||||
|
autoAdded: '— Ajouté automatiquement —',
|
||||||
hide: 'Masquer',
|
hide: 'Masquer',
|
||||||
close: 'Fermer',
|
close: 'Fermer',
|
||||||
nextTracks: 'Pistes suivantes',
|
nextTracks: 'Pistes suivantes',
|
||||||
@@ -1982,6 +1996,7 @@ const frTranslation = {
|
|||||||
cancel: 'Annuler',
|
cancel: 'Annuler',
|
||||||
empty: 'Aucune playlist pour l\'instant.',
|
empty: 'Aucune playlist pour l\'instant.',
|
||||||
emptyPlaylist: 'Cette playlist est vide.',
|
emptyPlaylist: 'Cette playlist est vide.',
|
||||||
|
addFirstSong: 'Ajouter votre premier titre',
|
||||||
notFound: 'Playlist introuvable.',
|
notFound: 'Playlist introuvable.',
|
||||||
songs: '{{n}} titres',
|
songs: '{{n}} titres',
|
||||||
playAll: 'Tout lire',
|
playAll: 'Tout lire',
|
||||||
@@ -2445,6 +2460,8 @@ const nlTranslation = {
|
|||||||
notWithCrossfade: 'Niet beschikbaar als overgang actief is',
|
notWithCrossfade: 'Niet beschikbaar als overgang actief is',
|
||||||
gapless: 'Naadloos afspelen',
|
gapless: 'Naadloos afspelen',
|
||||||
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
|
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
|
||||||
|
infiniteQueue: 'Oneindige wachtrij',
|
||||||
|
infiniteQueueDesc: 'Automatisch willekeurige nummers toevoegen als de wachtrij leeg raakt',
|
||||||
experimental: 'Experimenteel',
|
experimental: 'Experimenteel',
|
||||||
},
|
},
|
||||||
changelog: {
|
changelog: {
|
||||||
@@ -2548,6 +2565,8 @@ const nlTranslation = {
|
|||||||
shuffle: 'Wachtrij shufflen',
|
shuffle: 'Wachtrij shufflen',
|
||||||
gapless: 'Naadloos',
|
gapless: 'Naadloos',
|
||||||
crossfade: 'Overgang',
|
crossfade: 'Overgang',
|
||||||
|
infiniteQueue: 'Oneindige wachtrij',
|
||||||
|
autoAdded: '— Automatisch toegevoegd —',
|
||||||
hide: 'Verbergen',
|
hide: 'Verbergen',
|
||||||
close: 'Sluiten',
|
close: 'Sluiten',
|
||||||
nextTracks: 'Volgende nummers',
|
nextTracks: 'Volgende nummers',
|
||||||
@@ -2649,6 +2668,7 @@ const nlTranslation = {
|
|||||||
cancel: 'Annuleren',
|
cancel: 'Annuleren',
|
||||||
empty: 'Nog geen playlists.',
|
empty: 'Nog geen playlists.',
|
||||||
emptyPlaylist: 'Deze playlist is leeg.',
|
emptyPlaylist: 'Deze playlist is leeg.',
|
||||||
|
addFirstSong: 'Voeg je eerste nummer toe',
|
||||||
notFound: 'Playlist niet gevonden.',
|
notFound: 'Playlist niet gevonden.',
|
||||||
songs: '{{n}} nummers',
|
songs: '{{n}} nummers',
|
||||||
playAll: 'Alles afspelen',
|
playAll: 'Alles afspelen',
|
||||||
@@ -3112,6 +3132,8 @@ const zhTranslation = {
|
|||||||
notWithCrossfade: '交叉淡入淡出开启时不可用',
|
notWithCrossfade: '交叉淡入淡出开启时不可用',
|
||||||
gapless: '无缝播放',
|
gapless: '无缝播放',
|
||||||
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
|
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
|
||||||
|
infiniteQueue: '无限队列',
|
||||||
|
infiniteQueueDesc: '队列播完时自动追加随机曲目',
|
||||||
experimental: '实验性',
|
experimental: '实验性',
|
||||||
},
|
},
|
||||||
changelog: {
|
changelog: {
|
||||||
@@ -3215,6 +3237,8 @@ const zhTranslation = {
|
|||||||
shuffle: '随机打乱队列',
|
shuffle: '随机打乱队列',
|
||||||
gapless: '无缝播放',
|
gapless: '无缝播放',
|
||||||
crossfade: '交叉淡入淡出',
|
crossfade: '交叉淡入淡出',
|
||||||
|
infiniteQueue: '无限队列',
|
||||||
|
autoAdded: '— 自动添加 —',
|
||||||
hide: '隐藏',
|
hide: '隐藏',
|
||||||
close: '关闭',
|
close: '关闭',
|
||||||
nextTracks: '即将播放',
|
nextTracks: '即将播放',
|
||||||
@@ -3316,6 +3340,7 @@ const zhTranslation = {
|
|||||||
cancel: '取消',
|
cancel: '取消',
|
||||||
empty: '暂无播放列表。',
|
empty: '暂无播放列表。',
|
||||||
emptyPlaylist: '此播放列表为空。',
|
emptyPlaylist: '此播放列表为空。',
|
||||||
|
addFirstSong: '添加第一首歌曲',
|
||||||
notFound: '未找到播放列表。',
|
notFound: '未找到播放列表。',
|
||||||
songs: '{{n}} 首歌曲',
|
songs: '{{n}} 首歌曲',
|
||||||
playAll: '全部播放',
|
playAll: '全部播放',
|
||||||
|
|||||||
+70
-23
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||||
import AlbumCard from '../components/AlbumCard';
|
import AlbumCard from '../components/AlbumCard';
|
||||||
import CachedImage from '../components/CachedImage';
|
import CachedImage from '../components/CachedImage';
|
||||||
import CoverLightbox from '../components/CoverLightbox';
|
import CoverLightbox from '../components/CoverLightbox';
|
||||||
@@ -46,6 +46,7 @@ export default function ArtistDetail() {
|
|||||||
const [info, setInfo] = useState<SubsonicArtistInfo | null>(null);
|
const [info, setInfo] = useState<SubsonicArtistInfo | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [radioLoading, setRadioLoading] = useState(false);
|
const [radioLoading, setRadioLoading] = useState(false);
|
||||||
|
const [playAllLoading, setPlayAllLoading] = useState(false);
|
||||||
const [isStarred, setIsStarred] = useState(false);
|
const [isStarred, setIsStarred] = useState(false);
|
||||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||||
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
|
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
|
||||||
@@ -57,6 +58,8 @@ export default function ArtistDetail() {
|
|||||||
const enqueue = usePlayerStore(state => state.enqueue);
|
const enqueue = usePlayerStore(state => state.enqueue);
|
||||||
const clearQueue = usePlayerStore(state => state.clearQueue);
|
const clearQueue = usePlayerStore(state => state.clearQueue);
|
||||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||||
|
const currentTrack = usePlayerStore(state => state.currentTrack);
|
||||||
|
const isPlaying = usePlayerStore(state => state.isPlaying);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -160,18 +163,34 @@ export default function ArtistDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePlayAll = () => {
|
const fetchAllTracks = async () => {
|
||||||
if (topSongs.length > 0) {
|
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
||||||
clearQueue();
|
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
||||||
playTrack(topSongs[0], topSongs);
|
return sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlayAll = async () => {
|
||||||
|
if (albums.length === 0) return;
|
||||||
|
setPlayAllLoading(true);
|
||||||
|
try {
|
||||||
|
const tracks = await fetchAllTracks();
|
||||||
|
if (tracks.length > 0) playTrack(tracks[0], tracks);
|
||||||
|
} finally {
|
||||||
|
setPlayAllLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShuffle = () => {
|
const handleShuffle = async () => {
|
||||||
if (topSongs.length > 0) {
|
if (albums.length === 0) return;
|
||||||
const shuffled = [...topSongs].sort(() => Math.random() - 0.5);
|
setPlayAllLoading(true);
|
||||||
clearQueue();
|
try {
|
||||||
playTrack(shuffled[0], shuffled);
|
const tracks = await fetchAllTracks();
|
||||||
|
if (tracks.length > 0) {
|
||||||
|
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
||||||
|
playTrack(shuffled[0], shuffled);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPlayAllLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -179,16 +198,33 @@ export default function ArtistDetail() {
|
|||||||
if (!artist) return;
|
if (!artist) return;
|
||||||
setRadioLoading(true);
|
setRadioLoading(true);
|
||||||
try {
|
try {
|
||||||
const similar = await getSimilarSongs2(artist.id, 50);
|
// Fire both fetches in parallel
|
||||||
if (similar.length > 0) {
|
const topPromise = getTopSongs(artist.name);
|
||||||
clearQueue();
|
const similarPromise = getSimilarSongs2(artist.id, 50);
|
||||||
playTrack(similar[0], similar);
|
|
||||||
|
// Start playing as soon as top songs arrive
|
||||||
|
const top = await topPromise;
|
||||||
|
if (top.length > 0) {
|
||||||
|
const firstTrack = songToTrack(top[0]);
|
||||||
|
playTrack(firstTrack, [firstTrack]);
|
||||||
|
setRadioLoading(false);
|
||||||
|
// Enqueue remaining tracks when similar songs arrive
|
||||||
|
const similar = await similarPromise;
|
||||||
|
const remaining = [...top.slice(1), ...similar].map(songToTrack);
|
||||||
|
if (remaining.length > 0) enqueue(remaining);
|
||||||
} else {
|
} else {
|
||||||
alert(t('artistDetail.noRadio'));
|
// No top songs — fall back to similar
|
||||||
|
const similar = await similarPromise;
|
||||||
|
if (similar.length > 0) {
|
||||||
|
const tracks = similar.map(songToTrack);
|
||||||
|
playTrack(tracks[0], tracks);
|
||||||
|
} else {
|
||||||
|
alert(t('artistDetail.noRadio'));
|
||||||
|
}
|
||||||
|
setRadioLoading(false);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Radio start failed', e);
|
console.error('Radio start failed', e);
|
||||||
} finally {
|
|
||||||
setRadioLoading(false);
|
setRadioLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -289,13 +325,15 @@ export default function ArtistDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||||||
{topSongs.length > 0 && (
|
{albums.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<button className="btn btn-primary" onClick={handlePlayAll}>
|
<button className="btn btn-primary" onClick={handlePlayAll} disabled={playAllLoading}>
|
||||||
<Play size={16} /> {t('artistDetail.playAll')}
|
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Play size={16} />}
|
||||||
|
{t('artistDetail.playAll')}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-surface" onClick={handleShuffle}>
|
<button className="btn btn-surface" onClick={handleShuffle} disabled={playAllLoading}>
|
||||||
<Shuffle size={16} /> {t('artistDetail.shuffle')}
|
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Shuffle size={16} />}
|
||||||
|
{t('artistDetail.shuffle')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -337,13 +375,22 @@ export default function ArtistDetail() {
|
|||||||
key={song.id}
|
key={song.id}
|
||||||
className="track-row"
|
className="track-row"
|
||||||
style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}
|
style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}
|
||||||
onDoubleClick={() => playTrack(track, topSongs.map(songToTrack))}
|
onClick={e => {
|
||||||
|
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||||
|
playTrack(track, topSongs.map(songToTrack));
|
||||||
|
}}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="track-num" style={{ textAlign: 'center' }}>{idx + 1}</div>
|
<div className="track-num" style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, topSongs.map(songToTrack)); }}>
|
||||||
|
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||||
|
{currentTrack?.id === song.id && isPlaying
|
||||||
|
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||||
|
: <Play size={13} fill="currentColor" />}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="track-info" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
<div className="track-info" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
{song.coverArt && (
|
{song.coverArt && (
|
||||||
<CachedImage
|
<CachedImage
|
||||||
|
|||||||
+12
-3
@@ -17,6 +17,8 @@ export default function Favorites() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const { playTrack, enqueue } = usePlayerStore();
|
const { playTrack, enqueue } = usePlayerStore();
|
||||||
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
|
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||||
const psyDrag = useDragDrop();
|
const psyDrag = useDragDrop();
|
||||||
|
|
||||||
function removeSong(id: string) {
|
function removeSong(id: string) {
|
||||||
@@ -105,7 +107,10 @@ export default function Favorites() {
|
|||||||
key={song.id}
|
key={song.id}
|
||||||
className="track-row track-row-va"
|
className="track-row track-row-va"
|
||||||
style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}
|
style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}
|
||||||
onDoubleClick={() => playTrack(track, songs.map(songToTrack))}
|
onClick={e => {
|
||||||
|
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||||
|
playTrack(track, songs.map(songToTrack));
|
||||||
|
}}
|
||||||
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||||
role="row"
|
role="row"
|
||||||
onMouseDown={e => {
|
onMouseDown={e => {
|
||||||
@@ -124,8 +129,12 @@ export default function Favorites() {
|
|||||||
document.addEventListener('mouseup', onUp);
|
document.addEventListener('mouseup', onUp);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="track-num col-center" onClick={() => playTrack(track, songs.map(songToTrack))} style={{ cursor: 'pointer' }}>
|
<div className="track-num col-center" style={{ cursor: 'pointer' }}>
|
||||||
{i + 1}
|
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||||
|
{currentTrack?.id === song.id && isPlaying
|
||||||
|
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||||
|
: <Play size={13} fill="currentColor" />}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="track-info">
|
<div className="track-info">
|
||||||
<span className="track-title">{song.title}</span>
|
<span className="track-title">{song.title}</span>
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export default function PlaylistDetail() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||||
const [hoveredSongId, setHoveredSongId] = useState<string | null>(null);
|
|
||||||
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
|
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
|
||||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||||
@@ -332,6 +331,10 @@ export default function PlaylistDetail() {
|
|||||||
document.addEventListener('mouseup', onUp);
|
document.addEventListener('mouseup', onUp);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Memoized derivations ──────────────────────────────────────
|
||||||
|
const existingIds = useMemo(() => new Set(songs.map(s => s.id)), [songs]);
|
||||||
|
const tracks = useMemo(() => songs.map(songToTrack), [songs]);
|
||||||
|
|
||||||
// ── Drag-over visual feedback ─────────────────────────────────
|
// ── Drag-over visual feedback ─────────────────────────────────
|
||||||
const handleRowMouseEnter = (idx: number, e: React.MouseEvent) => {
|
const handleRowMouseEnter = (idx: number, e: React.MouseEvent) => {
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
@@ -353,8 +356,6 @@ export default function PlaylistDetail() {
|
|||||||
return <div className="content-body"><div className="empty-state">{t('playlists.notFound')}</div></div>;
|
return <div className="content-body"><div className="empty-state">{t('playlists.notFound')}</div></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingIds = new Set(songs.map(s => s.id));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-body animate-fade-in">
|
<div className="content-body animate-fade-in">
|
||||||
|
|
||||||
@@ -393,7 +394,6 @@ export default function PlaylistDetail() {
|
|||||||
<button className="btn btn-primary" disabled={songs.length === 0} onClick={() => {
|
<button className="btn btn-primary" disabled={songs.length === 0} onClick={() => {
|
||||||
if (!songs.length) return;
|
if (!songs.length) return;
|
||||||
touchPlaylist(id!);
|
touchPlaylist(id!);
|
||||||
const tracks = songs.map(songToTrack);
|
|
||||||
playTrack(tracks[0], tracks);
|
playTrack(tracks[0], tracks);
|
||||||
}}>
|
}}>
|
||||||
<Play size={16} fill="currentColor" /> {t('playlists.playAll')}
|
<Play size={16} fill="currentColor" /> {t('playlists.playAll')}
|
||||||
@@ -401,7 +401,6 @@ export default function PlaylistDetail() {
|
|||||||
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
|
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
|
||||||
if (!songs.length) return;
|
if (!songs.length) return;
|
||||||
touchPlaylist(id!);
|
touchPlaylist(id!);
|
||||||
const tracks = songs.map(songToTrack);
|
|
||||||
const shuffled = [...tracks];
|
const shuffled = [...tracks];
|
||||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
@@ -414,7 +413,7 @@ export default function PlaylistDetail() {
|
|||||||
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
|
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
|
||||||
if (!songs.length) return;
|
if (!songs.length) return;
|
||||||
touchPlaylist(id!);
|
touchPlaylist(id!);
|
||||||
enqueue(songs.map(songToTrack));
|
enqueue(tracks);
|
||||||
}}>
|
}}>
|
||||||
<ListPlus size={16} /> {t('playlists.addToQueue')}
|
<ListPlus size={16} /> {t('playlists.addToQueue')}
|
||||||
</button>
|
</button>
|
||||||
@@ -453,20 +452,13 @@ export default function PlaylistDetail() {
|
|||||||
<div className="empty-state" style={{ padding: '0.5rem 0' }}>{t('playlists.noResults')}</div>
|
<div className="empty-state" style={{ padding: '0.5rem 0' }}>{t('playlists.noResults')}</div>
|
||||||
)}
|
)}
|
||||||
{searchResults.map(song => (
|
{searchResults.map(song => (
|
||||||
<div key={song.id} className="playlist-search-row">
|
<div key={song.id} className="playlist-search-row" style={{ cursor: 'pointer' }} onClick={() => addSong(song)}>
|
||||||
<CachedImage src={buildCoverArtUrl(song.coverArt ?? '', 40)} cacheKey={coverArtCacheKey(song.coverArt ?? '', 40)} alt="" className="playlist-search-thumb" />
|
<CachedImage src={buildCoverArtUrl(song.coverArt ?? '', 40)} cacheKey={coverArtCacheKey(song.coverArt ?? '', 40)} alt="" className="playlist-search-thumb" />
|
||||||
<div className="playlist-search-info">
|
<div className="playlist-search-info">
|
||||||
<span className="playlist-search-title">{song.title}</span>
|
<span className="playlist-search-title">{song.title}</span>
|
||||||
<span className="playlist-search-artist">{song.artist} · <span className="playlist-search-album">{song.album}</span></span>
|
<span className="playlist-search-artist">{song.artist} · <span className="playlist-search-album">{song.album}</span></span>
|
||||||
</div>
|
</div>
|
||||||
<span className="playlist-search-duration">{formatDuration(song.duration ?? 0)}</span>
|
<span className="playlist-search-duration">{formatDuration(song.duration ?? 0)}</span>
|
||||||
<button
|
|
||||||
className="playlist-search-add-btn"
|
|
||||||
data-tooltip={t('playlists.addSong')}
|
|
||||||
onClick={() => addSong(song)}
|
|
||||||
>
|
|
||||||
<Plus size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -532,7 +524,13 @@ export default function PlaylistDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{songs.length === 0 && (
|
{songs.length === 0 && (
|
||||||
<div className="empty-state" style={{ padding: '2rem 0' }}>{t('playlists.emptyPlaylist')}</div>
|
<div className="empty-state" style={{ padding: '2rem 0', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||||
|
<span>{t('playlists.emptyPlaylist')}</span>
|
||||||
|
<button className="btn btn-primary" onClick={() => setSearchOpen(true)}>
|
||||||
|
<Search size={15} />
|
||||||
|
{t('playlists.addFirstSong')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{songs.map((song, idx) => (
|
{songs.map((song, idx) => (
|
||||||
@@ -545,16 +543,14 @@ export default function PlaylistDetail() {
|
|||||||
<div
|
<div
|
||||||
data-track-idx={idx}
|
data-track-idx={idx}
|
||||||
className={`track-row track-row-va tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
className={`track-row track-row-va tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
||||||
onMouseEnter={e => { setHoveredSongId(song.id); handleRowMouseEnter(idx, e); }}
|
onMouseEnter={e => handleRowMouseEnter(idx, e)}
|
||||||
onMouseLeave={() => setHoveredSongId(null)}
|
|
||||||
onMouseDown={e => handleRowMouseDown(e, idx)}
|
onMouseDown={e => handleRowMouseDown(e, idx)}
|
||||||
onDoubleClick={() => {
|
|
||||||
const tracks = songs.map(songToTrack);
|
|
||||||
playTrack(tracks[idx], tracks);
|
|
||||||
}}
|
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
if (selectedIds.size > 0 && !(e.target as HTMLElement).closest('button, input')) {
|
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||||
|
if (selectedIds.size > 0) {
|
||||||
toggleSelect(song.id, idx, e.shiftKey);
|
toggleSelect(song.id, idx, e.shiftKey);
|
||||||
|
} else {
|
||||||
|
playTrack(tracks[idx], tracks);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onContextMenu={e => {
|
onContextMenu={e => {
|
||||||
@@ -563,7 +559,7 @@ export default function PlaylistDetail() {
|
|||||||
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* # — checkbox in select mode, grip/play on hover otherwise */}
|
{/* # — checkbox in select mode, always-visible play button otherwise */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const inSelectMode = selectedIds.size > 0;
|
const inSelectMode = selectedIds.size > 0;
|
||||||
return (
|
return (
|
||||||
@@ -572,26 +568,17 @@ export default function PlaylistDetail() {
|
|||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (inSelectMode || hoveredSongId === song.id) {
|
playTrack(tracks[idx], tracks);
|
||||||
toggleSelect(song.id, idx, e.shiftKey);
|
|
||||||
} else {
|
|
||||||
const tracks = songs.map(songToTrack);
|
|
||||||
playTrack(tracks[idx], tracks);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${(inSelectMode || hoveredSongId === song.id) ? ' bulk-check-visible' : ''}`}
|
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||||
onClick={e => { e.stopPropagation(); toggleSelect(song.id, idx, e.shiftKey); }}
|
onClick={e => { e.stopPropagation(); toggleSelect(song.id, idx, e.shiftKey); }}
|
||||||
/>
|
/>
|
||||||
<span style={{ color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : 'var(--text-muted)' }}>
|
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||||
{hoveredSongId === song.id && currentTrack?.id !== song.id && !inSelectMode
|
{currentTrack?.id === song.id && isPlaying
|
||||||
? <GripVertical size={13} />
|
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||||
: currentTrack?.id === song.id && isPlaying
|
: <Play size={13} fill="currentColor" />}
|
||||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
|
||||||
: currentTrack?.id === song.id
|
|
||||||
? <Play size={13} fill="currentColor" />
|
|
||||||
: idx + 1}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -696,7 +683,10 @@ export default function PlaylistDetail() {
|
|||||||
className={`track-row track-row-va tracklist-playlist${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
className={`track-row track-row-va tracklist-playlist${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||||
onMouseEnter={() => setHoveredSuggestionId(song.id)}
|
onMouseEnter={() => setHoveredSuggestionId(song.id)}
|
||||||
onMouseLeave={() => setHoveredSuggestionId(null)}
|
onMouseLeave={() => setHoveredSuggestionId(null)}
|
||||||
onDoubleClick={() => addSong(song)}
|
onClick={e => {
|
||||||
|
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||||
|
addSong(song);
|
||||||
|
}}
|
||||||
onContextMenu={e => {
|
onContextMenu={e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setContextMenuSongId(song.id);
|
setContextMenuSongId(song.id);
|
||||||
|
|||||||
@@ -414,6 +414,25 @@ export default function Settings() {
|
|||||||
<span className="toggle-track" />
|
<span className="toggle-track" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
|
||||||
|
{/* Infinite Queue */}
|
||||||
|
<div className="settings-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>
|
||||||
|
{t('settings.infiniteQueue')}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
{t('settings.infiniteQueueDesc')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="toggle-switch" aria-label={t('settings.infiniteQueue')}>
|
||||||
|
<input type="checkbox" checked={auth.infiniteQueueEnabled}
|
||||||
|
onChange={e => auth.setInfiniteQueueEnabled(e.target.checked)} id="infinite-queue-toggle" />
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ interface AuthState {
|
|||||||
crossfadeEnabled: boolean;
|
crossfadeEnabled: boolean;
|
||||||
crossfadeSecs: number;
|
crossfadeSecs: number;
|
||||||
gaplessEnabled: boolean;
|
gaplessEnabled: boolean;
|
||||||
|
infiniteQueueEnabled: boolean;
|
||||||
minimizeToTray: boolean;
|
minimizeToTray: boolean;
|
||||||
nowPlayingEnabled: boolean;
|
nowPlayingEnabled: boolean;
|
||||||
showChangelogOnUpdate: boolean;
|
showChangelogOnUpdate: boolean;
|
||||||
@@ -66,6 +67,7 @@ interface AuthState {
|
|||||||
setCrossfadeEnabled: (v: boolean) => void;
|
setCrossfadeEnabled: (v: boolean) => void;
|
||||||
setCrossfadeSecs: (v: number) => void;
|
setCrossfadeSecs: (v: number) => void;
|
||||||
setGaplessEnabled: (v: boolean) => void;
|
setGaplessEnabled: (v: boolean) => void;
|
||||||
|
setInfiniteQueueEnabled: (v: boolean) => void;
|
||||||
setMinimizeToTray: (v: boolean) => void;
|
setMinimizeToTray: (v: boolean) => void;
|
||||||
setNowPlayingEnabled: (v: boolean) => void;
|
setNowPlayingEnabled: (v: boolean) => void;
|
||||||
setShowChangelogOnUpdate: (v: boolean) => void;
|
setShowChangelogOnUpdate: (v: boolean) => void;
|
||||||
@@ -100,6 +102,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
crossfadeEnabled: false,
|
crossfadeEnabled: false,
|
||||||
crossfadeSecs: 3,
|
crossfadeSecs: 3,
|
||||||
gaplessEnabled: false,
|
gaplessEnabled: false,
|
||||||
|
infiniteQueueEnabled: false,
|
||||||
minimizeToTray: false,
|
minimizeToTray: false,
|
||||||
nowPlayingEnabled: false,
|
nowPlayingEnabled: false,
|
||||||
showChangelogOnUpdate: true,
|
showChangelogOnUpdate: true,
|
||||||
@@ -166,6 +169,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
|
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
|
||||||
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
|
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
|
||||||
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
|
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
|
||||||
|
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
|
||||||
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
|
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
|
||||||
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
|
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
|
||||||
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
|
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
|||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
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 { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong } from '../api/subsonic';
|
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs } from '../api/subsonic';
|
||||||
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
import { useOfflineStore } from './offlineStore';
|
import { useOfflineStore } from './offlineStore';
|
||||||
@@ -28,6 +28,7 @@ export interface Track {
|
|||||||
genre?: string;
|
genre?: string;
|
||||||
samplingRate?: number;
|
samplingRate?: number;
|
||||||
bitDepth?: number;
|
bitDepth?: number;
|
||||||
|
autoAdded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function songToTrack(song: SubsonicSong): Track {
|
export function songToTrack(song: SubsonicSong): Track {
|
||||||
@@ -671,16 +672,36 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
|
|
||||||
// ── next / previous ──────────────────────────────────────────────────────
|
// ── next / previous ──────────────────────────────────────────────────────
|
||||||
next: () => {
|
next: () => {
|
||||||
const { queue, queueIndex, repeatMode } = get();
|
const { queue, queueIndex, repeatMode, currentTrack } = get();
|
||||||
const nextIdx = queueIndex + 1;
|
const nextIdx = queueIndex + 1;
|
||||||
if (nextIdx < queue.length) {
|
if (nextIdx < queue.length) {
|
||||||
get().playTrack(queue[nextIdx], queue);
|
get().playTrack(queue[nextIdx], queue);
|
||||||
} else if (repeatMode === 'all' && queue.length > 0) {
|
} else if (repeatMode === 'all' && queue.length > 0) {
|
||||||
get().playTrack(queue[0], queue);
|
get().playTrack(queue[0], queue);
|
||||||
} else {
|
} else {
|
||||||
invoke('audio_stop').catch(console.error);
|
const { infiniteQueueEnabled } = useAuthStore.getState();
|
||||||
isAudioPaused = false;
|
if (infiniteQueueEnabled && repeatMode === 'off') {
|
||||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
getRandomSongs(25, currentTrack?.genre).then(songs => {
|
||||||
|
if (songs.length === 0) {
|
||||||
|
invoke('audio_stop').catch(console.error);
|
||||||
|
isAudioPaused = false;
|
||||||
|
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newTracks: Track[] = songs.map(s => ({ ...songToTrack(s), autoAdded: true }));
|
||||||
|
const currentQueue = get().queue;
|
||||||
|
const newQueue = [...currentQueue, ...newTracks];
|
||||||
|
get().playTrack(newTracks[0], newQueue);
|
||||||
|
}).catch(() => {
|
||||||
|
invoke('audio_stop').catch(console.error);
|
||||||
|
isAudioPaused = false;
|
||||||
|
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
invoke('audio_stop').catch(console.error);
|
||||||
|
isAudioPaused = false;
|
||||||
|
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1344,6 +1344,10 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
.track-row:hover .track-num .bulk-check {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* Equalizer bars — shown for the currently playing track */
|
/* Equalizer bars — shown for the currently playing track */
|
||||||
.eq-bars {
|
.eq-bars {
|
||||||
|
|||||||
Reference in New Issue
Block a user