diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bf81bd..d7e50ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an | Playlist create/edit | ✅ | ✅ | | Playlist cover upload | ✅ | ✅ | | Spotify / YouTube importer | ✅ | ✅ | +| Teilen per Link | ✅ | ✅ | | Playlist aus Import anlegen | ✅ | ✅ | | Song von Festplatte löschen | ✅ | ✅ | | Metadata editor (tags + cover) | ✅ | ❌ | @@ -132,6 +133,17 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an zurück; der Editor zeigt sie samt bearbeitetem Dateipfad an und bleibt offen. ### Added +- **Teilen per Link (Web + Android)** — ein Song oder Album lässt sich per `https://…?song=` + an jemanden auf derselben Instanz schicken. Der Link trägt **nur die Id, keine Zugangsdaten**: + der Empfänger sieht den Inhalt mit seinem eigenen Navidrome-Konto. Web teilt über + `navigator.share()` und fällt sonst auf die Zwischenablage zurück; beim Öffnen wird das Album + angezeigt, der Song gestartet und der Parameter aus der Adresszeile entfernt, damit ein Neuladen + nicht erneut losspielt. Als installierte PWA landet der Link dank `launch_handler` im bereits + offenen Fenster. Android teilt über den Systemdialog (`ACTION_SEND`) und empfängt Links per + App-Link-Intent-Filter; der Host kommt beim Bauen aus `-PnovasonicHost=…`, die Web-Adresse für + ausgehende Links aus den Einstellungen. Für das Öffnen ohne Nachfrage muss der SHA-256 des + Release-Schlüssels in `public/.well-known/assetlinks.json` stehen — Anleitung in + `docs/share-links.md`. Ohne diesen Schritt öffnet der Link die Web-App im Browser. - **Android: Songs von der Festplatte löschen** — im Song-Kontextmenü, unten abgesetzt und rot, mit derselben Sicherheitsabfrage wie im Web. Nutzt denselben Backend-Endpunkt (`/api/delete-song`) und erscheint nur, wenn ein Backend konfiguriert ist — Navidrome allein kann keine Dateien entfernen. @@ -675,7 +687,6 @@ Polish release: Android navigation and offline resilience, download accuracy for ## Planned / Backlog ### Features -- **Web + Android** — **Share by link** — share a song/album to another user of the same instance via one `https://…` link that deep-links into the **PWA on desktop** and the **Android app on phone** (like Spotify/YouTube links). Android **App Links** (`intent-filter` + hosted `/.well-known/assetlinks.json` with the *release* signing SHA-256) open the app directly; PWA `launch_handler`/`handle_links` capture links on desktop. Link format leaning query param `?song=` (no server SPA-rewrite; PWA reads it on load). Carries only the song id — no secrets; playback uses the recipient's own Navidrome creds. Songs first → albums/artists/playlists. Web: `navigator.share()`; Android: `ACTION_SEND`. - **Web + Android** — **Yearly Wrapped** — a yearly story (`/scrobble/wrapped` + web story page + Android pager), built on the existing stats aggregations. - **Web + Android** — **Household leaderboard (>2 users)** — "who listened most this month" + top artists across all users; the broader multi-user version of the already-built 2-user Musik-Match. Needs wider cross-user reads (admin/household mode, or an aggregate endpoint exposing per-user totals only, never raw histories). - **Backend (optional)** — **ListenBrainz relay** — mirror counted plays to ListenBrainz. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 880a00b..ac82b84 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,6 +16,11 @@ android { targetSdk = 35 versionCode = 3 versionName = "1.0" + // Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in + // gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der + // Platzhalter stehen und der App-Link greift schlicht nicht. + manifestPlaceholders["shareHost"] = + (project.findProperty("novasonicHost") as String?) ?: "music.example.com" } buildFeatures { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d9586a4..9d0da22 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -33,6 +33,18 @@ + + + + + + + + /?song=… bzw. ?album=…). + * + * Die Activity kann nicht selbst navigieren — der NavController lebt in der Compose-Hierarchie. + * Deshalb legt MainActivity den eingehenden Link hier ab, und AppNavigation holt ihn sich, sobald + * es bereit ist. Ein einzelner Slot genuegt: der letzte geoeffnete Link gewinnt. + */ +object DeepLinkState { + val pending = MutableStateFlow(null) + + fun offer(uri: Uri?) { + // Nur Links mit einer fuer uns verwertbaren Kennung annehmen — ein blosser Start ueber das + // App-Symbol darf keine Navigation ausloesen. + if (uri == null) return + if (uri.getQueryParameter("song") == null && uri.getQueryParameter("album") == null) return + pending.value = uri + } + + /** Holt den Link und leert den Slot, damit er bei Drehung o. ae. nicht erneut greift. */ + fun consume(): Uri? = pending.value.also { pending.value = null } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt b/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt index 76da7c3..f6e45ef 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt @@ -19,6 +19,8 @@ class PrefsStore(private val context: Context) { val KEY_LASTFM_KEY = stringPreferencesKey("lastfm_key") val KEY_BACKEND_URL = stringPreferencesKey("backend_url") val KEY_SCROBBLE_URL = stringPreferencesKey("scrobble_url") + // Adresse der eigenen NovaSonic-Web-App — Basis der geteilten Links. + val KEY_WEB_URL = stringPreferencesKey("web_url") val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode") val KEY_SHOW_COUNT = booleanPreferencesKey("show_play_count") val KEY_THEME = stringPreferencesKey("theme") @@ -42,6 +44,7 @@ class PrefsStore(private val context: Context) { val lastFmKey: String = "", val backendUrl: String = "", val scrobbleUrl: String = "", + val webUrl: String = "", val scrobbleMode: String = "30s", val showPlayCount: Boolean = false, val theme: String = "dark-ocean", @@ -73,6 +76,7 @@ class PrefsStore(private val context: Context) { lastFmKey = prefs[KEY_LASTFM_KEY] ?: "", backendUrl = prefs[KEY_BACKEND_URL] ?: "", scrobbleUrl = prefs[KEY_SCROBBLE_URL] ?: "", + webUrl = prefs[KEY_WEB_URL] ?: "", scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s", showPlayCount = prefs[KEY_SHOW_COUNT] ?: false, theme = prefs[KEY_THEME] ?: "dark-ocean", @@ -108,6 +112,7 @@ class PrefsStore(private val context: Context) { prefs[KEY_LASTFM_KEY] = settings.lastFmKey prefs[KEY_BACKEND_URL] = settings.backendUrl prefs[KEY_SCROBBLE_URL] = settings.scrobbleUrl + prefs[KEY_WEB_URL] = settings.webUrl.trimEnd('/') prefs[KEY_SCROBBLE_MODE] = settings.scrobbleMode prefs[KEY_SHOW_COUNT] = settings.showPlayCount prefs[KEY_THEME] = settings.theme diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt index 8f0fa5d..1048e83 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt @@ -123,6 +123,31 @@ fun AppNavigation() { } val navController = rememberNavController() + + // Geteilten Link aufloesen, sobald die Navigation steht: Song abspielen und sein Album oeffnen, + // bzw. direkt zum Album. Laeuft ueber denselben Weg wie im Web, damit sich beide gleich + // verhalten. Fehlschlaege (geloeschter Song, fremde Instanz) enden in einer Meldung statt still. + LaunchedEffect(Unit) { + com.novasonic.app.data.DeepLinkState.pending.collect { uri -> + if (uri == null) return@collect + com.novasonic.app.data.DeepLinkState.consume() + val songId = uri.getQueryParameter("song") + val albumId = uri.getQueryParameter("album") + runCatching { + when { + songId != null -> { + val song = repo.getSong(songId) + song.albumId?.let { navController.navigate("album/$it") } + player.playSongs(listOf(song), 0) + } + albumId != null -> navController.navigate("album/$albumId") + } + }.onFailure { + Toast.makeText(context, "Link konnte nicht geöffnet werden: ${it.message}", + Toast.LENGTH_LONG).show() + } + } + } var showNowPlaying by remember { mutableStateOf(false) } var contextMenuSong by remember { mutableStateOf(null) } @@ -583,6 +608,21 @@ fun AppNavigation() { Toast.makeText(context, "Download gestartet: ${song.title}", android.widget.Toast.LENGTH_SHORT).show() contextMenuSong = null }, + // Link auf die eigene Web-App, den der Empfaenger mit SEINEN Zugangsdaten oeffnet — + // uebertragen wird nur die Song-Id, kein Geheimnis. Ohne konfigurierten Host waere + // der Link unbrauchbar, deshalb erscheint der Eintrag dann gar nicht erst. + onShare = appSettings.webUrl.takeIf { it.isNotBlank() }?.let { host -> + { + val link = "${host.trimEnd('/')}/?song=${song.id}" + val text = listOfNotNull(song.title, song.artist).joinToString(" — ") + context.startActivity(android.content.Intent.createChooser( + android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(android.content.Intent.EXTRA_SUBJECT, text) + putExtra(android.content.Intent.EXTRA_TEXT, "$text\n$link") + }, "Song teilen")) + } + }, // Nur mit Backend: das Loeschen laeuft ueber /api/delete-song, Navidrome allein // kann keine Dateien entfernen. onDelete = if (appSettings.backendUrl.isNotBlank()) { diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/MainActivity.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/MainActivity.kt index 93efa55..d1c1da4 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/MainActivity.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/MainActivity.kt @@ -19,6 +19,8 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Geteilter Link, mit dem die App gestartet wurde. + com.novasonic.app.data.DeepLinkState.offer(intent?.data) enableEdgeToEdge() setContent { NovaSonicTheme { @@ -28,6 +30,14 @@ class MainActivity : ComponentActivity() { } } } + + // launchMode=singleTask: laeuft die App bereits, kommt ein weiterer Link hier an statt in + // onCreate. Ohne das passierte beim zweiten geteilten Link schlicht nichts. + override fun onNewIntent(intent: android.content.Intent) { + super.onNewIntent(intent) + setIntent(intent) + com.novasonic.app.data.DeepLinkState.offer(intent.data) + } } @Composable diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/component/SongContextSheet.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/component/SongContextSheet.kt index 6712a9c..ba8d4dd 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/component/SongContextSheet.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/component/SongContextSheet.kt @@ -34,6 +34,7 @@ fun SongContextSheet( onShowInfo: (() -> Unit)? = null, onDownload: (() -> Unit)? = null, onDelete: (() -> Unit)? = null, + onShare: (() -> Unit)? = null, ) { ModalBottomSheet( onDismissRequest = onDismiss, @@ -78,6 +79,7 @@ fun SongContextSheet( onGoToAlbum?.let { CtxItem(Icons.Default.Album, "Zum Album", onClick = { onDismiss(); it() }) } onShowInfo?.let { CtxItem(Icons.Default.Info, "Song-Info", onClick = { onDismiss(); it() }) } onDownload?.let { CtxItem(Icons.Default.Download, "Herunterladen", onClick = { onDismiss(); it() }) } + onShare?.let { CtxItem(Icons.Default.Share, "Teilen", onClick = { onDismiss(); it() }) } } // Loeschen steht bewusst unten, abgesetzt und rot: es entfernt die Datei unwiderruflich von diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt index 6f6e88d..d385bd4 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt @@ -210,6 +210,13 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {}) ) ConnStatusRow(status = state.scrobbleStatus, label = "Nicht konfiguriert", onTest = vm::testScrobble) + NsTextField( + value = state.webUrl, onValueChange = vm::onWebUrl, + label = "NovaSonic Web-Adresse", + hint = "Basis der geteilten Links, z. B. https://music.example.com", + keyboardType = KeyboardType.Uri, + ) + Spacer(Modifier.height(4.dp)) // ── 🌐 Navidrome Server ────────────────────────────────────────────── diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt index 71fb74c..98d6c13 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt @@ -30,6 +30,7 @@ data class SettingsUiState( val lastFmKey: String = "", val backendUrl: String = "", val scrobbleUrl: String = "", + val webUrl: String = "", val scrobbleMode: String = "30s", val showPlayCount: Boolean = false, val theme: String = "dark-ocean", @@ -62,6 +63,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { lastFmKey = s.lastFmKey, backendUrl = s.backendUrl, scrobbleUrl = s.scrobbleUrl, + webUrl = s.webUrl, scrobbleMode = s.scrobbleMode, showPlayCount = s.showPlayCount, theme = s.theme, @@ -79,6 +81,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { fun onLastFmKey(v: String) = update { it.copy(lastFmKey = v, savedOk = false) } fun onBackendUrl(v: String) = update { it.copy(backendUrl = v, savedOk = false, backendStatus = ConnStatus.Idle) } fun onScrobbleUrl(v: String) = update { it.copy(scrobbleUrl = v, savedOk = false, scrobbleStatus = ConnStatus.Idle) } + fun onWebUrl(v: String) = update { it.copy(webUrl = v, savedOk = false) } fun onScrobbleMode(v: String) = update { it.copy(scrobbleMode = v, savedOk = false) } fun onShowPlayCount(v: Boolean) = update { it.copy(showPlayCount = v, savedOk = false) } fun onAbsUrl(v: String) = update { it.copy(absUrl = v, savedOk = false, absStatus = ConnStatus.Idle) } @@ -171,6 +174,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { lastFmKey = s.lastFmKey.trim(), backendUrl = s.backendUrl.trimEnd('/'), scrobbleUrl = s.scrobbleUrl.trimEnd('/'), + webUrl = s.webUrl.trimEnd('/'), scrobbleMode = s.scrobbleMode, showPlayCount = s.showPlayCount, theme = s.theme, diff --git a/docs/share-links.md b/docs/share-links.md new file mode 100644 index 0000000..e812ce1 --- /dev/null +++ b/docs/share-links.md @@ -0,0 +1,53 @@ +# Teilen per Link + +Ein geteilter Link sieht so aus: + +``` +https:///?song= +https:///?album= +``` + +Er trägt **nur die Id**, keine Zugangsdaten. Der Empfänger sieht den Inhalt mit seinem eigenen +Navidrome-Konto auf derselben Instanz — wer dort keinen Zugang hat, sieht nichts. + +## Web + +Funktioniert ohne weitere Einrichtung. Der Link wird beim Start ausgelesen (`?song=` / `?album=`), +das Album geöffnet und der Song abgespielt; danach verschwindet der Parameter aus der Adresszeile, +damit ein Neuladen nicht erneut startet. Als installierte PWA landet der Link dank +`launch_handler: navigate-existing` im bereits offenen Fenster statt in einem neuen. + +## Android + +Zwei Dinge sind nötig, damit ein Link **direkt in der App** landet statt im Browser: + +**1. Web-Adresse in der App hinterlegen** — Einstellungen → „NovaSonic Web-Adresse". Ohne sie +erscheint der Teilen-Eintrag im Kontextmenü gar nicht erst, weil der Link sonst ins Leere zeigt. + +**2. Den Host beim Bauen mitgeben** und die Signatur hinterlegen: + +```bash +./gradlew assembleRelease -PnovasonicHost=music.example.com +``` + +Dann den SHA-256 des **Release**-Signaturschlüssels ermitteln … + +```bash +keytool -list -v -keystore .jks -alias | grep SHA256 +``` + +… und in `public/.well-known/assetlinks.json` anstelle von `PLATZHALTER_RELEASE_SHA256` eintragen. +Die Datei muss unter `https:///.well-known/assetlinks.json` erreichbar sein — +sie liegt im `public/`-Verzeichnis und wird vom Frontend-Image mit ausgeliefert. + +Prüfen lässt sich das Ergebnis mit: + +```bash +adb shell pm get-app-links com.novasonic.app +``` + +Steht dort `verified`, öffnet Android die Links ohne Nachfrage. + +**Ohne diesen Schritt** ist der Link trotzdem nicht verloren: er öffnet im Browser die Web-App. +Wer die App bevorzugt, kann sie in den Systemeinstellungen unter *Standardmäßig öffnen* → *Links* +manuell für die Domain zulassen. diff --git a/public/.well-known/assetlinks.json b/public/.well-known/assetlinks.json new file mode 100644 index 0000000..fcfcb23 --- /dev/null +++ b/public/.well-known/assetlinks.json @@ -0,0 +1,12 @@ +[ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "com.novasonic.app", + "sha256_cert_fingerprints": [ + "PLATZHALTER_RELEASE_SHA256" + ] + } + } +] diff --git a/public/manifest.json b/public/manifest.json index 89605e9..856627b 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -27,5 +27,9 @@ "type": "image/svg+xml", "purpose": "maskable" } - ] -} + ], + "launch_handler": { + "client_mode": "navigate-existing" + }, + "handle_links": "preferred" +} \ No newline at end of file diff --git a/src/main.js b/src/main.js index 2e3b067..300a78f 100644 --- a/src/main.js +++ b/src/main.js @@ -111,6 +111,12 @@ async function startApp() { } else { navigate('home'); } + // Geteilter Link (?song=… / ?album=…): erst NACH der Anmeldung aufloesen — vorher gibt es keine + // Zugangsdaten, mit denen sich der Inhalt laden liesse. Ueberschreibt bewusst die + // wiederhergestellte Ansicht: wer einen Link oeffnet, will dorthin und nicht dahin, wo er zuletzt war. + import('./ui/share.js') + .then(m => m.handleShareParam(navigate, playQueue)) + .catch(() => {}); initNotifBell(); initPlayLog(); // flush any queued plays to the scrobble service (offline-safe) loadArtistOverrides().catch(() => {}); // pinned same-name artist → Deezer mappings (shared) diff --git a/src/ui/modals.js b/src/ui/modals.js index 675bba2..3cf06ce 100644 --- a/src/ui/modals.js +++ b/src/ui/modals.js @@ -31,6 +31,7 @@ export function showCtxMenu(e, song, list, idx) { }}, { sep: true }, { icon: '💿', label: 'Zum Album', fn: () => song.albumId && navigate('album-detail', { id: song.albumId, name: song.album||'', artist: song.artist||'', artistId: song.artistId||'', coverArt: song.coverArt||'' }) }, + { icon: '🔗', label: 'Teilen', fn: () => import('./share.js').then(m => m.shareSong(song)) }, { icon: 'ℹ', label: 'Song-Info', fn: () => import('./metadata.js').then(m => m.showSongInfo(song)) }, { icon: '⬇', label: 'Herunterladen', fn: () => dlSongLocal(song) }, ]; diff --git a/src/ui/share.js b/src/ui/share.js new file mode 100644 index 0000000..5b429a4 --- /dev/null +++ b/src/ui/share.js @@ -0,0 +1,87 @@ +import { S } from '../state.js'; +import { toast } from '../utils.js'; +import { api } from '../api.js'; + +// ─── TEILEN PER LINK ─── +// Ein Link traegt NUR eine Navidrome-Id, keine Zugangsdaten: der Empfaenger sieht den Inhalt mit +// seinen eigenen Anmeldedaten auf derselben Instanz. Als Query-Parameter (?song=…) statt als Pfad, +// damit der Webserver keine SPA-Umschreibung braucht — die App liest ihn beim Start selbst aus. +export function buildShareLink(type, id) { + const base = `${location.origin}${location.pathname.replace(/index\.html$/, '')}`; + return `${base}?${type}=${encodeURIComponent(id)}`; +} + +/** + * Teilt einen Link ueber das System (Handy: Teilen-Dialog) und faellt sonst auf die Zwischenablage + * zurueck. navigator.share gibt es nur in sicheren Kontexten und nicht auf jedem Desktop-Browser. + */ +export async function shareLink(type, id, title, subtitle) { + const url = buildShareLink(type, id); + const text = subtitle ? `${title} — ${subtitle}` : title; + if (navigator.share) { + try { + await navigator.share({ title, text, url }); + return; + } catch (e) { + // AbortError = der Nutzer hat den Dialog geschlossen; das ist kein Fehler. + if (e?.name === 'AbortError') return; + } + } + try { + await navigator.clipboard.writeText(url); + toast('🔗 Link kopiert'); + } catch { + // Zwischenablage kann blockiert sein (kein HTTPS, kein Nutzergesten-Kontext) — dann zeigen wir + // den Link, damit er wenigstens von Hand kopiert werden kann. + toast('🔗 ' + url); + } +} + +export const shareSong = (s) => shareLink('song', s.id, s.title || 'Song', s.artist || ''); +export const shareAlbum = (a) => shareLink('album', a.id, a.name || 'Album', a.artist || ''); + +/** + * Loest einen beim Start uebergebenen Link auf: Song abspielen bzw. Album oeffnen. Wird NACH der + * Anmeldung aufgerufen — ohne gueltige Zugangsdaten gibt es nichts abzuspielen. Der Parameter wird + * anschliessend aus der Adresszeile entfernt, damit ein Neuladen nicht erneut losspielt. + */ +export async function handleShareParam(navigate, playQueue) { + const p = new URLSearchParams(location.search); + const songId = p.get('song'); + const albumId = p.get('album'); + if (!songId && !albumId) return false; + + const sauber = () => { + try { history.replaceState(history.state, '', location.pathname); } catch {} + }; + + try { + if (songId) { + const r = await api('getSong', `&id=${encodeURIComponent(songId)}`); + const song = r.song; + if (!song) { toast('Song nicht gefunden'); sauber(); return false; } + if (song.albumId) { + navigate('album-detail', { + id: song.albumId, name: song.album || '', artist: song.artist || '', + artistId: song.artistId || '', coverArt: song.coverArt || '', + }); + } + playQueue([song], 0); + toast(`▶ ${song.title}`); + } else { + const r = await api('getAlbum', `&id=${encodeURIComponent(albumId)}`); + const al = r.album; + if (!al) { toast('Album nicht gefunden'); sauber(); return false; } + navigate('album-detail', { + id: al.id, name: al.name || '', artist: al.artist || '', + artistId: al.artistId || '', coverArt: al.coverArt || '', + }); + } + sauber(); + return true; + } catch (e) { + toast('Link konnte nicht geöffnet werden: ' + e.message); + sauber(); + return false; + } +}