mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
Merge remote-tracking branch 'origin/main' into exp/orbit
# Conflicts: # src/api/subsonic.ts
This commit is contained in:
@@ -565,6 +565,121 @@ async fn nd_set_user_libraries(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists.
|
||||
#[tauri::command]
|
||||
async fn nd_list_playlists(
|
||||
server_url: String,
|
||||
token: String,
|
||||
smart: Option<bool>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let resp = nd_retry(|| {
|
||||
let client = nd_http_client();
|
||||
let mut req = client
|
||||
.get(format!("{}/api/playlist", server_url))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token));
|
||||
if let Some(s) = smart {
|
||||
req = req.query(&[("smart", s)]);
|
||||
}
|
||||
req.send()
|
||||
})
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
resp.json::<serde_json::Value>().await.map_err(nd_err)
|
||||
}
|
||||
|
||||
/// POST `/api/playlist` — create playlist (supports smart rules payload).
|
||||
#[tauri::command]
|
||||
async fn nd_create_playlist(
|
||||
server_url: String,
|
||||
token: String,
|
||||
body: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.post(format!("{}/api/playlist", server_url))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.json(&body)
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(format!("HTTP {}: {}", status, text));
|
||||
}
|
||||
serde_json::from_str(&text).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload).
|
||||
#[tauri::command]
|
||||
async fn nd_update_playlist(
|
||||
server_url: String,
|
||||
token: String,
|
||||
id: String,
|
||||
body: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.put(format!("{}/api/playlist/{}", server_url, id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.json(&body)
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(format!("HTTP {}: {}", status, text));
|
||||
}
|
||||
Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null))
|
||||
}
|
||||
|
||||
/// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available).
|
||||
#[tauri::command]
|
||||
async fn nd_get_playlist(
|
||||
server_url: String,
|
||||
token: String,
|
||||
id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.get(format!("{}/api/playlist/{}", server_url, id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(format!("HTTP {}: {}", status, text));
|
||||
}
|
||||
Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null))
|
||||
}
|
||||
|
||||
/// DELETE `/api/playlist/{id}` — delete playlist.
|
||||
#[tauri::command]
|
||||
async fn nd_delete_playlist(
|
||||
server_url: String,
|
||||
token: String,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.delete(format!("{}/api/playlist/{}", server_url, id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("HTTP {}: {}", status, text));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const RADIO_PAGE_SIZE: u32 = 25;
|
||||
|
||||
/// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView).
|
||||
@@ -3724,6 +3839,11 @@ pub fn run() {
|
||||
nd_delete_user,
|
||||
nd_list_libraries,
|
||||
nd_set_user_libraries,
|
||||
nd_list_playlists,
|
||||
nd_create_playlist,
|
||||
nd_update_playlist,
|
||||
nd_get_playlist,
|
||||
nd_delete_playlist,
|
||||
search_radio_browser,
|
||||
get_top_radio_stations,
|
||||
fetch_url_bytes,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { ndLogin } from './navidromeAdmin';
|
||||
|
||||
export type SmartRuleOperator =
|
||||
| 'is'
|
||||
| 'isNot'
|
||||
| 'contains'
|
||||
| 'notContains'
|
||||
| 'startsWith'
|
||||
| 'endsWith'
|
||||
| 'gt'
|
||||
| 'lt'
|
||||
| 'inTheRange';
|
||||
|
||||
export interface SmartRuleCondition {
|
||||
field: string;
|
||||
operator: SmartRuleOperator;
|
||||
value: string | number | boolean | [number, number];
|
||||
}
|
||||
|
||||
export interface NdSmartPlaylist {
|
||||
id: string;
|
||||
name: string;
|
||||
songCount: number;
|
||||
duration?: number;
|
||||
rules?: Record<string, unknown>;
|
||||
sync?: boolean;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
function parseNdSmartPlaylist(raw: unknown, fallback: Partial<NdSmartPlaylist> = {}): NdSmartPlaylist {
|
||||
const o = (raw as Record<string, unknown>) ?? {};
|
||||
return {
|
||||
id: String(o.id ?? fallback.id ?? ''),
|
||||
name: String(o.name ?? fallback.name ?? ''),
|
||||
songCount: Number(o.songCount ?? fallback.songCount ?? 0),
|
||||
duration: typeof o.duration === 'number' ? o.duration : fallback.duration,
|
||||
rules: typeof o.rules === 'object' && o.rules ? (o.rules as Record<string, unknown>) : fallback.rules,
|
||||
sync: typeof o.sync === 'boolean' ? o.sync : fallback.sync,
|
||||
updatedAt: typeof o.updatedAt === 'string' ? o.updatedAt : fallback.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
let authCache: {
|
||||
key: string;
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
} | null = null;
|
||||
|
||||
async function getNavidromeAuth(): Promise<{ serverUrl: string; token: string }> {
|
||||
const s = useAuthStore.getState();
|
||||
const server = s.getActiveServer();
|
||||
const serverUrl = s.getBaseUrl();
|
||||
if (!serverUrl || !server?.username || !server?.password) {
|
||||
throw new Error('No active server credentials');
|
||||
}
|
||||
const key = `${serverUrl}|${server.username}|${server.password}`;
|
||||
if (authCache && authCache.key === key && Date.now() < authCache.expiresAt) {
|
||||
return { serverUrl, token: authCache.token };
|
||||
}
|
||||
const login = await ndLogin(serverUrl, server.username, server.password);
|
||||
authCache = {
|
||||
key,
|
||||
token: login.token,
|
||||
expiresAt: Date.now() + 10 * 60 * 1000,
|
||||
};
|
||||
return { serverUrl, token: login.token };
|
||||
}
|
||||
|
||||
function conditionToRule(c: SmartRuleCondition): Record<string, unknown> {
|
||||
return { [c.operator]: { [c.field]: c.value } };
|
||||
}
|
||||
|
||||
export function buildSmartRules(conditions: SmartRuleCondition[], opts?: { limit?: number; sort?: string }) {
|
||||
const all = conditions.map(conditionToRule);
|
||||
const rules: Record<string, unknown> = { all };
|
||||
if (typeof opts?.limit === 'number' && opts.limit > 0) rules.limit = opts.limit;
|
||||
if (opts?.sort) rules.sort = opts.sort;
|
||||
return rules;
|
||||
}
|
||||
|
||||
export async function ndListSmartPlaylists(): Promise<NdSmartPlaylist[]> {
|
||||
const { serverUrl, token } = await getNavidromeAuth();
|
||||
const raw = await invoke<unknown>('nd_list_playlists', { serverUrl, token, smart: true });
|
||||
const list = Array.isArray(raw)
|
||||
? raw
|
||||
: (raw && typeof raw === 'object' && Array.isArray((raw as { items?: unknown[] }).items))
|
||||
? (raw as { items: unknown[] }).items
|
||||
: [];
|
||||
return list.map((v) => parseNdSmartPlaylist(v));
|
||||
}
|
||||
|
||||
export async function ndCreateSmartPlaylist(name: string, rules: Record<string, unknown>, sync = true): Promise<NdSmartPlaylist> {
|
||||
const { serverUrl, token } = await getNavidromeAuth();
|
||||
const raw = await invoke<unknown>('nd_create_playlist', {
|
||||
serverUrl,
|
||||
token,
|
||||
body: { name, rules, sync },
|
||||
});
|
||||
return parseNdSmartPlaylist(raw, { name, rules, sync });
|
||||
}
|
||||
|
||||
export async function ndUpdateSmartPlaylist(
|
||||
id: string,
|
||||
name: string,
|
||||
rules: Record<string, unknown>,
|
||||
sync = true,
|
||||
): Promise<NdSmartPlaylist> {
|
||||
const { serverUrl, token } = await getNavidromeAuth();
|
||||
const raw = await invoke<unknown>('nd_update_playlist', {
|
||||
serverUrl,
|
||||
token,
|
||||
id,
|
||||
body: { name, rules, sync },
|
||||
});
|
||||
return parseNdSmartPlaylist(raw, { id, name, rules, sync });
|
||||
}
|
||||
|
||||
export async function ndGetSmartPlaylist(id: string): Promise<NdSmartPlaylist> {
|
||||
const { serverUrl, token } = await getNavidromeAuth();
|
||||
const raw = await invoke<unknown>('nd_get_playlist', { serverUrl, token, id });
|
||||
return parseNdSmartPlaylist(raw, { id });
|
||||
}
|
||||
|
||||
export async function ndDeletePlaylist(id: string): Promise<void> {
|
||||
const { serverUrl, token } = await getNavidromeAuth();
|
||||
await invoke('nd_delete_playlist', { serverUrl, token, id });
|
||||
}
|
||||
+25
-1
@@ -499,6 +499,30 @@ export async function getRandomSongs(size = 50, genre?: string, timeout = 15000)
|
||||
return data.randomSongs?.song ?? [];
|
||||
}
|
||||
|
||||
export interface RandomSongsFilters {
|
||||
size?: number;
|
||||
genre?: string;
|
||||
fromYear?: number;
|
||||
toYear?: number;
|
||||
}
|
||||
|
||||
/** Extended random song fetch with server-side year/genre filtering. */
|
||||
export async function getRandomSongsFiltered(
|
||||
filters: RandomSongsFilters,
|
||||
timeout = 15000,
|
||||
): Promise<SubsonicSong[]> {
|
||||
const params: Record<string, string | number> = {
|
||||
size: filters.size ?? 50,
|
||||
_t: Date.now(),
|
||||
...libraryFilterParams(),
|
||||
};
|
||||
if (filters.genre) params.genre = filters.genre;
|
||||
if (typeof filters.fromYear === 'number') params.fromYear = filters.fromYear;
|
||||
if (typeof filters.toYear === 'number') params.toYear = filters.toYear;
|
||||
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout);
|
||||
return data.randomSongs?.song ?? [];
|
||||
}
|
||||
|
||||
export async function getSong(id: string): Promise<SubsonicSong | null> {
|
||||
try {
|
||||
const data = await api<{ song: SubsonicSong }>('getSong.view', { id });
|
||||
@@ -995,7 +1019,7 @@ export function buildDownloadUrl(id: string): string {
|
||||
|
||||
// ─── Playlists ────────────────────────────────────────────────
|
||||
export async function getPlaylists(includeOrbit = false): Promise<SubsonicPlaylist[]> {
|
||||
const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view');
|
||||
const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view', { _t: Date.now() });
|
||||
const all = data.playlists?.playlist ?? [];
|
||||
// Orbit session + outbox playlists are technical internals. They're `public`
|
||||
// so guests can reach them, which means they leak into every UI picker and
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Settings,
|
||||
PanelLeftClose, PanelLeft, AudioLines, HardDriveDownload, HardDriveUpload,
|
||||
ChevronDown, Check, Music2, X, ChevronRight, PlayCircle, Trash2,
|
||||
ChevronDown, Check, Music2, X, ChevronRight, PlayCircle, Sparkles, Trash2,
|
||||
} from 'lucide-react';
|
||||
import PsysonicLogo from './PsysonicLogo';
|
||||
import PSmallLogo from './PSmallLogo';
|
||||
@@ -31,6 +31,17 @@ import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
|
||||
|
||||
const SIDEBAR_NAV_LONG_PRESS_MS = 1000;
|
||||
const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10;
|
||||
const SMART_PREFIX = 'psy-smart-';
|
||||
|
||||
function isSmartPlaylistName(name: string): boolean {
|
||||
return (name ?? '').toLowerCase().startsWith(SMART_PREFIX);
|
||||
}
|
||||
|
||||
function displayPlaylistName(name: string): string {
|
||||
const n = name ?? '';
|
||||
if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length);
|
||||
return n;
|
||||
}
|
||||
|
||||
function isPointerOutsideAsideSidebar(clientX: number, clientY: number): boolean {
|
||||
const aside = document.querySelector('aside.sidebar');
|
||||
@@ -569,11 +580,11 @@ export default function Sidebar({
|
||||
key={pl.id}
|
||||
to={`/playlists/${pl.id}`}
|
||||
className={({ isActive }) => `nav-link sidebar-playlist-item ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? pl.name : undefined}
|
||||
data-tooltip={isCollapsed ? displayPlaylistName(pl.name) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<PlayCircle size={12} />
|
||||
<span>{pl.name}</span>
|
||||
{isSmartPlaylistName(pl.name) ? <Sparkles size={12} /> : <PlayCircle size={12} />}
|
||||
<span>{displayPlaylistName(pl.name)}</span>
|
||||
</NavLink>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1318,6 +1318,47 @@ export const deTranslation = {
|
||||
csvImportDownloadSuccess: 'Bericht erfolgreich heruntergeladen',
|
||||
csvImportDownloadError: 'Bericht konnte nicht heruntergeladen werden',
|
||||
},
|
||||
smartPlaylists: {
|
||||
sectionBasic: '1. Basis',
|
||||
sectionGenres: '2. Genres',
|
||||
sectionYearsAndFilters: '3. Jahre und Filter',
|
||||
genreMode: 'Genre-Modus',
|
||||
genreModeInclude: 'Genres einschließen',
|
||||
genreModeExclude: 'Genres ausschließen',
|
||||
genreSearchPlaceholder: 'Genres filtern...',
|
||||
availableGenres: 'Verfügbar',
|
||||
selectedGenres: 'Ausgewählt',
|
||||
yearMode: 'Jahresmodus',
|
||||
yearModeInclude: 'Bereich einschließen',
|
||||
yearModeExclude: 'Bereich ausschließen',
|
||||
name: 'Name (ohne Präfix)',
|
||||
limit: 'Limit',
|
||||
limitHint: 'Wie viele Songs in die Playlist aufgenommen werden (1-{{max}}, üblicherweise 50).',
|
||||
artistContains: 'Künstler enthält…',
|
||||
albumContains: 'Album enthält…',
|
||||
titleContains: 'Titel enthält…',
|
||||
minRating: 'Mindestbewertung',
|
||||
minRatingAria: 'Mindestbewertung für Smart Playlist',
|
||||
minRatingHint: '0 deaktiviert die Mindestgrenze; 1-5 behält Songs mit Bewertung über dem gewählten Wert.',
|
||||
fromYear: 'Von Jahr',
|
||||
toYear: 'Bis Jahr',
|
||||
excludeUnrated: 'Unbewertete Songs ausschließen',
|
||||
compilationOnly: 'Nur Sammlungen',
|
||||
create: 'Neue Smart Playlist',
|
||||
save: 'Smart Playlist speichern',
|
||||
created: '{{name}} erstellt',
|
||||
updated: '{{name}} aktualisiert',
|
||||
createFailed: 'Smart Playlist konnte nicht erstellt werden.',
|
||||
updateFailed: 'Smart Playlist konnte nicht aktualisiert werden.',
|
||||
navidromeOnly: 'Smart Playlists können nur auf Navidrome-Servern erstellt werden.',
|
||||
loadFailed: 'Smart Playlists konnten nicht von Navidrome geladen werden.',
|
||||
sortRandom: 'Sortierung: zufällig',
|
||||
sortTitleAsc: 'Sortierung: Titel A-Z',
|
||||
sortTitleDesc: 'Sortierung: Titel Z-A',
|
||||
sortYearDesc: 'Sortierung: Jahr absteigend',
|
||||
sortYearAsc: 'Sortierung: Jahr aufsteigend',
|
||||
sortPlayCountDesc: 'Sortierung: Wiedergaben absteigend',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Meistgehört',
|
||||
topArtists: 'Top-Künstler',
|
||||
|
||||
@@ -23,6 +23,7 @@ export const enTranslation = {
|
||||
offlineLibrary: 'Offline Library',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
smartPlaylists: 'Smart Playlists',
|
||||
mostPlayed: 'Most Played',
|
||||
radio: 'Internet Radio',
|
||||
folderBrowser: 'Folder Browser',
|
||||
@@ -1320,6 +1321,47 @@ export const enTranslation = {
|
||||
csvImportDownloadSuccess: 'Report downloaded successfully',
|
||||
csvImportDownloadError: 'Failed to download report',
|
||||
},
|
||||
smartPlaylists: {
|
||||
sectionBasic: '1. Basic',
|
||||
sectionGenres: '2. Genres',
|
||||
sectionYearsAndFilters: '3. Years and filters',
|
||||
genreMode: 'Genre mode',
|
||||
genreModeInclude: 'Include genres',
|
||||
genreModeExclude: 'Exclude genres',
|
||||
genreSearchPlaceholder: 'Filter genres...',
|
||||
availableGenres: 'Available',
|
||||
selectedGenres: 'Selected',
|
||||
yearMode: 'Year mode',
|
||||
yearModeInclude: 'Include range',
|
||||
yearModeExclude: 'Exclude range',
|
||||
name: 'Name (without prefix)',
|
||||
limit: 'Limit',
|
||||
limitHint: 'How many tracks to include in playlist (1-{{max}}, usually 50).',
|
||||
artistContains: 'Artist contains…',
|
||||
albumContains: 'Album contains…',
|
||||
titleContains: 'Title contains…',
|
||||
minRating: 'Minimum rating',
|
||||
minRatingAria: 'Minimum rating for smart playlist',
|
||||
minRatingHint: '0 disables minimum threshold; 1-5 keeps tracks with rating above selected value.',
|
||||
fromYear: 'From year',
|
||||
toYear: 'To year',
|
||||
excludeUnrated: 'Exclude unrated tracks',
|
||||
compilationOnly: 'Only compilations',
|
||||
create: 'New Smart Playlist',
|
||||
save: 'Save Smart Playlist',
|
||||
created: 'Created {{name}}',
|
||||
updated: 'Updated {{name}}',
|
||||
createFailed: 'Could not create smart playlist.',
|
||||
updateFailed: 'Could not update smart playlist.',
|
||||
navidromeOnly: 'Smart playlists can only be created on Navidrome servers.',
|
||||
loadFailed: 'Could not load smart playlists from Navidrome.',
|
||||
sortRandom: 'Sort: random',
|
||||
sortTitleAsc: 'Sort: title A-Z',
|
||||
sortTitleDesc: 'Sort: title Z-A',
|
||||
sortYearDesc: 'Sort: year desc',
|
||||
sortYearAsc: 'Sort: year asc',
|
||||
sortPlayCountDesc: 'Sort: play count desc',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Most Played',
|
||||
topArtists: 'Top Artists',
|
||||
|
||||
@@ -1311,6 +1311,47 @@ export const esTranslation = {
|
||||
csvImportDownloadSuccess: 'Reporte descargado exitosamente',
|
||||
csvImportDownloadError: 'Error al descargar el reporte',
|
||||
},
|
||||
smartPlaylists: {
|
||||
sectionBasic: '1. Básico',
|
||||
sectionGenres: '2. Géneros',
|
||||
sectionYearsAndFilters: '3. Años y filtros',
|
||||
genreMode: 'Modo de géneros',
|
||||
genreModeInclude: 'Incluir géneros',
|
||||
genreModeExclude: 'Excluir géneros',
|
||||
genreSearchPlaceholder: 'Filtrar géneros...',
|
||||
availableGenres: 'Disponibles',
|
||||
selectedGenres: 'Seleccionados',
|
||||
yearMode: 'Modo de años',
|
||||
yearModeInclude: 'Incluir rango',
|
||||
yearModeExclude: 'Excluir rango',
|
||||
name: 'Nombre (sin prefijo)',
|
||||
limit: 'Límite',
|
||||
limitHint: 'Cuántas canciones incluir en la playlist (1-{{max}}, normalmente 50).',
|
||||
artistContains: 'Artista contiene…',
|
||||
albumContains: 'Álbum contiene…',
|
||||
titleContains: 'Título contiene…',
|
||||
minRating: 'Valoración mínima',
|
||||
minRatingAria: 'Valoración mínima para la playlist inteligente',
|
||||
minRatingHint: '0 desactiva el umbral mínimo; 1-5 mantiene canciones con valoración mayor que el valor seleccionado.',
|
||||
fromYear: 'Desde año',
|
||||
toYear: 'Hasta año',
|
||||
excludeUnrated: 'Excluir canciones sin valoración',
|
||||
compilationOnly: 'Solo compilaciones',
|
||||
create: 'Nueva Smart Playlist',
|
||||
save: 'Guardar Smart Playlist',
|
||||
created: '{{name}} creada',
|
||||
updated: '{{name}} actualizada',
|
||||
createFailed: 'No se pudo crear la smart playlist.',
|
||||
updateFailed: 'No se pudo actualizar la smart playlist.',
|
||||
navidromeOnly: 'Las smart playlists solo se pueden crear en servidores Navidrome.',
|
||||
loadFailed: 'No se pudieron cargar las smart playlists desde Navidrome.',
|
||||
sortRandom: 'Ordenar: aleatorio',
|
||||
sortTitleAsc: 'Ordenar: título A-Z',
|
||||
sortTitleDesc: 'Ordenar: título Z-A',
|
||||
sortYearDesc: 'Ordenar: año desc',
|
||||
sortYearAsc: 'Ordenar: año asc',
|
||||
sortPlayCountDesc: 'Ordenar: reproducciones desc',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Más Reproducidos',
|
||||
topArtists: 'Artistas Principales',
|
||||
|
||||
@@ -1306,6 +1306,47 @@ export const frTranslation = {
|
||||
csvImportDownloadSuccess: 'Rapport téléchargé avec succès',
|
||||
csvImportDownloadError: 'Échec du téléchargement du rapport',
|
||||
},
|
||||
smartPlaylists: {
|
||||
sectionBasic: '1. Base',
|
||||
sectionGenres: '2. Genres',
|
||||
sectionYearsAndFilters: '3. Années et filtres',
|
||||
genreMode: 'Mode des genres',
|
||||
genreModeInclude: 'Inclure les genres',
|
||||
genreModeExclude: 'Exclure les genres',
|
||||
genreSearchPlaceholder: 'Filtrer les genres...',
|
||||
availableGenres: 'Disponibles',
|
||||
selectedGenres: 'Sélectionnés',
|
||||
yearMode: 'Mode des années',
|
||||
yearModeInclude: 'Inclure la plage',
|
||||
yearModeExclude: 'Exclure la plage',
|
||||
name: 'Nom (sans préfixe)',
|
||||
limit: 'Limite',
|
||||
limitHint: 'Nombre de morceaux à inclure dans la playlist (1-{{max}}, généralement 50).',
|
||||
artistContains: 'Artiste contient…',
|
||||
albumContains: 'Album contient…',
|
||||
titleContains: 'Titre contient…',
|
||||
minRating: 'Note minimale',
|
||||
minRatingAria: 'Note minimale pour playlist intelligente',
|
||||
minRatingHint: '0 désactive le seuil minimum; 1-5 conserve les morceaux avec une note supérieure à la valeur choisie.',
|
||||
fromYear: 'Depuis l\'année',
|
||||
toYear: 'Jusqu\'à l\'année',
|
||||
excludeUnrated: 'Exclure les morceaux non notés',
|
||||
compilationOnly: 'Compilations uniquement',
|
||||
create: 'Nouvelle Smart Playlist',
|
||||
save: 'Enregistrer la Smart Playlist',
|
||||
created: '{{name}} créée',
|
||||
updated: '{{name}} mise à jour',
|
||||
createFailed: 'Impossible de créer la smart playlist.',
|
||||
updateFailed: 'Impossible de mettre à jour la smart playlist.',
|
||||
navidromeOnly: 'Les smart playlists ne peuvent être créées que sur des serveurs Navidrome.',
|
||||
loadFailed: 'Impossible de charger les smart playlists depuis Navidrome.',
|
||||
sortRandom: 'Tri : aléatoire',
|
||||
sortTitleAsc: 'Tri : titre A-Z',
|
||||
sortTitleDesc: 'Tri : titre Z-A',
|
||||
sortYearDesc: 'Tri : année décroissante',
|
||||
sortYearAsc: 'Tri : année croissante',
|
||||
sortPlayCountDesc: 'Tri : nombre d\'écoutes décroissant',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Les plus joués',
|
||||
topArtists: 'Artistes populaires',
|
||||
|
||||
@@ -1318,6 +1318,47 @@ export const nbTranslation = {
|
||||
filterCompilations: 'Skjul kompilasjonsartister (Various Artists, Soundtracks, etc.)',
|
||||
filterCompilationsShort: 'Skjul kompilasjoner',
|
||||
},
|
||||
smartPlaylists: {
|
||||
sectionBasic: '1. Grunnleggende',
|
||||
sectionGenres: '2. Sjanger',
|
||||
sectionYearsAndFilters: '3. År og filtre',
|
||||
genreMode: 'Sjanger-modus',
|
||||
genreModeInclude: 'Inkluder sjangre',
|
||||
genreModeExclude: 'Ekskluder sjangre',
|
||||
genreSearchPlaceholder: 'Filtrer sjangre...',
|
||||
availableGenres: 'Tilgjengelige',
|
||||
selectedGenres: 'Valgte',
|
||||
yearMode: 'År-modus',
|
||||
yearModeInclude: 'Inkluder område',
|
||||
yearModeExclude: 'Ekskluder område',
|
||||
name: 'Navn (uten prefiks)',
|
||||
limit: 'Grense',
|
||||
limitHint: 'Hvor mange spor som skal inkluderes i spillelisten (1-{{max}}, vanligvis 50).',
|
||||
artistContains: 'Artist inneholder…',
|
||||
albumContains: 'Album inneholder…',
|
||||
titleContains: 'Tittel inneholder…',
|
||||
minRating: 'Minimumsvurdering',
|
||||
minRatingAria: 'Minimumsvurdering for smart-spilleliste',
|
||||
minRatingHint: '0 deaktiverer minimumsterskelen; 1-5 beholder spor med vurdering over valgt verdi.',
|
||||
fromYear: 'Fra år',
|
||||
toYear: 'Til år',
|
||||
excludeUnrated: 'Ekskluder spor uten vurdering',
|
||||
compilationOnly: 'Kun samlinger',
|
||||
create: 'Ny Smart-spilleliste',
|
||||
save: 'Lagre Smart-spilleliste',
|
||||
created: '{{name}} opprettet',
|
||||
updated: '{{name}} oppdatert',
|
||||
createFailed: 'Kunne ikke opprette smart-spilleliste.',
|
||||
updateFailed: 'Kunne ikke oppdatere smart-spilleliste.',
|
||||
navidromeOnly: 'Smart-spillelister kan bare opprettes på Navidrome-servere.',
|
||||
loadFailed: 'Kunne ikke laste smart-spillelister fra Navidrome.',
|
||||
sortRandom: 'Sortering: tilfeldig',
|
||||
sortTitleAsc: 'Sortering: tittel A-Å',
|
||||
sortTitleDesc: 'Sortering: tittel Å-A',
|
||||
sortYearDesc: 'Sortering: år synkende',
|
||||
sortYearAsc: 'Sortering: år stigende',
|
||||
sortPlayCountDesc: 'Sortering: avspillinger synkende',
|
||||
},
|
||||
radio: {
|
||||
title: 'Internettradio',
|
||||
empty: 'Ingen radiostasjoner konfigurert.',
|
||||
|
||||
@@ -1305,6 +1305,47 @@ export const nlTranslation = {
|
||||
csvImportDownloadSuccess: 'Rapport succesvol gedownload',
|
||||
csvImportDownloadError: 'Rapport downloaden mislukt',
|
||||
},
|
||||
smartPlaylists: {
|
||||
sectionBasic: '1. Basis',
|
||||
sectionGenres: '2. Genres',
|
||||
sectionYearsAndFilters: '3. Jaren en filters',
|
||||
genreMode: 'Genre-modus',
|
||||
genreModeInclude: 'Genres opnemen',
|
||||
genreModeExclude: 'Genres uitsluiten',
|
||||
genreSearchPlaceholder: 'Genres filteren...',
|
||||
availableGenres: 'Beschikbaar',
|
||||
selectedGenres: 'Geselecteerd',
|
||||
yearMode: 'Jaarmodus',
|
||||
yearModeInclude: 'Bereik opnemen',
|
||||
yearModeExclude: 'Bereik uitsluiten',
|
||||
name: 'Naam (zonder prefix)',
|
||||
limit: 'Limiet',
|
||||
limitHint: 'Hoeveel nummers in de playlist moeten komen (1-{{max}}, meestal 50).',
|
||||
artistContains: 'Artiest bevat…',
|
||||
albumContains: 'Album bevat…',
|
||||
titleContains: 'Titel bevat…',
|
||||
minRating: 'Minimale beoordeling',
|
||||
minRatingAria: 'Minimale beoordeling voor smart playlist',
|
||||
minRatingHint: '0 schakelt de minimumdrempel uit; 1-5 behoudt nummers met een beoordeling boven de gekozen waarde.',
|
||||
fromYear: 'Vanaf jaar',
|
||||
toYear: 'Tot jaar',
|
||||
excludeUnrated: 'Nummers zonder beoordeling uitsluiten',
|
||||
compilationOnly: 'Alleen compilaties',
|
||||
create: 'Nieuwe Smart Playlist',
|
||||
save: 'Smart Playlist opslaan',
|
||||
created: '{{name}} aangemaakt',
|
||||
updated: '{{name}} bijgewerkt',
|
||||
createFailed: 'Smart playlist kon niet worden aangemaakt.',
|
||||
updateFailed: 'Smart playlist kon niet worden bijgewerkt.',
|
||||
navidromeOnly: 'Smart playlists kunnen alleen op Navidrome-servers worden aangemaakt.',
|
||||
loadFailed: 'Smart playlists konden niet vanuit Navidrome worden geladen.',
|
||||
sortRandom: 'Sorteren: willekeurig',
|
||||
sortTitleAsc: 'Sorteren: titel A-Z',
|
||||
sortTitleDesc: 'Sorteren: titel Z-A',
|
||||
sortYearDesc: 'Sorteren: jaar aflopend',
|
||||
sortYearAsc: 'Sorteren: jaar oplopend',
|
||||
sortPlayCountDesc: 'Sorteren: afspeeltelling aflopend',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Meest gespeeld',
|
||||
topArtists: 'Topkunstenaars',
|
||||
|
||||
@@ -23,6 +23,7 @@ export const ruTranslation = {
|
||||
offlineLibrary: 'Офлайн-библиотека',
|
||||
genres: 'Жанры',
|
||||
playlists: 'Плейлисты',
|
||||
smartPlaylists: 'Смарт-плейлисты',
|
||||
mostPlayed: 'Популярное',
|
||||
radio: 'Онлайн-радио',
|
||||
folderBrowser: 'Браузер папок',
|
||||
@@ -1384,6 +1385,47 @@ export const ruTranslation = {
|
||||
csvImportDownloadSuccess: 'Отчёт успешно скачан',
|
||||
csvImportDownloadError: 'Не удалось скачать отчёт',
|
||||
},
|
||||
smartPlaylists: {
|
||||
sectionBasic: '1. База',
|
||||
sectionGenres: '2. Жанры',
|
||||
sectionYearsAndFilters: '3. Годы и фильтры',
|
||||
genreMode: 'Режим жанров',
|
||||
genreModeInclude: 'Включить жанры',
|
||||
genreModeExclude: 'Исключить жанры',
|
||||
genreSearchPlaceholder: 'Фильтр жанров...',
|
||||
availableGenres: 'Доступные',
|
||||
selectedGenres: 'Выбранные',
|
||||
yearMode: 'Режим годов',
|
||||
yearModeInclude: 'Включить диапазон',
|
||||
yearModeExclude: 'Исключить диапазон',
|
||||
name: 'Название (без префикса)',
|
||||
limit: 'Лимит',
|
||||
limitHint: 'Сколько треков включать в плейлист (1-{{max}}, обычно 50).',
|
||||
artistContains: 'Исполнитель содержит…',
|
||||
albumContains: 'Альбом содержит…',
|
||||
titleContains: 'Название содержит…',
|
||||
minRating: 'Мин. рейтинг',
|
||||
minRatingAria: 'Минимальный рейтинг для смарт-плейлиста',
|
||||
minRatingHint: '0 — без минимального порога; 1-5 — оставить треки с рейтингом выше выбранного.',
|
||||
fromYear: 'Год от',
|
||||
toYear: 'Год до',
|
||||
excludeUnrated: 'Исключить треки без рейтинга',
|
||||
compilationOnly: 'Только сборники',
|
||||
create: 'Создать смарт-плейлист',
|
||||
save: 'Сохранить смарт-плейлист',
|
||||
created: 'Создан {{name}}',
|
||||
updated: 'Обновлён {{name}}',
|
||||
createFailed: 'Не удалось создать смарт-плейлист.',
|
||||
updateFailed: 'Не удалось обновить смарт-плейлист.',
|
||||
navidromeOnly: 'Смарт-плейлисты можно создавать только на серверах Navidrome.',
|
||||
loadFailed: 'Не удалось загрузить смарт-плейлисты из Navidrome.',
|
||||
sortRandom: 'Сортировка: случайно',
|
||||
sortTitleAsc: 'Сортировка: название А-Я',
|
||||
sortTitleDesc: 'Сортировка: название Я-А',
|
||||
sortYearDesc: 'Сортировка: год по убыванию',
|
||||
sortYearAsc: 'Сортировка: год по возрастанию',
|
||||
sortPlayCountDesc: 'Сортировка: прослушивания по убыванию',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Популярное',
|
||||
topArtists: 'Топ исполнителей',
|
||||
|
||||
@@ -1298,6 +1298,47 @@ export const zhTranslation = {
|
||||
csvImportDownloadSuccess: '报告下载成功',
|
||||
csvImportDownloadError: '报告下载失败',
|
||||
},
|
||||
smartPlaylists: {
|
||||
sectionBasic: '1. 基础',
|
||||
sectionGenres: '2. 流派',
|
||||
sectionYearsAndFilters: '3. 年份与筛选',
|
||||
genreMode: '流派模式',
|
||||
genreModeInclude: '包含流派',
|
||||
genreModeExclude: '排除流派',
|
||||
genreSearchPlaceholder: '筛选流派...',
|
||||
availableGenres: '可用',
|
||||
selectedGenres: '已选',
|
||||
yearMode: '年份模式',
|
||||
yearModeInclude: '包含范围',
|
||||
yearModeExclude: '排除范围',
|
||||
name: '名称(不含前缀)',
|
||||
limit: '数量上限',
|
||||
limitHint: '要包含在播放列表中的曲目数量(1-{{max}},通常为 50)。',
|
||||
artistContains: '艺术家包含…',
|
||||
albumContains: '专辑包含…',
|
||||
titleContains: '标题包含…',
|
||||
minRating: '最低评分',
|
||||
minRatingAria: '智能播放列表的最低评分',
|
||||
minRatingHint: '0 表示关闭最低阈值;1-5 仅保留评分高于所选值的曲目。',
|
||||
fromYear: '起始年份',
|
||||
toYear: '结束年份',
|
||||
excludeUnrated: '排除未评分曲目',
|
||||
compilationOnly: '仅合集',
|
||||
create: '新建智能播放列表',
|
||||
save: '保存智能播放列表',
|
||||
created: '已创建 {{name}}',
|
||||
updated: '已更新 {{name}}',
|
||||
createFailed: '无法创建智能播放列表。',
|
||||
updateFailed: '无法更新智能播放列表。',
|
||||
navidromeOnly: '仅 Navidrome 服务器支持创建智能播放列表。',
|
||||
loadFailed: '无法从 Navidrome 加载智能播放列表。',
|
||||
sortRandom: '排序:随机',
|
||||
sortTitleAsc: '排序:标题 A-Z',
|
||||
sortTitleDesc: '排序:标题 Z-A',
|
||||
sortYearDesc: '排序:年份降序',
|
||||
sortYearAsc: '排序:年份升序',
|
||||
sortPlayCountDesc: '排序:播放次数降序',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: '最常播放',
|
||||
topArtists: '热门艺术家',
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download, FileUp, RotateCcw } from 'lucide-react';
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download, FileUp, RotateCcw, Sparkles } from 'lucide-react';
|
||||
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
|
||||
import {
|
||||
getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt,
|
||||
search, setRating, star, unstar,
|
||||
getRandomSongs, buildDownloadUrl, SubsonicPlaylist, SubsonicSong,
|
||||
getRandomSongs, buildDownloadUrl, filterSongsToActiveLibrary, SubsonicPlaylist, SubsonicSong,
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
@@ -56,6 +56,18 @@ function totalDurationLabel(songs: SubsonicSong[]): string {
|
||||
return formatHumanHoursMinutes(total);
|
||||
}
|
||||
|
||||
const SMART_PREFIX = 'psy-smart-';
|
||||
|
||||
function isSmartPlaylistName(name: string): boolean {
|
||||
return (name ?? '').toLowerCase().startsWith(SMART_PREFIX);
|
||||
}
|
||||
|
||||
function displayPlaylistName(name: string): string {
|
||||
const n = name ?? '';
|
||||
if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length);
|
||||
return n;
|
||||
}
|
||||
|
||||
function codecLabel(song: SubsonicSong, showBitrate: boolean): string {
|
||||
const parts: string[] = [];
|
||||
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
||||
@@ -226,6 +238,7 @@ export default function PlaylistDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride, userRatingOverrides } = usePlayerStore(
|
||||
useShallow(s => ({
|
||||
playTrack: s.playTrack,
|
||||
@@ -403,6 +416,14 @@ export default function PlaylistDetail() {
|
||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const state = (location.state as { openEditMeta?: boolean } | null) ?? null;
|
||||
if (state?.openEditMeta) {
|
||||
setEditingMeta(true);
|
||||
navigate(location.pathname, { replace: true, state: null });
|
||||
}
|
||||
}, [location.state, location.pathname, navigate]);
|
||||
|
||||
// ── Load ─────────────────────────────────────────────────────
|
||||
const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined));
|
||||
|
||||
@@ -410,13 +431,14 @@ export default function PlaylistDetail() {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
getPlaylist(id)
|
||||
.then(({ playlist, songs }) => {
|
||||
.then(async ({ playlist, songs }) => {
|
||||
const filteredSongs = await filterSongsToActiveLibrary(songs);
|
||||
setPlaylist(playlist);
|
||||
setSongs(songs);
|
||||
setSongs(filteredSongs);
|
||||
if (playlist.coverArt) setCustomCoverId(playlist.coverArt);
|
||||
const init: Record<string, number> = {};
|
||||
const starred = new Set<string>();
|
||||
songs.forEach(s => {
|
||||
filteredSongs.forEach(s => {
|
||||
if (s.userRating) init[s.id] = s.userRating;
|
||||
if (s.starred) starred.add(s.id);
|
||||
});
|
||||
@@ -1148,7 +1170,10 @@ export default function PlaylistDetail() {
|
||||
<div className="album-detail-meta">
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<h1 className="album-detail-title" style={{ marginBottom: 0, marginTop: 6 }}>{playlist.name}</h1>
|
||||
<h1 className="album-detail-title" style={{ marginBottom: 0, marginTop: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{isSmartPlaylistName(playlist.name) && <Sparkles size={16} style={{ color: 'var(--text-muted)' }} />}
|
||||
<span>{displayPlaylistName(playlist.name)}</span>
|
||||
</h1>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setEditingMeta(true)}
|
||||
|
||||
+718
-12
@@ -1,18 +1,170 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ListMusic, Play, Plus, Trash2, X, CheckSquare2, Check } from 'lucide-react';
|
||||
import { getPlaylists, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist } from '../api/subsonic';
|
||||
import { ListMusic, Play, Plus, Trash2, CheckSquare2, Check, Clock3, Sparkles, Pencil } from 'lucide-react';
|
||||
import { deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist, getGenres, SubsonicGenre, filterSongsToActiveLibrary } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import StarRating from '../components/StarRating';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { ndCreateSmartPlaylist, ndGetSmartPlaylist, ndListSmartPlaylists, ndUpdateSmartPlaylist } from '../api/navidromeSmart';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
return formatHumanHoursMinutes(seconds);
|
||||
}
|
||||
|
||||
const SMART_PREFIX = 'psy-smart-';
|
||||
const LIMIT_MAX = 500;
|
||||
const YEAR_MIN = 1950;
|
||||
const YEAR_MAX = new Date().getFullYear() + 1;
|
||||
|
||||
type GenreMode = 'include' | 'exclude';
|
||||
type YearMode = 'include' | 'exclude';
|
||||
|
||||
type SmartFilters = {
|
||||
name: string;
|
||||
limit: string;
|
||||
sort: string;
|
||||
artistContains: string;
|
||||
albumContains: string;
|
||||
titleContains: string;
|
||||
minRating: number;
|
||||
excludeUnrated: boolean;
|
||||
compilationOnly: boolean;
|
||||
selectedGenres: string[];
|
||||
genreMode: GenreMode;
|
||||
yearFrom: number;
|
||||
yearTo: number;
|
||||
yearMode: YearMode;
|
||||
};
|
||||
|
||||
type PendingSmartPlaylist = {
|
||||
name: string;
|
||||
id?: string;
|
||||
firstSeenCoverArt?: string;
|
||||
attempts: number;
|
||||
};
|
||||
|
||||
type NdSmartRuleNode = Record<string, unknown>;
|
||||
|
||||
const defaultSmartFilters: SmartFilters = {
|
||||
name: '',
|
||||
limit: '50',
|
||||
sort: '+random',
|
||||
artistContains: '',
|
||||
albumContains: '',
|
||||
titleContains: '',
|
||||
minRating: 0,
|
||||
excludeUnrated: false,
|
||||
compilationOnly: false,
|
||||
selectedGenres: [],
|
||||
genreMode: 'include',
|
||||
yearFrom: YEAR_MIN,
|
||||
yearTo: YEAR_MAX,
|
||||
yearMode: 'include',
|
||||
};
|
||||
|
||||
function clampYear(v: number): number {
|
||||
return Math.max(YEAR_MIN, Math.min(YEAR_MAX, v));
|
||||
}
|
||||
|
||||
function isSmartPlaylistName(name: string): boolean {
|
||||
return (name ?? '').toLowerCase().startsWith(SMART_PREFIX);
|
||||
}
|
||||
|
||||
function displayPlaylistName(name: string): string {
|
||||
const n = name ?? '';
|
||||
if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length);
|
||||
return n;
|
||||
}
|
||||
|
||||
function asRecord(v: unknown): Record<string, unknown> | null {
|
||||
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function parseSmartRulesToFilters(
|
||||
rules: Record<string, unknown> | undefined,
|
||||
playlistName: string,
|
||||
): SmartFilters {
|
||||
const next: SmartFilters = {
|
||||
...defaultSmartFilters,
|
||||
name: displayPlaylistName(playlistName),
|
||||
};
|
||||
if (!rules) return next;
|
||||
|
||||
if (typeof rules.limit === 'number' && Number.isFinite(rules.limit)) {
|
||||
next.limit = String(Math.max(1, Math.min(LIMIT_MAX, Number(rules.limit))));
|
||||
}
|
||||
if (typeof rules.sort === 'string' && rules.sort.trim()) next.sort = rules.sort;
|
||||
|
||||
const includeGenres: string[] = [];
|
||||
const excludeGenres: string[] = [];
|
||||
const all = Array.isArray(rules.all) ? rules.all : [];
|
||||
for (const node of all) {
|
||||
const obj = asRecord(node);
|
||||
if (!obj) continue;
|
||||
|
||||
const contains = asRecord(obj.contains);
|
||||
if (contains) {
|
||||
if (typeof contains.artist === 'string') next.artistContains = contains.artist;
|
||||
if (typeof contains.album === 'string') next.albumContains = contains.album;
|
||||
if (typeof contains.title === 'string') next.titleContains = contains.title;
|
||||
}
|
||||
|
||||
const gt = asRecord(obj.gt);
|
||||
if (gt && typeof gt.rating === 'number') {
|
||||
if (gt.rating > 0) next.minRating = Math.max(0, Math.min(5, Math.floor(gt.rating)));
|
||||
else if (gt.rating === 0) next.excludeUnrated = true;
|
||||
}
|
||||
|
||||
const is = asRecord(obj.is);
|
||||
if (is?.compilation === true) next.compilationOnly = true;
|
||||
|
||||
const notContains = asRecord(obj.notContains);
|
||||
if (notContains && typeof notContains.genre === 'string') excludeGenres.push(notContains.genre);
|
||||
|
||||
const inTheRange = asRecord(obj.inTheRange);
|
||||
if (inTheRange && Array.isArray(inTheRange.year) && inTheRange.year.length === 2) {
|
||||
const from = Number(inTheRange.year[0]);
|
||||
const to = Number(inTheRange.year[1]);
|
||||
if (Number.isFinite(from) && Number.isFinite(to)) {
|
||||
next.yearMode = 'include';
|
||||
next.yearFrom = clampYear(Math.min(from, to));
|
||||
next.yearTo = clampYear(Math.max(from, to));
|
||||
}
|
||||
}
|
||||
|
||||
const any = Array.isArray(obj.any) ? (obj.any as NdSmartRuleNode[]) : [];
|
||||
if (any.length > 0) {
|
||||
const parsedGenreIncludes = any
|
||||
.map((item) => asRecord(asRecord(item)?.contains)?.genre)
|
||||
.filter((v): v is string => typeof v === 'string');
|
||||
if (parsedGenreIncludes.length > 0) includeGenres.push(...parsedGenreIncludes);
|
||||
|
||||
const ltYear = any.map((item) => asRecord(asRecord(item)?.lt)?.year).find((v) => typeof v === 'number');
|
||||
const gtYear = any.map((item) => asRecord(asRecord(item)?.gt)?.year).find((v) => typeof v === 'number');
|
||||
if (typeof ltYear === 'number' && typeof gtYear === 'number') {
|
||||
next.yearMode = 'exclude';
|
||||
next.yearFrom = clampYear(Math.min(ltYear, gtYear));
|
||||
next.yearTo = clampYear(Math.max(ltYear, gtYear));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (includeGenres.length > 0) {
|
||||
next.genreMode = 'include';
|
||||
next.selectedGenres = [...new Set(includeGenres)];
|
||||
} else if (excludeGenres.length > 0) {
|
||||
next.genreMode = 'exclude';
|
||||
next.selectedGenres = [...new Set(excludeGenres)];
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export default function Playlists() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -23,10 +175,24 @@ export default function Playlists() {
|
||||
const playlists = usePlaylistStore((s) => s.playlists);
|
||||
const fetchPlaylists = usePlaylistStore((s) => s.fetchPlaylists);
|
||||
const playlistsLoading = usePlaylistStore((s) => s.playlistsLoading);
|
||||
const activeUsername = useAuthStore(s => s.getActiveServer()?.username ?? '');
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const subsonicIdentityByServer = useAuthStore(s => s.subsonicServerIdentityByServer);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [creatingSmart, setCreatingSmart] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [smartFilters, setSmartFilters] = useState<SmartFilters>(defaultSmartFilters);
|
||||
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
||||
const [genreQuery, setGenreQuery] = useState('');
|
||||
const [creatingSmartBusy, setCreatingSmartBusy] = useState(false);
|
||||
const [editingSmartId, setEditingSmartId] = useState<string | null>(null);
|
||||
const [pendingSmart, setPendingSmart] = useState<PendingSmartPlaylist[]>([]);
|
||||
const [smartCoverIdsByPlaylist, setSmartCoverIdsByPlaylist] = useState<Record<string, string[]>>({});
|
||||
const [filteredSongCountByPlaylist, setFilteredSongCountByPlaylist] = useState<Record<string, number>>({});
|
||||
const [filteredDurationByPlaylist, setFilteredDurationByPlaylist] = useState<Record<string, number>>({});
|
||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -34,6 +200,10 @@ export default function Playlists() {
|
||||
// ── Multi-selection ──────────────────────────────────────────────────────
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const isNavidromeServer = Boolean(
|
||||
activeServerId &&
|
||||
(subsonicIdentityByServer[activeServerId]?.type ?? '').toLowerCase() === 'navidrome',
|
||||
);
|
||||
|
||||
const toggleSelectionMode = () => {
|
||||
setSelectionMode(v => !v);
|
||||
@@ -54,17 +224,109 @@ export default function Playlists() {
|
||||
};
|
||||
|
||||
const selectedPlaylists = playlists.filter(p => selectedIds.has(p.id));
|
||||
const isPlaylistDeletable = useCallback((pl: SubsonicPlaylist) => {
|
||||
if (!pl.owner) return true;
|
||||
if (!activeUsername) return false;
|
||||
return pl.owner === activeUsername;
|
||||
}, [activeUsername]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlaylists().finally(() => setLoading(false));
|
||||
getGenres().then(setGenres).catch(() => {});
|
||||
}, [fetchPlaylists]);
|
||||
|
||||
// Smart playlists: build 2x2 cover collage from tracks inside the active library scope.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
const smart = playlists.filter(pl => isSmartPlaylistName(pl.name));
|
||||
if (smart.length === 0) {
|
||||
if (!cancelled) setSmartCoverIdsByPlaylist({});
|
||||
return;
|
||||
}
|
||||
const rows = await Promise.all(
|
||||
smart.map(async (pl) => {
|
||||
try {
|
||||
const { songs } = await getPlaylist(pl.id);
|
||||
const filtered = await filterSongsToActiveLibrary(songs);
|
||||
const ids: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const s of filtered) {
|
||||
const cid = s.coverArt;
|
||||
if (!cid || seen.has(cid)) continue;
|
||||
seen.add(cid);
|
||||
ids.push(cid);
|
||||
if (ids.length >= 4) break;
|
||||
}
|
||||
return [pl.id, ids] as const;
|
||||
} catch {
|
||||
return [pl.id, [] as string[]] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (cancelled) return;
|
||||
const next: Record<string, string[]> = {};
|
||||
for (const [id, ids] of rows) next[id] = ids;
|
||||
setSmartCoverIdsByPlaylist(next);
|
||||
};
|
||||
run();
|
||||
return () => { cancelled = true; };
|
||||
}, [playlists, musicLibraryFilterVersion]);
|
||||
|
||||
// Playlist list should reflect active library scope for song counts.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
if (playlists.length === 0) {
|
||||
if (!cancelled) {
|
||||
setFilteredSongCountByPlaylist({});
|
||||
setFilteredDurationByPlaylist({});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const ids = playlists.map((pl) => pl.id);
|
||||
const next: Record<string, number> = {};
|
||||
const nextDuration: Record<string, number> = {};
|
||||
for (let i = 0; i < ids.length; i += 4) {
|
||||
const chunk = ids.slice(i, i + 4);
|
||||
const rows = await Promise.all(
|
||||
chunk.map(async (id) => {
|
||||
try {
|
||||
const { songs } = await getPlaylist(id);
|
||||
const filtered = await filterSongsToActiveLibrary(songs);
|
||||
const duration = filtered.reduce((acc, s) => acc + (s.duration ?? 0), 0);
|
||||
return [id, filtered.length, duration] as const;
|
||||
} catch {
|
||||
return [id, -1, -1] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
for (const [id, count, duration] of rows) {
|
||||
if (count >= 0) next[id] = count;
|
||||
if (duration >= 0) nextDuration[id] = duration;
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
setFilteredSongCountByPlaylist(next);
|
||||
setFilteredDurationByPlaylist(nextDuration);
|
||||
}
|
||||
};
|
||||
run();
|
||||
return () => { cancelled = true; };
|
||||
}, [playlists, musicLibraryFilterVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (creating) nameInputRef.current?.focus();
|
||||
}, [creating]);
|
||||
|
||||
const createPlaylist = usePlaylistStore(s => s.createPlaylist);
|
||||
|
||||
const availableGenres = genres
|
||||
.map(g => g.value)
|
||||
.filter(v => !smartFilters.selectedGenres.includes(v))
|
||||
.filter(v => !genreQuery.trim() || v.toLowerCase().includes(genreQuery.trim().toLowerCase()))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = newName.trim() || t('playlists.unnamed');
|
||||
await createPlaylist(name);
|
||||
@@ -74,13 +336,219 @@ export default function Playlists() {
|
||||
setNewName('');
|
||||
};
|
||||
|
||||
const buildSmartRulesPayload = (): Record<string, unknown> => {
|
||||
const all: Record<string, unknown>[] = [];
|
||||
if (smartFilters.artistContains.trim()) all.push({ contains: { artist: smartFilters.artistContains.trim() } });
|
||||
if (smartFilters.albumContains.trim()) all.push({ contains: { album: smartFilters.albumContains.trim() } });
|
||||
if (smartFilters.titleContains.trim()) all.push({ contains: { title: smartFilters.titleContains.trim() } });
|
||||
|
||||
const minRating = Number(smartFilters.minRating);
|
||||
if (Number.isFinite(minRating) && minRating > 0) all.push({ gt: { rating: minRating } });
|
||||
else if (smartFilters.excludeUnrated) all.push({ gt: { rating: 0 } });
|
||||
if (smartFilters.compilationOnly) all.push({ is: { compilation: true } });
|
||||
|
||||
if (smartFilters.selectedGenres.length > 0) {
|
||||
if (smartFilters.genreMode === 'include') {
|
||||
all.push({ any: smartFilters.selectedGenres.map(v => ({ contains: { genre: v } })) });
|
||||
} else {
|
||||
for (const g of smartFilters.selectedGenres) all.push({ notContains: { genre: g } });
|
||||
}
|
||||
}
|
||||
|
||||
if (smartFilters.yearMode === 'include') {
|
||||
all.push({ inTheRange: { year: [smartFilters.yearFrom, smartFilters.yearTo] } });
|
||||
} else {
|
||||
all.push({ any: [{ lt: { year: smartFilters.yearFrom } }, { gt: { year: smartFilters.yearTo } }] });
|
||||
}
|
||||
|
||||
const rules: Record<string, unknown> = { all };
|
||||
rules.limit = Math.max(1, Math.min(LIMIT_MAX, Number(smartFilters.limit) || 50));
|
||||
rules.sort = smartFilters.sort;
|
||||
return rules;
|
||||
};
|
||||
|
||||
const handleOpenSmartEditor = async (pl: SubsonicPlaylist) => {
|
||||
if (!isNavidromeServer || !isSmartPlaylistName(pl.name)) return;
|
||||
setCreatingSmartBusy(true);
|
||||
try {
|
||||
let target: { id: string; name: string; rules?: Record<string, unknown> } | null = null;
|
||||
try {
|
||||
// Prefer direct endpoint for this playlist: returns freshest rules.
|
||||
const direct = await ndGetSmartPlaylist(pl.id);
|
||||
if (direct.id && (direct.rules || isSmartPlaylistName(direct.name))) target = direct;
|
||||
} catch {
|
||||
// Fallback to list endpoint below.
|
||||
}
|
||||
if (!target) {
|
||||
const smart = await ndListSmartPlaylists();
|
||||
target = smart.find((v) =>
|
||||
v.id === pl.id ||
|
||||
v.name === pl.name ||
|
||||
displayPlaylistName(v.name) === displayPlaylistName(pl.name),
|
||||
) ?? null;
|
||||
}
|
||||
if (target) {
|
||||
setSmartFilters(parseSmartRulesToFilters(target.rules, target.name));
|
||||
setEditingSmartId(target.id);
|
||||
} else {
|
||||
// Fallback: allow editing even if Navidrome smart list endpoint
|
||||
// doesn't return this playlist (shared/migrated/legacy edge cases).
|
||||
setSmartFilters({
|
||||
...defaultSmartFilters,
|
||||
name: displayPlaylistName(pl.name),
|
||||
});
|
||||
setEditingSmartId(pl.id);
|
||||
}
|
||||
setGenreQuery('');
|
||||
setCreating(false);
|
||||
setCreatingSmart(true);
|
||||
} catch {
|
||||
// Degrade gracefully instead of blocking the editor on transient/API errors.
|
||||
setSmartFilters({
|
||||
...defaultSmartFilters,
|
||||
name: displayPlaylistName(pl.name),
|
||||
});
|
||||
setGenreQuery('');
|
||||
setEditingSmartId(pl.id);
|
||||
setCreating(false);
|
||||
setCreatingSmart(true);
|
||||
showToast(t('smartPlaylists.loadFailed'), 3500, 'warning');
|
||||
} finally {
|
||||
setCreatingSmartBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSmart = async () => {
|
||||
if (!isNavidromeServer) {
|
||||
showToast(t('smartPlaylists.navidromeOnly'), 3500, 'error');
|
||||
return;
|
||||
}
|
||||
setCreatingSmartBusy(true);
|
||||
try {
|
||||
let baseName = smartFilters.name.trim() || `mix-${new Date().toISOString().slice(0, 10)}`;
|
||||
if (!editingSmartId) {
|
||||
const existingNames = new Set(playlists.map((p) => (p.name ?? '').toLowerCase()));
|
||||
const requestedBaseName = baseName;
|
||||
let ordinal = 2;
|
||||
while (existingNames.has(`${SMART_PREFIX}${baseName}`.toLowerCase())) {
|
||||
baseName = `${requestedBaseName}-${ordinal}`;
|
||||
ordinal += 1;
|
||||
}
|
||||
}
|
||||
const rules = buildSmartRulesPayload();
|
||||
const fullName = `${SMART_PREFIX}${baseName}`;
|
||||
if (editingSmartId) {
|
||||
await ndUpdateSmartPlaylist(editingSmartId, fullName, rules, true);
|
||||
} else {
|
||||
await ndCreateSmartPlaylist(fullName, rules, true);
|
||||
}
|
||||
await fetchPlaylists();
|
||||
const createdName = fullName;
|
||||
const updatedId = editingSmartId;
|
||||
setPendingSmart(prev => {
|
||||
const existing = prev.find(p => p.id === updatedId || p.name === createdName);
|
||||
if (existing) return prev;
|
||||
const created = usePlaylistStore.getState().playlists.find((p) => p.id === updatedId || p.name === createdName);
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
name: createdName,
|
||||
id: updatedId ?? created?.id,
|
||||
firstSeenCoverArt: created?.coverArt,
|
||||
attempts: 0,
|
||||
},
|
||||
];
|
||||
});
|
||||
setCreatingSmart(false);
|
||||
setEditingSmartId(null);
|
||||
setSmartFilters(defaultSmartFilters);
|
||||
setGenreQuery('');
|
||||
if (updatedId) showToast(t('smartPlaylists.updated', { name: createdName }), 3500, 'success');
|
||||
else showToast(t('smartPlaylists.created', { name: createdName }), 3500, 'success');
|
||||
} catch {
|
||||
showToast(editingSmartId ? t('smartPlaylists.updateFailed') : t('smartPlaylists.createFailed'), 3500, 'error');
|
||||
} finally {
|
||||
setCreatingSmartBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Smart playlist rules are processed asynchronously on server.
|
||||
// Poll list every 10s and keep waiting through Navidrome placeholder cover.
|
||||
useEffect(() => {
|
||||
if (pendingSmart.length === 0) return;
|
||||
const interval = window.setInterval(async () => {
|
||||
await fetchPlaylists();
|
||||
const listNow = usePlaylistStore.getState().playlists;
|
||||
const hydrated = pendingSmart.map(item => {
|
||||
if (item.id) return item;
|
||||
const found = listNow.find(p => p.name === item.name);
|
||||
return found ? { ...item, id: found.id } : item;
|
||||
});
|
||||
// Detail endpoint tends to reflect fresh metadata earlier than list endpoint.
|
||||
const ids = hydrated.map(p => p.id).filter((v): v is string => Boolean(v));
|
||||
const details = await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
try {
|
||||
const { playlist } = await getPlaylist(id);
|
||||
return playlist;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const freshById = new Map(
|
||||
details.filter((p): p is SubsonicPlaylist => p !== null).map(p => [p.id, p]),
|
||||
);
|
||||
if (freshById.size > 0) {
|
||||
usePlaylistStore.setState((s) => ({
|
||||
playlists: s.playlists.map((p) => {
|
||||
const fresh = freshById.get(p.id);
|
||||
return fresh ? { ...p, ...fresh } : p;
|
||||
}),
|
||||
}));
|
||||
}
|
||||
const current = usePlaylistStore.getState().playlists;
|
||||
setPendingSmart(() => {
|
||||
const next: PendingSmartPlaylist[] = [];
|
||||
for (const item of hydrated) {
|
||||
const pl = item.id
|
||||
? current.find(p => p.id === item.id)
|
||||
: current.find(p => p.name === item.name);
|
||||
if (!pl) {
|
||||
next.push({ ...item, attempts: item.attempts + 1 });
|
||||
continue;
|
||||
}
|
||||
const songCount = pl.songCount ?? 0;
|
||||
const currentCover = pl.coverArt;
|
||||
const firstCover = item.firstSeenCoverArt ?? currentCover;
|
||||
const placeholderStillThere = Boolean(firstCover) && currentCover === firstCover;
|
||||
// Wait until we see actual content and cover changed from the first placeholder-ish cover.
|
||||
// Fallback timeout keeps UI from waiting forever on servers that never update cover id.
|
||||
const hardTimeoutReached = item.attempts >= 18; // ~3 minutes (18 * 10s)
|
||||
const ready = songCount > 0 && (!placeholderStillThere || hardTimeoutReached);
|
||||
if (!ready) {
|
||||
next.push({
|
||||
...item,
|
||||
id: pl.id,
|
||||
firstSeenCoverArt: firstCover,
|
||||
attempts: item.attempts + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 10000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [pendingSmart, fetchPlaylists]);
|
||||
|
||||
const handlePlay = async (e: React.MouseEvent, pl: SubsonicPlaylist) => {
|
||||
e.stopPropagation();
|
||||
if (playingId === pl.id) return;
|
||||
setPlayingId(pl.id);
|
||||
try {
|
||||
const data = await getPlaylist(pl.id);
|
||||
const tracks = data.songs.map(songToTrack);
|
||||
const filteredSongs = await filterSongsToActiveLibrary(data.songs);
|
||||
const tracks = filteredSongs.map(songToTrack);
|
||||
if (tracks.length > 0) {
|
||||
touchPlaylist(pl.id);
|
||||
playTrack(tracks[0], tracks);
|
||||
@@ -93,6 +561,10 @@ export default function Playlists() {
|
||||
e.stopPropagation();
|
||||
if (deleteConfirmId !== pl.id) {
|
||||
setDeleteConfirmId(pl.id);
|
||||
const btn = e.currentTarget as HTMLElement;
|
||||
requestAnimationFrame(() => {
|
||||
btn.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -109,9 +581,10 @@ export default function Playlists() {
|
||||
};
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (selectedPlaylists.length === 0) return;
|
||||
const deletable = selectedPlaylists.filter(isPlaylistDeletable);
|
||||
if (deletable.length === 0) return;
|
||||
let deleted = 0;
|
||||
for (const pl of selectedPlaylists) {
|
||||
for (const pl of deletable) {
|
||||
try {
|
||||
await deletePlaylist(pl.id);
|
||||
removeId(pl.id);
|
||||
@@ -121,7 +594,7 @@ export default function Playlists() {
|
||||
}
|
||||
}
|
||||
usePlaylistStore.setState((s) => ({
|
||||
playlists: s.playlists.filter((p) => !selectedIds.has(p.id)),
|
||||
playlists: s.playlists.filter((p) => !(selectedIds.has(p.id) && isPlaylistDeletable(p))),
|
||||
}));
|
||||
clearSelection();
|
||||
if (deleted > 0) {
|
||||
@@ -169,6 +642,49 @@ export default function Playlists() {
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<style>{`
|
||||
.dual-year-range {
|
||||
position: relative;
|
||||
height: 34px;
|
||||
}
|
||||
.dual-year-range__track,
|
||||
.dual-year-range__selected {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
height: 4px;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.dual-year-range__track { background: var(--border); }
|
||||
.dual-year-range__selected { background: var(--accent); }
|
||||
.dual-year-range input[type='range'] {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dual-year-range input[type='range']::-webkit-slider-runnable-track { height: 4px; background: transparent; }
|
||||
.dual-year-range input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-top: -5px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* ── Header row ── */}
|
||||
<div className="playlists-header">
|
||||
@@ -201,10 +717,21 @@ export default function Playlists() {
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||
<button className="btn btn-primary" onClick={() => { setCreatingSmart(false); setCreating(true); }}>
|
||||
<Plus size={15} /> {t('playlists.newPlaylist')}
|
||||
</button>
|
||||
)}
|
||||
{!creating && isNavidromeServer && (
|
||||
<button className="btn btn-surface" onClick={() => {
|
||||
setCreating(false);
|
||||
setEditingSmartId(null);
|
||||
setSmartFilters(defaultSmartFilters);
|
||||
setGenreQuery('');
|
||||
setCreatingSmart(v => !v);
|
||||
}}>
|
||||
<Plus size={15} /> {t('smartPlaylists.create')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
@@ -219,6 +746,109 @@ export default function Playlists() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{creatingSmart && (
|
||||
<div style={{ marginBottom: '1rem', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '0.9rem', background: 'var(--bg-card)' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<section style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.75rem' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: '0.65rem' }}>{t('smartPlaylists.sectionBasic')}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
|
||||
<input className="input" placeholder={t('smartPlaylists.name')} value={smartFilters.name} onChange={e => setSmartFilters(v => ({ ...v, name: e.target.value }))} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<input className="input" type="number" min={1} max={LIMIT_MAX} placeholder={t('smartPlaylists.limit')} value={smartFilters.limit} onChange={e => setSmartFilters(v => ({ ...v, limit: e.target.value }))} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('smartPlaylists.limitHint', { max: LIMIT_MAX })}</span>
|
||||
</div>
|
||||
<select className="input" value={smartFilters.sort} onChange={e => setSmartFilters(v => ({ ...v, sort: e.target.value }))}>
|
||||
<option value="+random">{t('smartPlaylists.sortRandom')}</option>
|
||||
<option value="+title">{t('smartPlaylists.sortTitleAsc')}</option>
|
||||
<option value="-title">{t('smartPlaylists.sortTitleDesc')}</option>
|
||||
<option value="-year">{t('smartPlaylists.sortYearDesc')}</option>
|
||||
<option value="+year">{t('smartPlaylists.sortYearAsc')}</option>
|
||||
<option value="-playcount">{t('smartPlaylists.sortPlayCountDesc')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
<section style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.75rem' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: '0.65rem' }}>{t('smartPlaylists.sectionGenres')}</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('smartPlaylists.genreMode')}</span>
|
||||
<button className={`btn ${smartFilters.genreMode === 'include' ? 'btn-primary' : 'btn-surface'}`} onClick={() => setSmartFilters(v => ({ ...v, genreMode: 'include' }))}>{t('smartPlaylists.genreModeInclude')}</button>
|
||||
<button className={`btn ${smartFilters.genreMode === 'exclude' ? 'btn-primary' : 'btn-surface'}`} onClick={() => setSmartFilters(v => ({ ...v, genreMode: 'exclude' }))}>{t('smartPlaylists.genreModeExclude')}</button>
|
||||
</div>
|
||||
<input className="input" placeholder={t('smartPlaylists.genreSearchPlaceholder')} value={genreQuery} onChange={e => setGenreQuery(e.target.value)} style={{ marginBottom: '0.75rem' }} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.5rem', minHeight: 120 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.5rem' }}>{t('smartPlaylists.availableGenres')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
||||
{availableGenres.map(g => (
|
||||
<button key={g} className="btn btn-surface" style={{ fontSize: 12, padding: '2px 8px' }} onClick={() => setSmartFilters(v => ({ ...v, selectedGenres: [...v.selectedGenres, g] }))}>{g}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.5rem', minHeight: 120 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.5rem' }}>{t('smartPlaylists.selectedGenres')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
||||
{smartFilters.selectedGenres.map(g => (
|
||||
<button key={g} className="btn btn-surface" style={{ fontSize: 12, padding: '2px 8px' }} onClick={() => setSmartFilters(v => ({ ...v, selectedGenres: v.selectedGenres.filter(x => x !== g) }))}>× {g}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.75rem' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: '0.65rem' }}>{t('smartPlaylists.sectionYearsAndFilters')}</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('smartPlaylists.yearMode')}</span>
|
||||
<button className={`btn ${smartFilters.yearMode === 'include' ? 'btn-primary' : 'btn-surface'}`} onClick={() => setSmartFilters(v => ({ ...v, yearMode: 'include' }))}>{t('smartPlaylists.yearModeInclude')}</button>
|
||||
<button className={`btn ${smartFilters.yearMode === 'exclude' ? 'btn-primary' : 'btn-surface'}`} onClick={() => setSmartFilters(v => ({ ...v, yearMode: 'exclude' }))}>{t('smartPlaylists.yearModeExclude')}</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
<span>{t('smartPlaylists.fromYear')}: {smartFilters.yearFrom}</span>
|
||||
<span>{t('smartPlaylists.toYear')}: {smartFilters.yearTo}</span>
|
||||
</div>
|
||||
<div className="dual-year-range">
|
||||
<div className="dual-year-range__track" />
|
||||
<div className="dual-year-range__selected" style={{ left: `${((smartFilters.yearFrom - YEAR_MIN) / (YEAR_MAX - YEAR_MIN)) * 100}%`, right: `${100 - ((smartFilters.yearTo - YEAR_MIN) / (YEAR_MAX - YEAR_MIN)) * 100}%` }} />
|
||||
<input type="range" min={YEAR_MIN} max={YEAR_MAX} value={smartFilters.yearFrom} onChange={e => setSmartFilters(v => ({ ...v, yearFrom: Math.min(clampYear(Number(e.target.value)), v.yearTo) }))} />
|
||||
<input type="range" min={YEAR_MIN} max={YEAR_MAX} value={smartFilters.yearTo} onChange={e => setSmartFilters(v => ({ ...v, yearTo: Math.max(clampYear(Number(e.target.value)), v.yearFrom) }))} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem', marginTop: '0.75rem' }}>
|
||||
<input className="input" placeholder={t('smartPlaylists.artistContains')} value={smartFilters.artistContains} onChange={e => setSmartFilters(v => ({ ...v, artistContains: e.target.value }))} />
|
||||
<input className="input" placeholder={t('smartPlaylists.albumContains')} value={smartFilters.albumContains} onChange={e => setSmartFilters(v => ({ ...v, albumContains: e.target.value }))} />
|
||||
<input className="input" placeholder={t('smartPlaylists.titleContains')} value={smartFilters.titleContains} onChange={e => setSmartFilters(v => ({ ...v, titleContains: e.target.value }))} />
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('smartPlaylists.minRating')}: {smartFilters.minRating}★</div>
|
||||
<StarRating value={smartFilters.minRating} onChange={rating => setSmartFilters(v => ({ ...v, minRating: rating }))} ariaLabel={t('smartPlaylists.minRatingAria')} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('smartPlaylists.minRatingHint')}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem', display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<input type="checkbox" checked={smartFilters.excludeUnrated} onChange={e => setSmartFilters(v => ({ ...v, excludeUnrated: e.target.checked }))} />
|
||||
{t('smartPlaylists.excludeUnrated')}
|
||||
</label>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<input type="checkbox" checked={smartFilters.compilationOnly} onChange={e => setSmartFilters(v => ({ ...v, compilationOnly: e.target.checked }))} />
|
||||
{t('smartPlaylists.compilationOnly')}
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={() => {
|
||||
setCreatingSmart(false);
|
||||
setEditingSmartId(null);
|
||||
setSmartFilters(defaultSmartFilters);
|
||||
setGenreQuery('');
|
||||
}}
|
||||
>
|
||||
{t('playlists.cancel')}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleCreateSmart} disabled={creatingSmartBusy}>
|
||||
<Plus size={15} /> {editingSmartId ? t('smartPlaylists.save') : t('smartPlaylists.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Grid ── */}
|
||||
{playlists.length === 0 ? (
|
||||
@@ -246,11 +876,41 @@ export default function Playlists() {
|
||||
}}
|
||||
onMouseLeave={() => { if (deleteConfirmId === pl.id) setDeleteConfirmId(null); }}
|
||||
style={selectionMode && selectedIds.has(pl.id) ? {
|
||||
position: 'relative',
|
||||
outline: '2px solid var(--accent)',
|
||||
outlineOffset: '2px',
|
||||
borderRadius: 'var(--radius-md)'
|
||||
} : {}}
|
||||
} : { position: 'relative' }}
|
||||
>
|
||||
{!selectionMode && (
|
||||
<div className="playlist-card-actions">
|
||||
{isPlaylistDeletable(pl) && (
|
||||
<button
|
||||
className="playlist-card-action playlist-card-action--edit"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isSmartPlaylistName(pl.name)) {
|
||||
void handleOpenSmartEditor(pl);
|
||||
return;
|
||||
}
|
||||
navigate(`/playlists/${pl.id}`, { state: { openEditMeta: true } });
|
||||
}}
|
||||
data-tooltip={t('playlists.editMeta')}
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
)}
|
||||
{isPlaylistDeletable(pl) && (
|
||||
<button
|
||||
className={`playlist-card-action playlist-card-action--delete${deleteConfirmId === pl.id ? ' playlist-card-action--delete-confirm' : ''}`}
|
||||
onClick={(e) => handleDelete(e, pl)}
|
||||
data-tooltip={deleteConfirmId === pl.id ? t('playlists.confirmDelete') : t('common.delete')}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selectionMode && (
|
||||
<div className={`album-card-select-check${selectedIds.has(pl.id) ? ' album-card-select-check--on' : ''}`}>
|
||||
{selectedIds.has(pl.id) && <Check size={14} strokeWidth={3} />}
|
||||
@@ -258,7 +918,24 @@ export default function Playlists() {
|
||||
)}
|
||||
{/* Cover area — server collage or fallback icon */}
|
||||
<div className="album-card-cover">
|
||||
{pl.coverArt ? (
|
||||
{isSmartPlaylistName(pl.name) && (smartCoverIdsByPlaylist[pl.id]?.length ?? 0) > 0 ? (
|
||||
<div className="playlist-cover-grid">
|
||||
{Array.from({ length: 4 }, (_, i) => {
|
||||
const id = smartCoverIdsByPlaylist[pl.id][i % smartCoverIdsByPlaylist[pl.id].length];
|
||||
return id ? (
|
||||
<CachedImage
|
||||
key={i}
|
||||
className="playlist-cover-cell"
|
||||
src={buildCoverArtUrl(id, 200)}
|
||||
cacheKey={coverArtCacheKey(id, 200)}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : pl.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(pl.coverArt, 256)}
|
||||
cacheKey={coverArtCacheKey(pl.coverArt, 256)}
|
||||
@@ -270,6 +947,29 @@ export default function Playlists() {
|
||||
<ListMusic size={48} strokeWidth={1.2} />
|
||||
</div>
|
||||
)}
|
||||
{pendingSmart.some(p => p.id === pl.id || p.name === pl.name) && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 999,
|
||||
background: 'rgba(0,0,0,0.45)',
|
||||
border: '1px solid rgba(255,255,255,0.25)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
zIndex: 8,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
data-tooltip={t('common.loading')}
|
||||
>
|
||||
<Clock3 size={13} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Play overlay — same pattern as AlbumCard */}
|
||||
<div className="album-card-play-overlay">
|
||||
@@ -288,16 +988,22 @@ export default function Playlists() {
|
||||
</div>
|
||||
|
||||
<div className="album-card-info">
|
||||
<div className="album-card-title">{pl.name}</div>
|
||||
<div className="album-card-title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{isSmartPlaylistName(pl.name) && <Sparkles size={14} style={{ color: 'var(--text-muted)', flex: '0 0 auto' }} />}
|
||||
<span>{displayPlaylistName(pl.name)}</span>
|
||||
</div>
|
||||
<div className="album-card-artist">
|
||||
{t('playlists.songs', { n: pl.songCount })}
|
||||
{pl.duration > 0 && <> · {formatDuration(pl.duration)}</>}
|
||||
{t('playlists.songs', { n: filteredSongCountByPlaylist[pl.id] ?? pl.songCount })}
|
||||
{(filteredDurationByPlaylist[pl.id] ?? pl.duration) > 0 && (
|
||||
<> · {formatDuration(filteredDurationByPlaylist[pl.id] ?? pl.duration)}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+32
-16
@@ -1051,6 +1051,14 @@
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* In playlist cards, the 2x2 grid must fill the full cover area. */
|
||||
.album-card-cover > .playlist-cover-grid {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.playlist-cover-cell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -7300,43 +7308,51 @@ html.no-compositing .fs-seekbar-played {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Delete button — top-right corner of card cover, hidden until hover */
|
||||
.playlist-card-delete {
|
||||
/* Playlist card actions (edit/delete) — visible on hover */
|
||||
.playlist-card-actions {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
transition: opacity var(--transition-fast), transform var(--transition-fast);
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
.album-card:hover .playlist-card-actions {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.playlist-card-action {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.album-card:hover .playlist-card-delete {
|
||||
opacity: 1;
|
||||
.playlist-card-action--edit:hover {
|
||||
background: rgba(90, 120, 255, 0.85);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.playlist-card-delete:hover {
|
||||
.playlist-card-action--delete:hover {
|
||||
background: rgba(220, 60, 60, 0.85);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.playlist-card-delete--confirm {
|
||||
.playlist-card-action--delete-confirm {
|
||||
background: #dc3c3c !important;
|
||||
color: #fff !important;
|
||||
opacity: 1 !important;
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
top: 3px !important;
|
||||
right: 3px !important;
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.45);
|
||||
animation: delete-confirm-pulse 0.7s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user