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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user