mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(playlists): improve smart playlist editing and localization
Open smart playlist editing from playlist cards, load rules via Navidrome single-playlist API with safer fallbacks, and keep edit visibility aligned with ownership rules. Also add/clean smart playlist locale keys across all supported languages and preserve smarter autogenerated naming behavior.
This commit is contained in:
@@ -636,6 +636,28 @@ async fn nd_update_playlist(
|
|||||||
Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null))
|
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.
|
/// DELETE `/api/playlist/{id}` — delete playlist.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn nd_delete_playlist(
|
async fn nd_delete_playlist(
|
||||||
@@ -3820,6 +3842,7 @@ pub fn run() {
|
|||||||
nd_list_playlists,
|
nd_list_playlists,
|
||||||
nd_create_playlist,
|
nd_create_playlist,
|
||||||
nd_update_playlist,
|
nd_update_playlist,
|
||||||
|
nd_get_playlist,
|
||||||
nd_delete_playlist,
|
nd_delete_playlist,
|
||||||
search_radio_browser,
|
search_radio_browser,
|
||||||
get_top_radio_stations,
|
get_top_radio_stations,
|
||||||
|
|||||||
+37
-22
@@ -29,6 +29,19 @@ export interface NdSmartPlaylist {
|
|||||||
updatedAt?: string;
|
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: {
|
let authCache: {
|
||||||
key: string;
|
key: string;
|
||||||
token: string;
|
token: string;
|
||||||
@@ -75,18 +88,7 @@ export async function ndListSmartPlaylists(): Promise<NdSmartPlaylist[]> {
|
|||||||
: (raw && typeof raw === 'object' && Array.isArray((raw as { items?: unknown[] }).items))
|
: (raw && typeof raw === 'object' && Array.isArray((raw as { items?: unknown[] }).items))
|
||||||
? (raw as { items: unknown[] }).items
|
? (raw as { items: unknown[] }).items
|
||||||
: [];
|
: [];
|
||||||
return list.map((v) => {
|
return list.map((v) => parseNdSmartPlaylist(v));
|
||||||
const o = (v as Record<string, unknown>) ?? {};
|
|
||||||
return {
|
|
||||||
id: String(o.id ?? ''),
|
|
||||||
name: String(o.name ?? ''),
|
|
||||||
songCount: Number(o.songCount ?? 0),
|
|
||||||
duration: typeof o.duration === 'number' ? o.duration : undefined,
|
|
||||||
rules: typeof o.rules === 'object' && o.rules ? (o.rules as Record<string, unknown>) : undefined,
|
|
||||||
sync: typeof o.sync === 'boolean' ? o.sync : undefined,
|
|
||||||
updatedAt: typeof o.updatedAt === 'string' ? o.updatedAt : undefined,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ndCreateSmartPlaylist(name: string, rules: Record<string, unknown>, sync = true): Promise<NdSmartPlaylist> {
|
export async function ndCreateSmartPlaylist(name: string, rules: Record<string, unknown>, sync = true): Promise<NdSmartPlaylist> {
|
||||||
@@ -96,16 +98,29 @@ export async function ndCreateSmartPlaylist(name: string, rules: Record<string,
|
|||||||
token,
|
token,
|
||||||
body: { name, rules, sync },
|
body: { name, rules, sync },
|
||||||
});
|
});
|
||||||
const o = (raw as Record<string, unknown>) ?? {};
|
return parseNdSmartPlaylist(raw, { name, rules, sync });
|
||||||
return {
|
}
|
||||||
id: String(o.id ?? ''),
|
|
||||||
name: String(o.name ?? name),
|
export async function ndUpdateSmartPlaylist(
|
||||||
songCount: Number(o.songCount ?? 0),
|
id: string,
|
||||||
duration: typeof o.duration === 'number' ? o.duration : undefined,
|
name: string,
|
||||||
rules: typeof o.rules === 'object' && o.rules ? (o.rules as Record<string, unknown>) : undefined,
|
rules: Record<string, unknown>,
|
||||||
sync: typeof o.sync === 'boolean' ? o.sync : undefined,
|
sync = true,
|
||||||
updatedAt: typeof o.updatedAt === 'string' ? o.updatedAt : undefined,
|
): 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> {
|
export async function ndDeletePlaylist(id: string): Promise<void> {
|
||||||
|
|||||||
@@ -1315,6 +1315,47 @@ export const deTranslation = {
|
|||||||
csvImportDownloadSuccess: 'Bericht erfolgreich heruntergeladen',
|
csvImportDownloadSuccess: 'Bericht erfolgreich heruntergeladen',
|
||||||
csvImportDownloadError: 'Bericht konnte nicht heruntergeladen werden',
|
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: {
|
mostPlayed: {
|
||||||
title: 'Meistgehört',
|
title: 'Meistgehört',
|
||||||
topArtists: 'Top-Künstler',
|
topArtists: 'Top-Künstler',
|
||||||
|
|||||||
+3
-6
@@ -1319,8 +1319,6 @@ export const enTranslation = {
|
|||||||
csvImportDownloadError: 'Failed to download report',
|
csvImportDownloadError: 'Failed to download report',
|
||||||
},
|
},
|
||||||
smartPlaylists: {
|
smartPlaylists: {
|
||||||
title: 'Smart Playlists',
|
|
||||||
empty: 'No smart playlists yet.',
|
|
||||||
sectionBasic: '1. Basic',
|
sectionBasic: '1. Basic',
|
||||||
sectionGenres: '2. Genres',
|
sectionGenres: '2. Genres',
|
||||||
sectionYearsAndFilters: '3. Years and filters',
|
sectionYearsAndFilters: '3. Years and filters',
|
||||||
@@ -1334,7 +1332,6 @@ export const enTranslation = {
|
|||||||
yearModeInclude: 'Include range',
|
yearModeInclude: 'Include range',
|
||||||
yearModeExclude: 'Exclude range',
|
yearModeExclude: 'Exclude range',
|
||||||
name: 'Name (without prefix)',
|
name: 'Name (without prefix)',
|
||||||
anyGenre: 'Any genre',
|
|
||||||
limit: 'Limit',
|
limit: 'Limit',
|
||||||
limitHint: 'How many tracks to include in playlist (1-{{max}}, usually 50).',
|
limitHint: 'How many tracks to include in playlist (1-{{max}}, usually 50).',
|
||||||
artistContains: 'Artist contains…',
|
artistContains: 'Artist contains…',
|
||||||
@@ -1348,13 +1345,13 @@ export const enTranslation = {
|
|||||||
excludeUnrated: 'Exclude unrated tracks',
|
excludeUnrated: 'Exclude unrated tracks',
|
||||||
compilationOnly: 'Only compilations',
|
compilationOnly: 'Only compilations',
|
||||||
create: 'New Smart Playlist',
|
create: 'New Smart Playlist',
|
||||||
|
save: 'Save Smart Playlist',
|
||||||
created: 'Created {{name}}',
|
created: 'Created {{name}}',
|
||||||
|
updated: 'Updated {{name}}',
|
||||||
createFailed: 'Could not create smart playlist.',
|
createFailed: 'Could not create smart playlist.',
|
||||||
deleted: 'Deleted {{name}}',
|
updateFailed: 'Could not update smart playlist.',
|
||||||
deleteFailed: 'Could not delete smart playlist.',
|
|
||||||
navidromeOnly: 'Smart playlists can only be created on Navidrome servers.',
|
navidromeOnly: 'Smart playlists can only be created on Navidrome servers.',
|
||||||
loadFailed: 'Could not load smart playlists from Navidrome.',
|
loadFailed: 'Could not load smart playlists from Navidrome.',
|
||||||
liveBadge: 'live',
|
|
||||||
sortRandom: 'Sort: random',
|
sortRandom: 'Sort: random',
|
||||||
sortTitleAsc: 'Sort: title A-Z',
|
sortTitleAsc: 'Sort: title A-Z',
|
||||||
sortTitleDesc: 'Sort: title Z-A',
|
sortTitleDesc: 'Sort: title Z-A',
|
||||||
|
|||||||
@@ -1308,6 +1308,47 @@ export const esTranslation = {
|
|||||||
csvImportDownloadSuccess: 'Reporte descargado exitosamente',
|
csvImportDownloadSuccess: 'Reporte descargado exitosamente',
|
||||||
csvImportDownloadError: 'Error al descargar el reporte',
|
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: {
|
mostPlayed: {
|
||||||
title: 'Más Reproducidos',
|
title: 'Más Reproducidos',
|
||||||
topArtists: 'Artistas Principales',
|
topArtists: 'Artistas Principales',
|
||||||
|
|||||||
@@ -1303,6 +1303,47 @@ export const frTranslation = {
|
|||||||
csvImportDownloadSuccess: 'Rapport téléchargé avec succès',
|
csvImportDownloadSuccess: 'Rapport téléchargé avec succès',
|
||||||
csvImportDownloadError: 'Échec du téléchargement du rapport',
|
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: {
|
mostPlayed: {
|
||||||
title: 'Les plus joués',
|
title: 'Les plus joués',
|
||||||
topArtists: 'Artistes populaires',
|
topArtists: 'Artistes populaires',
|
||||||
|
|||||||
@@ -1315,6 +1315,47 @@ export const nbTranslation = {
|
|||||||
filterCompilations: 'Skjul kompilasjonsartister (Various Artists, Soundtracks, etc.)',
|
filterCompilations: 'Skjul kompilasjonsartister (Various Artists, Soundtracks, etc.)',
|
||||||
filterCompilationsShort: 'Skjul kompilasjoner',
|
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: {
|
radio: {
|
||||||
title: 'Internettradio',
|
title: 'Internettradio',
|
||||||
empty: 'Ingen radiostasjoner konfigurert.',
|
empty: 'Ingen radiostasjoner konfigurert.',
|
||||||
|
|||||||
@@ -1302,6 +1302,47 @@ export const nlTranslation = {
|
|||||||
csvImportDownloadSuccess: 'Rapport succesvol gedownload',
|
csvImportDownloadSuccess: 'Rapport succesvol gedownload',
|
||||||
csvImportDownloadError: 'Rapport downloaden mislukt',
|
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: {
|
mostPlayed: {
|
||||||
title: 'Meest gespeeld',
|
title: 'Meest gespeeld',
|
||||||
topArtists: 'Topkunstenaars',
|
topArtists: 'Topkunstenaars',
|
||||||
|
|||||||
+3
-6
@@ -1383,8 +1383,6 @@ export const ruTranslation = {
|
|||||||
csvImportDownloadError: 'Не удалось скачать отчёт',
|
csvImportDownloadError: 'Не удалось скачать отчёт',
|
||||||
},
|
},
|
||||||
smartPlaylists: {
|
smartPlaylists: {
|
||||||
title: 'Смарт-плейлисты',
|
|
||||||
empty: 'Смарт-плейлистов пока нет.',
|
|
||||||
sectionBasic: '1. База',
|
sectionBasic: '1. База',
|
||||||
sectionGenres: '2. Жанры',
|
sectionGenres: '2. Жанры',
|
||||||
sectionYearsAndFilters: '3. Годы и фильтры',
|
sectionYearsAndFilters: '3. Годы и фильтры',
|
||||||
@@ -1398,7 +1396,6 @@ export const ruTranslation = {
|
|||||||
yearModeInclude: 'Включить диапазон',
|
yearModeInclude: 'Включить диапазон',
|
||||||
yearModeExclude: 'Исключить диапазон',
|
yearModeExclude: 'Исключить диапазон',
|
||||||
name: 'Название (без префикса)',
|
name: 'Название (без префикса)',
|
||||||
anyGenre: 'Любой жанр',
|
|
||||||
limit: 'Лимит',
|
limit: 'Лимит',
|
||||||
limitHint: 'Сколько треков включать в плейлист (1-{{max}}, обычно 50).',
|
limitHint: 'Сколько треков включать в плейлист (1-{{max}}, обычно 50).',
|
||||||
artistContains: 'Исполнитель содержит…',
|
artistContains: 'Исполнитель содержит…',
|
||||||
@@ -1412,13 +1409,13 @@ export const ruTranslation = {
|
|||||||
excludeUnrated: 'Исключить треки без рейтинга',
|
excludeUnrated: 'Исключить треки без рейтинга',
|
||||||
compilationOnly: 'Только сборники',
|
compilationOnly: 'Только сборники',
|
||||||
create: 'Создать смарт-плейлист',
|
create: 'Создать смарт-плейлист',
|
||||||
|
save: 'Сохранить смарт-плейлист',
|
||||||
created: 'Создан {{name}}',
|
created: 'Создан {{name}}',
|
||||||
|
updated: 'Обновлён {{name}}',
|
||||||
createFailed: 'Не удалось создать смарт-плейлист.',
|
createFailed: 'Не удалось создать смарт-плейлист.',
|
||||||
deleted: 'Удалён {{name}}',
|
updateFailed: 'Не удалось обновить смарт-плейлист.',
|
||||||
deleteFailed: 'Не удалось удалить смарт-плейлист.',
|
|
||||||
navidromeOnly: 'Смарт-плейлисты можно создавать только на серверах Navidrome.',
|
navidromeOnly: 'Смарт-плейлисты можно создавать только на серверах Navidrome.',
|
||||||
loadFailed: 'Не удалось загрузить смарт-плейлисты из Navidrome.',
|
loadFailed: 'Не удалось загрузить смарт-плейлисты из Navidrome.',
|
||||||
liveBadge: 'live',
|
|
||||||
sortRandom: 'Сортировка: случайно',
|
sortRandom: 'Сортировка: случайно',
|
||||||
sortTitleAsc: 'Сортировка: название А-Я',
|
sortTitleAsc: 'Сортировка: название А-Я',
|
||||||
sortTitleDesc: 'Сортировка: название Я-А',
|
sortTitleDesc: 'Сортировка: название Я-А',
|
||||||
|
|||||||
@@ -1295,6 +1295,47 @@ export const zhTranslation = {
|
|||||||
csvImportDownloadSuccess: '报告下载成功',
|
csvImportDownloadSuccess: '报告下载成功',
|
||||||
csvImportDownloadError: '报告下载失败',
|
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: {
|
mostPlayed: {
|
||||||
title: '最常播放',
|
title: '最常播放',
|
||||||
topArtists: '热门艺术家',
|
topArtists: '热门艺术家',
|
||||||
|
|||||||
+199
-22
@@ -10,7 +10,7 @@ import StarRating from '../components/StarRating';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
|
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
import { ndCreateSmartPlaylist } from '../api/navidromeSmart';
|
import { ndCreateSmartPlaylist, ndGetSmartPlaylist, ndListSmartPlaylists, ndUpdateSmartPlaylist } from '../api/navidromeSmart';
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
return formatHumanHoursMinutes(seconds);
|
return formatHumanHoursMinutes(seconds);
|
||||||
@@ -48,6 +48,8 @@ type PendingSmartPlaylist = {
|
|||||||
attempts: number;
|
attempts: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NdSmartRuleNode = Record<string, unknown>;
|
||||||
|
|
||||||
const defaultSmartFilters: SmartFilters = {
|
const defaultSmartFilters: SmartFilters = {
|
||||||
name: '',
|
name: '',
|
||||||
limit: '50',
|
limit: '50',
|
||||||
@@ -79,6 +81,90 @@ function displayPlaylistName(name: string): string {
|
|||||||
return n;
|
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() {
|
export default function Playlists() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -102,6 +188,7 @@ export default function Playlists() {
|
|||||||
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
||||||
const [genreQuery, setGenreQuery] = useState('');
|
const [genreQuery, setGenreQuery] = useState('');
|
||||||
const [creatingSmartBusy, setCreatingSmartBusy] = useState(false);
|
const [creatingSmartBusy, setCreatingSmartBusy] = useState(false);
|
||||||
|
const [editingSmartId, setEditingSmartId] = useState<string | null>(null);
|
||||||
const [pendingSmart, setPendingSmart] = useState<PendingSmartPlaylist[]>([]);
|
const [pendingSmart, setPendingSmart] = useState<PendingSmartPlaylist[]>([]);
|
||||||
const [smartCoverIdsByPlaylist, setSmartCoverIdsByPlaylist] = useState<Record<string, string[]>>({});
|
const [smartCoverIdsByPlaylist, setSmartCoverIdsByPlaylist] = useState<Record<string, string[]>>({});
|
||||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||||
@@ -236,6 +323,57 @@ export default function Playlists() {
|
|||||||
return rules;
|
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 () => {
|
const handleCreateSmart = async () => {
|
||||||
if (!isNavidromeServer) {
|
if (!isNavidromeServer) {
|
||||||
showToast(t('smartPlaylists.navidromeOnly'), 3500, 'error');
|
showToast(t('smartPlaylists.navidromeOnly'), 3500, 'error');
|
||||||
@@ -243,31 +381,48 @@ export default function Playlists() {
|
|||||||
}
|
}
|
||||||
setCreatingSmartBusy(true);
|
setCreatingSmartBusy(true);
|
||||||
try {
|
try {
|
||||||
const baseName = smartFilters.name.trim() || `mix-${new Date().toISOString().slice(0, 10)}`;
|
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 rules = buildSmartRulesPayload();
|
||||||
await ndCreateSmartPlaylist(`${SMART_PREFIX}${baseName}`, rules, true);
|
const fullName = `${SMART_PREFIX}${baseName}`;
|
||||||
|
if (editingSmartId) {
|
||||||
|
await ndUpdateSmartPlaylist(editingSmartId, fullName, rules, true);
|
||||||
|
} else {
|
||||||
|
await ndCreateSmartPlaylist(fullName, rules, true);
|
||||||
|
}
|
||||||
await fetchPlaylists();
|
await fetchPlaylists();
|
||||||
const createdName = `${SMART_PREFIX}${baseName}`;
|
const createdName = fullName;
|
||||||
|
const updatedId = editingSmartId;
|
||||||
setPendingSmart(prev => {
|
setPendingSmart(prev => {
|
||||||
const existing = prev.find(p => p.name === createdName);
|
const existing = prev.find(p => p.id === updatedId || p.name === createdName);
|
||||||
if (existing) return prev;
|
if (existing) return prev;
|
||||||
const created = usePlaylistStore.getState().playlists.find(p => p.name === createdName);
|
const created = usePlaylistStore.getState().playlists.find((p) => p.id === updatedId || p.name === createdName);
|
||||||
return [
|
return [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
name: createdName,
|
name: createdName,
|
||||||
id: created?.id,
|
id: updatedId ?? created?.id,
|
||||||
firstSeenCoverArt: created?.coverArt,
|
firstSeenCoverArt: created?.coverArt,
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
setCreatingSmart(false);
|
setCreatingSmart(false);
|
||||||
|
setEditingSmartId(null);
|
||||||
setSmartFilters(defaultSmartFilters);
|
setSmartFilters(defaultSmartFilters);
|
||||||
setGenreQuery('');
|
setGenreQuery('');
|
||||||
showToast(t('smartPlaylists.created', { name: createdName }), 3500, 'success');
|
if (updatedId) showToast(t('smartPlaylists.updated', { name: createdName }), 3500, 'success');
|
||||||
|
else showToast(t('smartPlaylists.created', { name: createdName }), 3500, 'success');
|
||||||
} catch {
|
} catch {
|
||||||
showToast(t('smartPlaylists.createFailed'), 3500, 'error');
|
showToast(editingSmartId ? t('smartPlaylists.updateFailed') : t('smartPlaylists.createFailed'), 3500, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setCreatingSmartBusy(false);
|
setCreatingSmartBusy(false);
|
||||||
}
|
}
|
||||||
@@ -522,7 +677,13 @@ export default function Playlists() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!creating && isNavidromeServer && (
|
{!creating && isNavidromeServer && (
|
||||||
<button className="btn btn-surface" onClick={() => { setCreating(false); setCreatingSmart(v => !v); }}>
|
<button className="btn btn-surface" onClick={() => {
|
||||||
|
setCreating(false);
|
||||||
|
setEditingSmartId(null);
|
||||||
|
setSmartFilters(defaultSmartFilters);
|
||||||
|
setGenreQuery('');
|
||||||
|
setCreatingSmart(v => !v);
|
||||||
|
}}>
|
||||||
<Plus size={15} /> {t('smartPlaylists.create')}
|
<Plus size={15} /> {t('smartPlaylists.create')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -625,9 +786,19 @@ export default function Playlists() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
||||||
<button className="btn btn-surface" onClick={() => setCreatingSmart(false)}>{t('playlists.cancel')}</button>
|
<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}>
|
<button className="btn btn-primary" onClick={handleCreateSmart} disabled={creatingSmartBusy}>
|
||||||
<Plus size={15} /> {t('smartPlaylists.create')}
|
<Plus size={15} /> {editingSmartId ? t('smartPlaylists.save') : t('smartPlaylists.create')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -668,16 +839,22 @@ export default function Playlists() {
|
|||||||
>
|
>
|
||||||
{!selectionMode && (
|
{!selectionMode && (
|
||||||
<div className="playlist-card-actions">
|
<div className="playlist-card-actions">
|
||||||
<button
|
{isPlaylistDeletable(pl) && (
|
||||||
className="playlist-card-action playlist-card-action--edit"
|
<button
|
||||||
onClick={(e) => {
|
className="playlist-card-action playlist-card-action--edit"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
navigate(`/playlists/${pl.id}`, { state: { openEditMeta: true } });
|
e.stopPropagation();
|
||||||
}}
|
if (isSmartPlaylistName(pl.name)) {
|
||||||
data-tooltip={t('playlists.editMeta')}
|
void handleOpenSmartEditor(pl);
|
||||||
>
|
return;
|
||||||
<Pencil size={13} />
|
}
|
||||||
</button>
|
navigate(`/playlists/${pl.id}`, { state: { openEditMeta: true } });
|
||||||
|
}}
|
||||||
|
data-tooltip={t('playlists.editMeta')}
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{isPlaylistDeletable(pl) && (
|
{isPlaylistDeletable(pl) && (
|
||||||
<button
|
<button
|
||||||
className={`playlist-card-action playlist-card-action--delete${deleteConfirmId === pl.id ? ' playlist-card-action--delete-confirm' : ''}`}
|
className={`playlist-card-action playlist-card-action--delete${deleteConfirmId === pl.id ? ' playlist-card-action--delete-confirm' : ''}`}
|
||||||
|
|||||||
@@ -1051,6 +1051,14 @@
|
|||||||
box-shadow: var(--shadow-lg);
|
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 {
|
.playlist-cover-cell {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user