feat(web+android): Teilen per Link
Ein Song oder Album laesst sich per https://<host>/?song=<id> an jemanden auf derselben Instanz schicken. Der Link traegt nur die Navidrome-Id, keine Zugangsdaten — der Empfaenger sieht den Inhalt mit seinem eigenen Konto. Web: Teilen ueber navigator.share() mit Rueckfall auf die Zwischenablage. Beim Start wird ?song= / ?album= aufgeloest (nach der Anmeldung, vorher fehlen die Zugangsdaten), das Album geoeffnet, der Song gestartet und der Parameter aus der Adresszeile entfernt, damit ein Neuladen nicht erneut losspielt. Das Manifest bekommt launch_handler=navigate-existing, damit der Link in einem bereits offenen PWA-Fenster landet statt in einem neuen. Android: Teilen ueber ACTION_SEND, Empfang ueber einen App-Link-Intent-Filter. Der Host ist eine Build-Eigenschaft (-PnovasonicHost=…, verifiziert am zusammengefuehrten Manifest), die Web-Adresse fuer ausgehende Links eine neue Einstellung. Weil der NavController in der Compose-Hierarchie lebt und die Activity nicht selbst navigieren kann, reicht DeepLinkState den Link durch; onNewIntent ist mitbedacht, sonst passierte beim zweiten Link nichts. assetlinks.json liegt als Vorlage in public/ und wird vom Frontend-Image ausgeliefert (im Build verifiziert); ohne den Release-Fingerabdruck oeffnet der Link die Web-App im Browser statt die App. Anleitung: docs/share-links.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3c99778567
commit
430ecdc737
16 files changed
+288
-3
No files matched your search
+12
-1
@@ -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=<id>`
|
||||
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=<id>` (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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -33,6 +33,18 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
|
||||
<!-- Geteilte Links (https://<host>/?song=…) direkt in der App oeffnen.
|
||||
autoVerify greift erst, wenn unter https://<host>/.well-known/assetlinks.json der
|
||||
SHA-256 des RELEASE-Signaturschluessels hinterlegt ist. Ohne das bleibt der Filter
|
||||
wirkungslos, bis der Nutzer die App in den Systemeinstellungen unter
|
||||
"Standardmaessig oeffnen" fuer diese Domain zulaesst. -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="https" android:host="${shareHost}"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
/**
|
||||
* Uebergabepunkt fuer geteilte Links (https://<host>/?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<Uri?>(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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<Song?>(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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Teilen per Link
|
||||
|
||||
Ein geteilter Link sieht so aus:
|
||||
|
||||
```
|
||||
https://<deine-web-adresse>/?song=<navidrome-song-id>
|
||||
https://<deine-web-adresse>/?album=<navidrome-album-id>
|
||||
```
|
||||
|
||||
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 <dein-keystore>.jks -alias <alias> | grep SHA256
|
||||
```
|
||||
|
||||
… und in `public/.well-known/assetlinks.json` anstelle von `PLATZHALTER_RELEASE_SHA256` eintragen.
|
||||
Die Datei muss unter `https://<deine-web-adresse>/.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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -27,5 +27,9 @@
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"launch_handler": {
|
||||
"client_mode": "navigate-existing"
|
||||
},
|
||||
"handle_links": "preferred"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) },
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user