diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml
index 91acb98039..467e9ac3d9 100644
--- a/amethyst/src/main/AndroidManifest.xml
+++ b/amethyst/src/main/AndroidManifest.xml
@@ -90,6 +90,11 @@
+
+
+
+
@@ -556,6 +561,25 @@
+
+
+
+
+
+
+
+
+
(null, packageName) {
+ value =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ context.packageManager
+ .getApplicationIcon(packageName)
+ .toBitmap(ICON_PX, ICON_PX)
+ .asImageBitmap()
+ }.getOrNull()
+ }
+ }
+
+ val current = icon
+ if (current != null) {
+ Image(bitmap = current, contentDescription = null, modifier = Modifier.fillMaxSize())
+ } else {
+ MonogramBadge(label, size = 36, shape = RoundedCornerShape(10.dp))
+ }
+}
+
+private const val ICON_PX = 108
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/AndroidNowPlayingAccess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/AndroidNowPlayingAccess.kt
new file mode 100644
index 0000000000..93bb929353
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/AndroidNowPlayingAccess.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.nowPlaying
+
+import android.content.Context
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingAccess
+
+/** Notification access, which Android requires before it shares other apps' media sessions. */
+class AndroidNowPlayingAccess(
+ private val context: Context,
+) : NowPlayingAccess {
+ override fun hasAccess() = OtherAppsNowPlayingService.hasAccess(context)
+
+ override fun requestAccess() = OtherAppsNowPlayingService.openAccessSettings(context)
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/InAppPlayback.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/InAppPlayback.kt
new file mode 100644
index 0000000000..3515857edd
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/InAppPlayback.kt
@@ -0,0 +1,135 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.nowPlaying
+
+import androidx.compose.runtime.Immutable
+import androidx.media3.common.C
+import androidx.media3.common.Player
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying
+import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemCache
+import com.vitorpamplona.quartz.utils.TimeUtils
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import kotlin.math.abs
+
+/** What one of the app's players is audibly playing: its item's `nostr:` URI and where it ends. */
+@Immutable
+data class InAppPlayback(
+ val callbackUri: String,
+ val title: String?,
+ val artist: String?,
+ val endsAt: Long?,
+) {
+ /** Same item, ending within a few seconds: a position tick rather than a change. */
+ fun isSameMoment(other: InAppPlayback?): Boolean {
+ if (other == null || callbackUri != other.callbackUri || title != other.title || artist != other.artist) return false
+ val a = endsAt
+ val b = other.endsAt
+ return if (a == null || b == null) a == b else abs(a - b) <= NowPlaying.MOMENT_TOLERANCE_SECONDS
+ }
+}
+
+/**
+ * The players in this process that are audibly playing something with a Nostr event behind it,
+ * keyed by player. Fed by [InAppPlaybackListener]; read by the now-playing coordinator, which
+ * resolves the URI to a music track or podcast episode and ignores everything else.
+ */
+object InAppPlaybackRegistry {
+ private val playing = MutableStateFlow>(emptyMap())
+
+ val flow: StateFlow> = playing
+
+ fun update(
+ player: Player,
+ playback: InAppPlayback?,
+ ) {
+ val key = System.identityHashCode(player)
+ playing.update { current ->
+ val previous = current[key]
+ when {
+ playback == null -> if (previous != null) current - key else current
+ // Live streams and some players report timeline/position changes every few
+ // seconds; keeping the same map avoids re-running everything downstream.
+ playback.isSameMoment(previous) -> current
+ // The same item with a corrected end keeps its place; a new item moves to the
+ // end, so iterating backwards visits the most recently started first.
+ previous?.callbackUri == playback.callbackUri -> current + (key to playback)
+ else -> (current - key) + (key to playback)
+ }
+ }
+ }
+
+ /** A released player reports nothing on its way out, so the pool drops it here. */
+ fun forget(player: Player) = update(player, null)
+}
+
+/**
+ * Reports a player's audible playback to [InAppPlaybackRegistry]. A muted player (feed autoplay)
+ * or one without a `nostr:` callback URI reports nothing.
+ *
+ * Attached once per player in `ExoPlayerBuilder`; runs on the player's looper.
+ */
+class InAppPlaybackListener(
+ private val registry: InAppPlaybackRegistry = InAppPlaybackRegistry,
+ private val nowSeconds: () -> Long = TimeUtils::now,
+) : Player.Listener {
+ override fun onEvents(
+ player: Player,
+ events: Player.Events,
+ ) {
+ if (events.containsAny(*RELEVANT_EVENTS)) {
+ registry.update(player, snapshot(player))
+ }
+ }
+
+ private fun snapshot(player: Player): InAppPlayback? {
+ if (!player.isPlaying || player.volume < 0.001f) return null
+
+ val item = player.currentMediaItem ?: return null
+ val metadata = item.mediaMetadata
+ val callbackUri = metadata.extras?.getString(MediaItemCache.EXTRA_CALLBACK_URI) ?: return null
+ if (!callbackUri.startsWith("nostr:")) return null
+
+ val duration = player.duration.takeIf { it != C.TIME_UNSET && !player.isCurrentMediaItemLive }
+
+ return InAppPlayback(
+ callbackUri = callbackUri,
+ // MediaItemCache falls back to the media URL (the media id) when there is no title.
+ title = metadata.title?.toString()?.takeUnless { it == item.mediaId },
+ artist = metadata.artist?.toString(),
+ endsAt = NowPlaying.endsAt(nowSeconds(), duration, player.currentPosition, player.playbackParameters.speed),
+ )
+ }
+
+ companion object {
+ private val RELEVANT_EVENTS =
+ intArrayOf(
+ Player.EVENT_IS_PLAYING_CHANGED,
+ Player.EVENT_MEDIA_ITEM_TRANSITION,
+ Player.EVENT_POSITION_DISCONTINUITY,
+ Player.EVENT_TIMELINE_CHANGED,
+ Player.EVENT_VOLUME_CHANGED,
+ Player.EVENT_PLAYBACK_PARAMETERS_CHANGED,
+ Player.EVENT_MEDIA_METADATA_CHANGED,
+ )
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/NowPlayingStatusCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/NowPlayingStatusCoordinator.kt
new file mode 100644
index 0000000000..40485ae3df
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/NowPlayingStatusCoordinator.kt
@@ -0,0 +1,144 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.nowPlaying
+
+import com.vitorpamplona.amethyst.commons.model.Account
+import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingPublisher
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingResolver
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingStatusService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+
+/**
+ * Publishes what the user is listening to, in Amethyst or in another app, as the logged-in
+ * account's NIP-38 music status, following that account's now-playing settings.
+ *
+ * App-scoped, like the other background watchers in AppModules: the music keeps playing (and
+ * other apps keep reporting) after the activity is gone. While the app is in the background the
+ * relay pool is paused, so every publish holds [relayServices] open for [RELAY_HOLD_MS], long
+ * enough for the event to go out.
+ */
+class NowPlayingStatusCoordinator(
+ private val scope: CoroutineScope,
+ private val accountFlow: Flow,
+ private val relayServices: Flow<*>,
+ private val inApp: StateFlow> = InAppPlaybackRegistry.flow,
+ private val otherApps: StateFlow = OtherAppsPlaybackRegistry.flow,
+) {
+ private var relayHold: Job? = null
+
+ fun start() {
+ scope.launch(Dispatchers.IO) {
+ accountFlow
+ .distinctUntilChanged { a, b -> a?.signer?.pubKey == b?.signer?.pubKey }
+ .collectLatest { account ->
+ if (account == null || !account.isWriteable()) return@collectLatest
+ run(account)
+ }
+ }
+ }
+
+ private suspend fun run(account: Account) {
+ // Every player that is audibly playing, most recently started first: the first one that
+ // is a music track or podcast episode wins, so an unmuted feed video started after a song
+ // does not hide the song.
+ val inAppTracks =
+ inApp
+ .map { playing -> playing.values.reversed().firstNotNullOfOrNull(::resolve) }
+ .flowOn(Dispatchers.IO)
+
+ val publisher =
+ NowPlayingPublisher(
+ // Relays are woken first: in the background the pool is paused, and the event
+ // waits in the outbox until a relay connects.
+ publish = { track, expiration ->
+ holdRelays()
+ account.publishNowPlaying(track, expiration)
+ },
+ clear = {
+ holdRelays()
+ account.clearNowPlaying()
+ },
+ )
+
+ NowPlayingStatusService(
+ settings = account.nowPlayingSettings.flow,
+ sources = listOf(inAppTracks, otherApps),
+ onOtherAppSeen = { account.nowPlayingSettings.rememberApp(it.id, it.label) },
+ publisher = publisher,
+ ).run()
+ }
+
+ /**
+ * What a player's item is as a status, without its end time. Keyed by the item's `nostr:` URI
+ * so the NIP-19 parse and the cache lookup run once per item, not once per position update.
+ * Only touched from the single flow collecting [inApp].
+ */
+ private val resolved = HashMap()
+
+ private class Resolution(
+ val track: NowPlaying?,
+ )
+
+ private fun resolve(playback: InAppPlayback): NowPlaying? {
+ val cached =
+ resolved[playback.callbackUri] ?: run {
+ val key = NowPlayingResolver.noteKey(playback.callbackUri)
+ val event =
+ if (key != null) {
+ // Not loaded yet: resolve again on the next update instead of caching a miss.
+ LocalCache.getNoteIfExists(key)?.event ?: return null
+ } else {
+ null
+ }
+ val track = event?.let { NowPlayingResolver.fromEvent(it, playback.title, playback.artist) }
+
+ if (resolved.size >= MAX_RESOLVED) resolved.clear()
+ Resolution(track).also { resolved[playback.callbackUri] = it }
+ }
+
+ return cached.track?.copy(endsAt = playback.endsAt)
+ }
+
+ private fun holdRelays() {
+ relayHold?.cancel()
+ relayHold =
+ scope.launch(Dispatchers.IO) {
+ withTimeoutOrNull(RELAY_HOLD_MS) { relayServices.collect {} }
+ }
+ }
+
+ companion object {
+ private const val RELAY_HOLD_MS = 30_000L
+ private const val MAX_RESOLVED = 64
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/OtherAppsNowPlayingService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/OtherAppsNowPlayingService.kt
new file mode 100644
index 0000000000..a461d2b516
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/OtherAppsNowPlayingService.kt
@@ -0,0 +1,225 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.nowPlaying
+
+import android.content.ActivityNotFoundException
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.media.MediaMetadata
+import android.media.session.MediaController
+import android.media.session.MediaSession
+import android.media.session.MediaSessionManager
+import android.media.session.PlaybackState
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
+import android.provider.Settings
+import android.service.notification.NotificationListenerService
+import androidx.core.app.NotificationManagerCompat
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSource
+import com.vitorpamplona.quartz.utils.Log
+import com.vitorpamplona.quartz.utils.TimeUtils
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+/** What another app on this device is playing, as reported by its media session. */
+object OtherAppsPlaybackRegistry {
+ private val playing = MutableStateFlow(null)
+
+ val flow: StateFlow = playing
+
+ fun update(track: NowPlaying?) {
+ // Many players report their position every second; only a real change goes downstream.
+ if (track != null && track.isSameMoment(playing.value)) return
+ playing.value = track
+ }
+}
+
+/**
+ * Reads the media sessions of other apps (Spotify, YouTube Music, podcast players, ...) to share
+ * what the user is listening to as a NIP-38 music status.
+ *
+ * Android only hands out other apps' sessions to an enabled notification listener, which is why
+ * this is one: it never reads or posts notifications. The user grants it in the system's
+ * "Notification access" screen ([openAccessSettings]); until then the system never binds it.
+ * Whether anything is published is still up to the account's now-playing settings.
+ *
+ * Runs in the main process (it is not declared in `:napplet`), where the account lives. The
+ * system may start that process just to bind this listener; AppModules then logs the saved
+ * account in on its own, as it does on every start, so the publisher has it.
+ */
+class OtherAppsNowPlayingService : NotificationListenerService() {
+ private val handler = Handler(Looper.getMainLooper())
+ private var sessionManager: MediaSessionManager? = null
+ private val watched = mutableMapOf>()
+
+ // App labels come from PackageManager, an IPC; players report state up to once a second.
+ private val labels = mutableMapOf()
+
+ private val sessionsListener =
+ MediaSessionManager.OnActiveSessionsChangedListener { controllers ->
+ watch(controllers.orEmpty())
+ }
+
+ override fun onListenerConnected() {
+ super.onListenerConnected()
+ val manager = getSystemService(MediaSessionManager::class.java) ?: return
+ sessionManager = manager
+
+ try {
+ val component = ComponentName(this, OtherAppsNowPlayingService::class.java)
+ manager.addOnActiveSessionsChangedListener(sessionsListener, component, handler)
+ watch(manager.getActiveSessions(component))
+ } catch (e: SecurityException) {
+ // Access was revoked between the bind and this call.
+ Log.w("OtherAppsNowPlaying", "Notification access is not granted", e)
+ }
+ }
+
+ override fun onListenerDisconnected() {
+ sessionManager?.removeOnActiveSessionsChangedListener(sessionsListener)
+ sessionManager = null
+ unwatchAll()
+ OtherAppsPlaybackRegistry.update(null)
+ super.onListenerDisconnected()
+ }
+
+ override fun onDestroy() {
+ sessionManager?.removeOnActiveSessionsChangedListener(sessionsListener)
+ unwatchAll()
+ OtherAppsPlaybackRegistry.update(null)
+ super.onDestroy()
+ }
+
+ private fun watch(controllers: List) {
+ val others = controllers.filter { it.packageName != packageName }
+ val keys = others.mapTo(mutableSetOf()) { it.sessionToken }
+
+ watched.keys.filter { it !in keys }.forEach { key ->
+ watched.remove(key)?.let { (controller, callback) -> controller.unregisterCallback(callback) }
+ }
+
+ others.forEach { controller ->
+ val key = controller.sessionToken
+ if (key !in watched) {
+ val callback =
+ object : MediaController.Callback() {
+ override fun onPlaybackStateChanged(state: PlaybackState?) = refresh()
+
+ override fun onMetadataChanged(metadata: MediaMetadata?) = refresh()
+
+ override fun onSessionDestroyed() {
+ watched.remove(key)?.let { (it, cb) -> it.unregisterCallback(cb) }
+ refresh()
+ }
+ }
+ controller.registerCallback(callback, handler)
+ watched[key] = controller to callback
+ }
+ }
+
+ refresh()
+ }
+
+ private fun unwatchAll() {
+ watched.values.forEach { (controller, callback) -> controller.unregisterCallback(callback) }
+ watched.clear()
+ }
+
+ /** The first session that is playing, in the system's priority order. */
+ private fun refresh() {
+ val track = watched.values.firstNotNullOfOrNull { (controller, _) -> toNowPlaying(controller) }
+ OtherAppsPlaybackRegistry.update(track)
+ }
+
+ private fun toNowPlaying(controller: MediaController): NowPlaying? {
+ val state = controller.playbackState ?: return null
+ if (state.state != PlaybackState.STATE_PLAYING) return null
+
+ val metadata = controller.metadata ?: return null
+ val title =
+ metadata.getString(MediaMetadata.METADATA_KEY_TITLE)?.ifBlank { null }
+ ?: metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)?.ifBlank { null }
+ ?: return null
+ val artist =
+ metadata.getString(MediaMetadata.METADATA_KEY_ARTIST)?.ifBlank { null }
+ ?: metadata.getString(MediaMetadata.METADATA_KEY_ALBUM_ARTIST)?.ifBlank { null }
+ ?: metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE)?.ifBlank { null }
+
+ val duration = metadata.getLong(MediaMetadata.METADATA_KEY_DURATION).takeIf { it > 0 }
+ val speed = state.playbackSpeed.takeIf { it > 0f } ?: 1f
+ val position =
+ if (state.lastPositionUpdateTime > 0) {
+ state.position + ((SystemClock.elapsedRealtime() - state.lastPositionUpdateTime) * speed).toLong()
+ } else {
+ state.position
+ }
+
+ return NowPlaying(
+ title = title,
+ artist = artist,
+ source = NowPlayingSource.OtherApp(controller.packageName, appLabel(controller.packageName)),
+ endsAt = NowPlaying.endsAt(TimeUtils.now(), duration, position, speed),
+ )
+ }
+
+ private fun appLabel(packageName: String): String =
+ labels.getOrPut(packageName) {
+ try {
+ packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)).toString()
+ } catch (_: PackageManager.NameNotFoundException) {
+ packageName
+ }
+ }
+
+ companion object {
+ fun hasAccess(context: Context): Boolean = context.packageName in NotificationManagerCompat.getEnabledListenerPackages(context)
+
+ /** Opens the system screen where the user grants or revokes access to this listener. */
+ fun openAccessSettings(context: Context) {
+ val component = ComponentName(context, OtherAppsNowPlayingService::class.java).flattenToString()
+ // Straight to this app's toggle where the system supports it, else the list of listeners.
+ val intents =
+ listOfNotNull(
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ Intent(Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS)
+ .putExtra(Settings.EXTRA_NOTIFICATION_LISTENER_COMPONENT_NAME, component)
+ } else {
+ null
+ },
+ Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS),
+ )
+
+ for (intent in intents) {
+ try {
+ context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ return
+ } catch (e: ActivityNotFoundException) {
+ Log.w("OtherAppsNowPlaying", "No activity for ${intent.action}", e)
+ }
+ }
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt
index aa969245d4..888df68e76 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt
@@ -33,6 +33,7 @@ import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.audio.TeeAudioProcessor
import com.vitorpamplona.amethyst.commons.model.MediaAspectRatioCache
+import com.vitorpamplona.amethyst.service.nowPlaying.InAppPlaybackListener
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.playerPool.aspectRatio.AspectRatioCacher
import com.vitorpamplona.amethyst.service.playback.playerPool.positions.CurrentPlayPositionCacher
@@ -89,6 +90,7 @@ class ExoPlayerBuilder(
addListener(KeepVideosPlaying(this))
addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache))
addListener(MediaPlayTimeTracker())
+ addListener(InAppPlaybackListener())
}
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt
index 6e9c18c89c..c6de2f111e 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt
@@ -25,6 +25,7 @@ import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
+import com.vitorpamplona.amethyst.service.nowPlaying.InAppPlaybackRegistry
import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineExceptionHandler
@@ -132,6 +133,7 @@ class ExoPlayerPool(
if (error != null) {
Log.d("PlaybackService") { "ExoPlayerPool discarding errored warm player: $preferredMediaId (${error.errorCodeName})" }
PcmTapRegistry.unregisterPlayer(warm)
+ InAppPlaybackRegistry.forget(warm)
warm.release()
releaseDecoder()
} else {
@@ -239,6 +241,7 @@ class ExoPlayerPool(
if (error != null) {
Log.d("PlaybackService") { "ExoPlayerPool dropping errored player: ${player.currentMediaItem?.mediaId} (${error.errorCodeName})" }
PcmTapRegistry.unregisterPlayer(player)
+ InAppPlaybackRegistry.forget(player)
player.release()
releaseDecoder()
return@withLock
@@ -309,6 +312,7 @@ class ExoPlayerPool(
}
} else {
PcmTapRegistry.unregisterPlayer(player)
+ InAppPlaybackRegistry.forget(player)
player.release() // Release if pool is full.
}
}
@@ -345,11 +349,13 @@ class ExoPlayerPool(
}
warmSnapshot.forEach {
PcmTapRegistry.unregisterPlayer(it.player)
+ InAppPlaybackRegistry.forget(it.player)
it.player.release()
releaseDecoder()
}
coldPool.forEach {
PcmTapRegistry.unregisterPlayer(it)
+ InAppPlaybackRegistry.forget(it)
it.release()
}
coldPool.clear()
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
index 3131255e72..56689d78ff 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
@@ -232,6 +232,7 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.HomeTabsSe
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.MessagesSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.MutedThreadsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.NIP47SetupScreen
+import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.NowPlayingSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.ProfileUiSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.ReactionsSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SecurityFiltersScreen
@@ -271,6 +272,8 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.workouts.WorkoutsSc
import com.vitorpamplona.amethyst.commons.ui.uriToRoute
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
+import com.vitorpamplona.amethyst.service.nowPlaying.AndroidAppIcon
+import com.vitorpamplona.amethyst.service.nowPlaying.AndroidNowPlayingAccess
import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthPromptHost
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert
@@ -653,6 +656,11 @@ fun BuildNavigation(
composableFromEnd { ProfileUiSettingsScreen(accountViewModel, nav) }
composableFromEnd { VideoPlayerSettingsScreen(accountViewModel, nav) }
composableFromEnd { CallSettingsScreen(accountViewModel, nav) }
+ composableFromEnd {
+ val context = LocalContext.current
+ val access = remember(context) { AndroidNowPlayingAccess(context.applicationContext) }
+ NowPlayingSettingsScreen(accountViewModel, nav, access) { appId, label -> AndroidAppIcon(appId, label) }
+ }
composableFromEnd { NotificationSettingsScreen(accountViewModel, nav) }
composableFromEnd { ResourceUsageScreen(accountViewModel, nav) }
composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) }
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt
index e947d9f041..5190d02c82 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt
@@ -165,6 +165,7 @@ import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import org.jetbrains.compose.resources.StringResource
@@ -355,7 +356,11 @@ private fun EditStatusBoxes(
accountViewModel: AccountViewModel,
nav: INav,
) {
- val statuses by observeUserStatuses(baseAccountUser, accountViewModel)
+ val allStatuses by observeUserStatuses(baseAccountUser, accountViewModel)
+
+ // The music status is written by the now-playing publisher, not typed here: editing it would
+ // overwrite the track and keep its expiration, so only the other statuses are editable.
+ val statuses = remember(allStatuses) { allStatuses.filter { it.address.dTag != UserStatusEvent.MUSIC } }
if (statuses.isEmpty()) {
PreviewStatusEditBar(accountViewModel = accountViewModel, nav = nav)
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt
index 886cbeea46..557380d38c 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt
@@ -93,6 +93,8 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListS
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.OwnedEmojiPacksState
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState
import com.vitorpamplona.amethyst.commons.model.nip46Signer.Nip46ConsentPrompter
import com.vitorpamplona.amethyst.commons.model.nip46Signer.Nip46SignerState
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcInfoCache
@@ -533,6 +535,11 @@ class Account(
// channel list. Restored/persisted per account by BuzzChannelStarStore.
val buzzChannelStars = BuzzChannelStars()
+ // Whether THIS account shares what the user listens to as its NIP-38 music status, and from
+ // which apps. Per account because the status is signed and published by this key. Restored/
+ // persisted per account by NowPlayingSettingsStore on Android.
+ val nowPlayingSettings = NowPlayingSettingsState()
+
// The NIP-OA attestation an owner issued to THIS account's key, attached to its Buzz-relay
// AUTH so the relay grants virtual membership. Restored/persisted per account by
// BuzzAttestationStore.
@@ -3351,6 +3358,13 @@ class Account(
suspend fun deleteStatus(oldStatus: AddressableNote) = sendMyPublicAndPrivateOutbox(UserStatusAction.delete(oldStatus, signer))
+ suspend fun publishNowPlaying(
+ track: NowPlaying,
+ expiration: Long,
+ ) = sendMyPublicAndPrivateOutbox(UserStatusAction.createMusic(track, expiration, signer))
+
+ suspend fun clearNowPlaying() = sendMyPublicAndPrivateOutbox(UserStatusAction.clearMusic(signer))
+
suspend fun removeEmojiPack(emojiPack: Note) = sendMyPublicAndPrivateOutbox(emoji.removeEmojiPack(emojiPack))
suspend fun addEmojiPack(emojiPack: Note) = sendMyPublicAndPrivateOutbox(emoji.addEmojiPack(emojiPack))
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt
index 70b8daf092..f2a0d2e832 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt
@@ -585,6 +585,8 @@ sealed class Route {
@Serializable object CallSettings : Route()
+ @Serializable object NowPlayingSettings : Route()
+
@Serializable object NotificationSettings : Route()
@Serializable object CalendarReminderSettings : Route()
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusAction.kt
index 4fac63e096..57499eb3cb 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusAction.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusAction.kt
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses
import com.vitorpamplona.amethyst.commons.model.AddressableNote
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
@@ -34,6 +35,25 @@ class UserStatusAction {
type: String = UserStatusEvent.GENERAL,
): UserStatusEvent = UserStatusEvent.create(newStatus, type, signer = signer)
+ /** A `music` status for [track] that expires at [expiration], when the track ends. */
+ suspend fun createMusic(
+ track: NowPlaying,
+ expiration: Long,
+ signer: NostrSigner,
+ ): UserStatusEvent =
+ UserStatusEvent.create(
+ msg = track.statusText(),
+ type = UserStatusEvent.MUSIC,
+ expiration = expiration,
+ url = track.url,
+ eventId = track.eventId,
+ addressableId = track.addressId,
+ signer = signer,
+ )
+
+ /** Clears the `music` status: NIP-38 clears a status by replacing it with blank content. */
+ suspend fun clearMusic(signer: NostrSigner): UserStatusEvent = UserStatusEvent.create("", UserStatusEvent.MUSIC, signer = signer)
+
suspend fun update(
oldStatus: AddressableNote,
newStatus: String,
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt
index fc04d0352a..c5a548cc31 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt
@@ -42,11 +42,16 @@ class UserStatusCache : UserDependencies {
)
fun addStatus(note: AddressableNote) {
- // if it's already there, quick exit
- if (statuses.value.contains(note) || note.isEmptyStatus()) return
+ // A newer version of a listed status can be blank (NIP-38 clears a status that way, and
+ // a music status is cleared every time playback stops) or already expired: the note is
+ // the same object, so it has to leave the list rather than be skipped as already there.
+ if (note.isEmptyStatus() || note.event?.isExpired() == true) {
+ removeStatus(note)
+ return
+ }
- // don't add expired statuses
- if (note.event?.isExpired() == true) return
+ // if it's already there, quick exit
+ if (statuses.value.contains(note)) return
statuses.update {
(it + note).sortedWith(sortModel).toImmutableList()
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlaying.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlaying.kt
new file mode 100644
index 0000000000..32287b1968
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlaying.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying
+
+import androidx.compose.runtime.Immutable
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import kotlin.math.abs
+
+/** Where a track is being played. */
+@Immutable
+sealed interface NowPlayingSource {
+ /** Amethyst's own player (a music track or podcast episode opened in the app). */
+ data object InApp : NowPlayingSource
+
+ /**
+ * Another app on this device. [id] is stable across sessions (an Android package name, an
+ * MPRIS bus name, a Windows app id) and is what the per-app block list keys on; [label] is
+ * what the settings screen shows.
+ */
+ data class OtherApp(
+ val id: String,
+ val label: String,
+ ) : NowPlayingSource
+}
+
+/**
+ * A track (song or podcast episode) the user is playing right now, ready to become a NIP-38
+ * `music` status.
+ *
+ * [endsAt] is the Unix time (seconds) the track will finish at its current position and speed,
+ * which becomes the status' NIP-40 expiration, as NIP-38 asks for music. It is null when the
+ * length is unknown (a live stream, an app that does not report a duration).
+ *
+ * [url], [addressId] and [eventId] become the status' `r`, `a` and `e` tags, so readers can open
+ * what is playing: in-app tracks point at their Nostr event.
+ */
+@Immutable
+data class NowPlaying(
+ val title: String,
+ val artist: String? = null,
+ val source: NowPlayingSource,
+ val endsAt: Long? = null,
+ val url: String? = null,
+ val addressId: String? = null,
+ val eventId: HexKey? = null,
+) {
+ /** The status line, in the `Title - Artist` shape of the NIP-38 example. */
+ fun statusText(): String = if (artist.isNullOrBlank()) title else "$title - $artist"
+
+ /** Same track, regardless of the playback position. */
+ fun isSameTrack(other: NowPlaying): Boolean =
+ title == other.title &&
+ artist == other.artist &&
+ url == other.url &&
+ addressId == other.addressId &&
+ eventId == other.eventId
+
+ /**
+ * Same track from the same source, ending within [toleranceSeconds] of [other]. Sources call
+ * this to drop the position ticks some players report every second, which would otherwise
+ * re-emit a "new" value (and re-run everything downstream) for a status that has not changed.
+ */
+ fun isSameMoment(
+ other: NowPlaying?,
+ toleranceSeconds: Long = MOMENT_TOLERANCE_SECONDS,
+ ): Boolean {
+ if (other == null || source != other.source || !isSameTrack(other)) return false
+ val a = endsAt
+ val b = other.endsAt
+ return if (a == null || b == null) a == b else abs(a - b) <= toleranceSeconds
+ }
+
+ companion object {
+ const val MOMENT_TOLERANCE_SECONDS = 5L
+
+ /**
+ * When a track [durationMs] long, now at [positionMs] and playing at [speed], ends,
+ * measured from [nowSeconds]. Null when the duration is unknown or the track already ended.
+ */
+ fun endsAt(
+ nowSeconds: Long,
+ durationMs: Long?,
+ positionMs: Long,
+ speed: Float = 1f,
+ ): Long? {
+ if (durationMs == null || durationMs <= 0) return null
+ val remainingMs = durationMs - positionMs.coerceAtLeast(0)
+ if (remainingMs <= 0) return null
+ val effectiveSpeed = if (speed > 0f) speed else 1f
+ return nowSeconds + (remainingMs / effectiveSpeed / 1000).toLong() + 1
+ }
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingAccess.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingAccess.kt
new file mode 100644
index 0000000000..44d3f6db59
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingAccess.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying
+
+/**
+ * The OS permission that sharing other apps' playback depends on. On Android that is notification
+ * access, which only the user can grant in system settings; platforms that need nothing pass no
+ * port at all.
+ */
+interface NowPlayingAccess {
+ fun hasAccess(): Boolean
+
+ /** Takes the user to where the access is granted or revoked. */
+ fun requestAccess()
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisher.kt
new file mode 100644
index 0000000000..55a6899d0e
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisher.kt
@@ -0,0 +1,156 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying
+
+import com.vitorpamplona.quartz.utils.Log
+import com.vitorpamplona.quartz.utils.TimeUtils
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlin.coroutines.cancellation.CancellationException
+import kotlin.math.abs
+
+/**
+ * Picks what to share out of the tracks each source reports, in priority order (the in-app
+ * player first), skipping the sources [settings] does not allow.
+ */
+fun pickNowPlaying(
+ settings: NowPlayingSettings,
+ vararg candidates: NowPlaying?,
+): NowPlaying? = candidates.firstOrNull { it != null && settings.isAllowed(it.source) }
+
+/**
+ * Turns a stream of "what is playing now" (null when nothing is) into NIP-38 `music` statuses.
+ *
+ * - A new track is published only after it has played for [Config.startDelayMs], so skipping
+ * through a playlist does not sign and send an event per skipped song.
+ * - The status expires when the track ends (NIP-38 asks music statuses for that), so a crash or a
+ * dead battery never leaves a stale "listening to". When the length is unknown it expires after
+ * [Config.unknownLengthTtlSeconds] and is refreshed shortly before that while still playing.
+ * - A seek or a resume that moves the end by more than [Config.toleranceSeconds] republishes the
+ * same track with the corrected expiration.
+ * - When playback stops, the status is cleared (blank content, as NIP-38 says) after
+ * [Config.clearDelayMs], so a short pause or a buffering stall does not clear and re-post it.
+ *
+ * Runs on a single coroutine ([run]); a newer value cancels whatever wait the previous one was in.
+ */
+class NowPlayingPublisher(
+ private val publish: suspend (track: NowPlaying, expiration: Long) -> Unit,
+ private val clear: suspend () -> Unit,
+ private val nowSeconds: () -> Long = TimeUtils::now,
+ private val config: Config = Config(),
+) {
+ data class Config(
+ val startDelayMs: Long = 5_000,
+ val clearDelayMs: Long = 30_000,
+ val unknownLengthTtlSeconds: Long = 600,
+ val refreshLeadSeconds: Long = 60,
+ val toleranceSeconds: Long = 20,
+ )
+
+ private var published: NowPlaying? = null
+ private var publishedExpiration: Long = 0
+
+ suspend fun run(tracks: Flow) {
+ tracks
+ .distinctUntilChanged(::isEquivalent)
+ .collectLatest { track ->
+ if (track == null) stopped() else playing(track)
+ }
+ }
+
+ private fun isEquivalent(
+ old: NowPlaying?,
+ new: NowPlaying?,
+ ): Boolean =
+ when {
+ old == null || new == null -> old == new
+ old.source != new.source || !old.isSameTrack(new) -> false
+ else -> isSameEnd(old.endsAt, new.endsAt)
+ }
+
+ private fun isSameEnd(
+ a: Long?,
+ b: Long?,
+ ) = if (a == null || b == null) a == b else abs(a - b) <= config.toleranceSeconds
+
+ /** The status on relays still shows [track]. */
+ private fun isLive(track: NowPlaying) = published?.isSameTrack(track) == true && publishedExpiration > nowSeconds()
+
+ private suspend fun playing(track: NowPlaying) {
+ if (!isLive(track)) delay(config.startDelayMs)
+
+ while (true) {
+ val now = nowSeconds()
+ val expiration = track.endsAt?.takeIf { it > now } ?: (now + config.unknownLengthTtlSeconds)
+
+ if (!isLive(track) || abs(publishedExpiration - expiration) > config.toleranceSeconds) {
+ send(track, expiration)
+ }
+
+ // A known end needs nothing more: the next track, a seek or a stop comes in as a new value.
+ if (track.endsAt != null) return
+
+ val refreshIn = (publishedExpiration - nowSeconds() - config.refreshLeadSeconds).coerceAtLeast(MIN_REFRESH_SECONDS)
+ delay(refreshIn * 1000)
+ }
+ }
+
+ private suspend fun stopped() {
+ if (published == null) return
+
+ if (publishedExpiration <= nowSeconds()) {
+ // Relays already dropped it.
+ published = null
+ return
+ }
+
+ delay(config.clearDelayMs)
+
+ try {
+ clear()
+ published = null
+ publishedExpiration = 0
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ Log.w("NowPlayingPublisher", "Could not clear the music status", e)
+ }
+ }
+
+ private suspend fun send(
+ track: NowPlaying,
+ expiration: Long,
+ ) {
+ try {
+ publish(track, expiration)
+ published = track
+ publishedExpiration = expiration
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ Log.w("NowPlayingPublisher", "Could not publish the music status", e)
+ }
+ }
+
+ companion object {
+ private const val MIN_REFRESH_SECONDS = 60L
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolver.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolver.kt
new file mode 100644
index 0000000000..8d203fc781
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolver.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying
+
+import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
+import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
+import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
+import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
+import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
+import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
+import com.vitorpamplona.quartz.podcasts.PodcastEpisode
+
+/**
+ * Turns the Nostr event behind an in-app player into a [NowPlaying], or null when the event is
+ * not something to share as a listening status.
+ *
+ * Only music tracks (kind 36787), audio tracks (kind 31337) and podcast episodes (both drafts)
+ * qualify. Everything else the app plays (feed videos, voice messages, live streams) returns null,
+ * so autoplaying a clip never announces itself as "listening to".
+ *
+ * [fallbackTitle] and [fallbackArtist] are what the player shows (the event's title and the
+ * author's display name) and fill the gaps an event leaves.
+ */
+object NowPlayingResolver {
+ /**
+ * Resolves the `nostr:` URI the in-app player carries (`Note.toNostrUri()`) to its event with
+ * [findEvent] (given an event id or an `kind:pubkey:d` address) and then calls [fromEvent].
+ */
+ fun fromNostrUri(
+ uri: String,
+ findEvent: (key: String) -> Event?,
+ fallbackTitle: String? = null,
+ fallbackArtist: String? = null,
+ endsAt: Long? = null,
+ ): NowPlaying? {
+ val key = noteKey(uri) ?: return null
+ val event = findEvent(key) ?: return null
+ return fromEvent(event, fallbackTitle, fallbackArtist, endsAt)
+ }
+
+ /** The cache key (event id, or `kind:pubkey:d` address) a `nostr:` note URI points at. */
+ fun noteKey(uri: String): String? =
+ when (val entity = Nip19Parser.uriToRoute(uri)?.entity) {
+ is NEvent -> entity.hex
+ is NNote -> entity.hex
+ is NAddress -> entity.aTag()
+ else -> null
+ }
+
+ fun fromEvent(
+ event: Event,
+ fallbackTitle: String? = null,
+ fallbackArtist: String? = null,
+ endsAt: Long? = null,
+ ): NowPlaying? {
+ val title: String?
+ val artist: String?
+
+ when (event) {
+ is MusicTrackEvent -> {
+ title = event.title()
+ artist = event.artist() ?: fallbackArtist
+ }
+
+ is AudioTrackEvent -> {
+ title = event.subject()
+ artist = fallbackArtist
+ }
+
+ is PodcastEpisode -> {
+ title = event.episodeTitle()
+ artist = fallbackArtist
+ }
+
+ else -> {
+ return null
+ }
+ }
+
+ val bestTitle = title?.ifBlank { null } ?: fallbackTitle?.ifBlank { null } ?: return null
+
+ return NowPlaying(
+ title = bestTitle,
+ artist = artist?.ifBlank { null },
+ source = NowPlayingSource.InApp,
+ endsAt = endsAt,
+ addressId = (event as? AddressableEvent)?.address()?.toValue(),
+ eventId = if (event is AddressableEvent) null else event.id,
+ )
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingSettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingSettings.kt
new file mode 100644
index 0000000000..95139b35f0
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingSettings.kt
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+
+/**
+ * What this account shares as its NIP-38 `music` status. Everything is off by default: a listening
+ * status is public, so the user opts in.
+ *
+ * - [shareInApp]: tracks and podcast episodes played in Amethyst.
+ * - [shareOtherApps]: whatever another app on this device reports through the OS media controls
+ * (needs a platform permission on Android), except the apps in [blockedApps].
+ * - [knownApps]: the other apps seen playing so far (id to label), listed on the settings screen
+ * so the user can block one.
+ */
+@Immutable
+data class NowPlayingSettings(
+ val shareInApp: Boolean = false,
+ val shareOtherApps: Boolean = false,
+ val blockedApps: Set = emptySet(),
+ val knownApps: Map = emptyMap(),
+) {
+ fun isEnabled() = shareInApp || shareOtherApps
+
+ fun isAllowed(source: NowPlayingSource): Boolean =
+ when (source) {
+ NowPlayingSource.InApp -> shareInApp
+ is NowPlayingSource.OtherApp -> shareOtherApps && source.id !in blockedApps
+ }
+}
+
+/**
+ * Per-account holder for [NowPlayingSettings] (`Account.nowPlayingSettings`). The platform
+ * restores and persists it (Android: `NowPlayingSettingsStore`).
+ */
+@Stable
+class NowPlayingSettingsState {
+ private val state = MutableStateFlow(NowPlayingSettings())
+
+ val flow: StateFlow = state
+
+ fun restore(settings: NowPlayingSettings) = state.update { settings }
+
+ fun setShareInApp(enabled: Boolean) = state.update { it.copy(shareInApp = enabled) }
+
+ fun setShareOtherApps(enabled: Boolean) = state.update { it.copy(shareOtherApps = enabled) }
+
+ fun setAppBlocked(
+ appId: String,
+ blocked: Boolean,
+ ) = state.update { it.copy(blockedApps = if (blocked) it.blockedApps + appId else it.blockedApps - appId) }
+
+ /** Records an app seen playing, so the settings screen can offer to block it. */
+ fun rememberApp(
+ appId: String,
+ label: String,
+ ) {
+ if (state.value.knownApps[appId] == label) return
+ state.update { it.copy(knownApps = it.knownApps + (appId to label)) }
+ }
+
+ fun forgetApp(appId: String) =
+ state.update {
+ it.copy(knownApps = it.knownApps - appId, blockedApps = it.blockedApps - appId)
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingStatusService.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingStatusService.kt
new file mode 100644
index 0000000000..cc82f52b05
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingStatusService.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.onEach
+
+/**
+ * Wires the platform's playback sources to a [NowPlayingPublisher] under one account's
+ * [settings]. [sources] are in priority order: the first one playing an allowed track wins (the
+ * in-app player goes first, since the user is looking at it).
+ *
+ * Every other app seen playing while [NowPlayingSettings.shareOtherApps] is on is reported to
+ * [onOtherAppSeen], so the settings screen can list it and let the user block it.
+ */
+class NowPlayingStatusService(
+ private val settings: StateFlow,
+ private val sources: List>,
+ private val onOtherAppSeen: (NowPlayingSource.OtherApp) -> Unit,
+ private val publisher: NowPlayingPublisher,
+) {
+ suspend fun run() {
+ val observed =
+ sources.map { source ->
+ source.onEach { track ->
+ val app = track?.source as? NowPlayingSource.OtherApp
+ if (app != null && settings.value.shareOtherApps) onOtherAppSeen(app)
+ }
+ }
+
+ val tracks =
+ combine(listOf(settings) + observed) { values ->
+ val current = values[0] as NowPlayingSettings
+ pickNowPlaying(current, *Array(values.size - 1) { values[it + 1] as NowPlaying? })
+ }
+
+ publisher.run(tracks)
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NowPlayingSettingsStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NowPlayingSettingsStore.kt
new file mode 100644
index 0000000000..74da2d9b31
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NowPlayingSettingsStore.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.model.preferences
+
+import androidx.compose.runtime.Stable
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettings
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.utils.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlin.coroutines.cancellation.CancellationException
+
+/**
+ * Per-account persistence for [NowPlayingSettings]. Keys are namespaced by pubkey because the
+ * status is published by that account. Loads the saved settings into [state] on construction, then
+ * writes every later change back. Construct once per account, eagerly.
+ */
+@Stable
+class NowPlayingSettingsStore(
+ private val store: DataStore,
+ private val scope: CoroutineScope,
+ pubKeyHex: HexKey,
+ private val state: NowPlayingSettingsState,
+) {
+ private val shareInApp = booleanPreferencesKey("$KEY_PREFIX$pubKeyHex.shareInApp")
+ private val shareOtherApps = booleanPreferencesKey("$KEY_PREFIX$pubKeyHex.shareOtherApps")
+ private val blockedApps = stringSetPreferencesKey("$KEY_PREFIX$pubKeyHex.blockedApps")
+ private val knownApps = stringSetPreferencesKey("$KEY_PREFIX$pubKeyHex.knownApps")
+
+ init {
+ scope.launch {
+ restoreFromDisk()
+ // drop(1) skips the value present at collection start, which restoreFromDisk already wrote.
+ state.flow.drop(1).collect { persist(it) }
+ }
+ }
+
+ private suspend fun restoreFromDisk() {
+ try {
+ val prefs = store.data.first()
+ state.restore(
+ NowPlayingSettings(
+ shareInApp = prefs[shareInApp] ?: false,
+ shareOtherApps = prefs[shareOtherApps] ?: false,
+ blockedApps = prefs[blockedApps] ?: emptySet(),
+ knownApps = decodeApps(prefs[knownApps] ?: emptySet()),
+ ),
+ )
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ Log.e("NowPlayingSettingsStore") { "Error reading now playing settings: ${e.message}" }
+ }
+ }
+
+ private suspend fun persist(value: NowPlayingSettings) {
+ try {
+ store.edit { prefs ->
+ prefs[shareInApp] = value.shareInApp
+ prefs[shareOtherApps] = value.shareOtherApps
+ prefs[blockedApps] = value.blockedApps
+ prefs[knownApps] = encodeApps(value.knownApps)
+ }
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ Log.e("NowPlayingSettingsStore") { "Error writing now playing settings: ${e.message}" }
+ }
+ }
+
+ companion object {
+ private const val KEY_PREFIX = "nowPlaying."
+ private const val SEPARATOR = '\t'
+
+ fun encodeApps(apps: Map): Set = apps.mapTo(mutableSetOf()) { (id, label) -> "$id$SEPARATOR$label" }
+
+ fun decodeApps(raw: Set): Map =
+ raw
+ .mapNotNull { entry ->
+ val split = entry.indexOf(SEPARATOR)
+ if (split <= 0) null else entry.substring(0, split) to entry.substring(split + 1)
+ }.toMap()
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt
index 823862bf31..27571bebae 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt
@@ -49,7 +49,7 @@ class HtmlParser {
?: bodyBytes.bomCharsetName()
?: HtmlCharsetParser.detectCharset(bodyBytes)
val content = decodeBytes(bodyBytes, name)
- MetaTagsParser.parse(content)
+ MetaTagsParser.parse(content, includeTitleAndIcons = true)
}
private fun ByteArray.bomCharsetName(): String? {
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt
index 62e46ced82..5f4bd4cd4b 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt
@@ -22,8 +22,21 @@ package com.vitorpamplona.amethyst.commons.preview
import com.vitorpamplona.amethyst.commons.util.codePointToChars
+/** The `` element a [MetaTag] was read from. */
+enum class HeadElement {
+ /** A ` ` tag, the only kind [MetaTagsParser.parse] yields unless asked for more. */
+ META,
+
+ /** The document's ``, its text carried as the `content` attribute. */
+ TITLE,
+
+ /** A ` ` whose `rel` names an icon (`icon`, `shortcut icon`, `apple-touch-icon`, …). */
+ LINK,
+}
+
data class MetaTag(
private val attrs: Map,
+ val element: HeadElement = HeadElement.META,
) {
/**
* Returns a value of an attribute specified by its name (case insensitive), or empty string if it doesn't exist.
@@ -43,7 +56,17 @@ object MetaTagsParser {
private const val NO_QUOTE = ' '
private const val META = "meta"
+ private const val LINK = "link"
private const val HEAD = "head"
+ private const val BODY = "body"
+ private const val ICON = "icon"
+ private const val REL = "rel"
+ private const val CONTENT = "content"
+
+ // A `` is only a fallback label, so one that runs on (an unclosed tag swallowing the
+ // page, a spam keyword dump) is cut rather than carried into the preview cache whole.
+ private const val MAX_TITLE_LENGTH = 300
+ private val WHITESPACE_RUN = Regex("\\s+")
// Elements whose content is text rather than markup: script and style hold raw text, title and
// textarea hold character data. A `<` inside any of them is not a tag.
@@ -66,26 +89,99 @@ object MetaTagsParser {
/** The `` that ends the interesting part of the document. */
HEAD_END,
+ /**
+ * A ` ` start tag. `` is optional, so on a page that omits it this is the only
+ * sign the head is over.
+ */
+ BODY_START,
+
+ /** A closed ``: its text is [TagScanner.textStart]..<[TagScanner.textEnd]. */
+ TITLE,
+
+ /** A ` ` whose attribute span mentions `icon`; same span fields as [META]. */
+ LINK,
+
/** Anything else: other elements, comments, declarations, unparseable markup. */
OTHER,
}
/**
* Lazily parse a partial HTML document and extract meta tags.
+ *
+ * With [includeTitleAndIcons], the document's `` and its icon ` `s are yielded
+ * too, tagged by [MetaTag.element]. They are what a page without OpenGraph still offers for a
+ * preview: browsers show exactly these two in a tab.
*/
- fun parse(input: String): Sequence =
+ fun parse(
+ input: String,
+ includeTitleAndIcons: Boolean = false,
+ ): Sequence =
sequence {
val s = TagScanner(input)
+ // Meta tags past an implicit end of head are still read, as they always were. The
+ // title and icons are not: past ``, a `` is an inline SVG's tooltip
+ // ("Close", "Menu"), not the page's name.
+ var inBody = false
while (!s.exhausted()) {
- val kind = s.nextTag()
- if (kind == TagKind.HEAD_END) break
- if (kind == TagKind.META) {
- val attrs = parseAttrs(input, s.attrsStart, s.attrsEnd) ?: continue
- yield(MetaTag(attrs))
+ when (s.nextTag()) {
+ TagKind.HEAD_END -> {
+ break
+ }
+
+ TagKind.BODY_START -> {
+ inBody = true
+ }
+
+ TagKind.META -> {
+ val attrs = parseAttrs(input, s.attrsStart, s.attrsEnd) ?: continue
+ yield(MetaTag(attrs))
+ }
+
+ TagKind.TITLE -> {
+ if (includeTitleAndIcons && !inBody) {
+ val text = titleText(input, s.textStart, s.textEnd)
+ if (text.isNotEmpty()) yield(MetaTag(mapOf(CONTENT to text), HeadElement.TITLE))
+ }
+ }
+
+ TagKind.LINK -> {
+ if (includeTitleAndIcons && !inBody) {
+ val attrs = parseAttrs(input, s.attrsStart, s.attrsEnd) ?: continue
+ if (isIconRel(attrs[REL])) yield(MetaTag(attrs, HeadElement.LINK))
+ }
+ }
+
+ TagKind.OTHER -> {}
}
}
}
+ /**
+ * Whether a `rel` names an icon: one of its space-separated tokens is `icon` or an
+ * `apple-touch-icon` variant. `mask-icon` is deliberately not one -- it is Safari's monochrome
+ * pinned-tab silhouette, which renders as a black blob anywhere else.
+ */
+ private fun isIconRel(rel: String?): Boolean =
+ rel != null &&
+ rel.split(' ', '\t', '\n', '\r', '\u000C').any {
+ it.equals(ICON, ignoreCase = true) || it.startsWith("apple-touch-icon", ignoreCase = true)
+ }
+
+ /** The ``'s character data, with references resolved and whitespace collapsed. */
+ private fun titleText(
+ input: String,
+ from: Int,
+ to: Int,
+ ): String {
+ val raw = input.substring(from, to)
+ val decoded = if (raw.indexOf('&') < 0) raw else raw.replace(Attrs.RE_CHAR_REF, Attrs.Companion::replaceCharRefs)
+ val title = decoded.replace(WHITESPACE_RUN, " ").trim()
+ if (title.length <= MAX_TITLE_LENGTH) return title
+ // Never end on half of a surrogate pair: a lone high surrogate renders as tofu.
+ val cut = if (title[MAX_TITLE_LENGTH - 1].isHighSurrogate()) MAX_TITLE_LENGTH - 1 else MAX_TITLE_LENGTH
+ return title.substring(0, cut)
+ }
+
private class TagScanner(
private val input: String,
) {
@@ -98,6 +194,12 @@ object MetaTagsParser {
var attrsEnd = 0
private set
+ /** Character-data span of the `` [nextTag] last reported as [TagKind.TITLE]. */
+ var textStart = 0
+ private set
+ var textEnd = 0
+ private set
+
fun exhausted(): Boolean = p >= length
/**
@@ -128,6 +230,20 @@ object MetaTagsParser {
p = if (end < 0) length else end + 1
}
+ /** True when `input[from..`, a ` `) down to one comparison.
when (nameEnd - nameStart) {
- META.length -> if (nameIs(nameStart, nameEnd, META)) return TagKind.META
+ META.length -> {
+ if (nameIs(nameStart, nameEnd, META)) return TagKind.META
+ if (nameIs(nameStart, nameEnd, BODY)) return TagKind.BODY_START
+ // Most ` `s are stylesheets and preloads; checking the raw span for `icon`
+ // keeps their attributes from ever being parsed into a map.
+ if (nameIs(nameStart, nameEnd, LINK) && spanContains(attrsStart, attrsEnd, ICON)) return TagKind.LINK
+ }
- STYLE.length ->
+ STYLE.length -> {
if (nameIs(nameStart, nameEnd, STYLE)) {
skipRawText(STYLE_END)
} else if (nameIs(nameStart, nameEnd, TITLE)) {
+ textStart = p
skipRawText(TITLE_END)
+ textEnd = p
+ // An unclosed title runs to the end of the input: that is not a title.
+ if (p < length) return TagKind.TITLE
}
+ }
SCRIPT.length -> if (nameIs(nameStart, nameEnd, SCRIPT)) skipRawText(SCRIPT_END)
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt
index 9e4714756b..3c0dfe3667 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.preview
class OpenGraphParser {
class Result(
+ /** The OpenGraph/Twitter/meta title, or the document's `` when it declares none. */
val title: String,
val description: String,
val image: String,
@@ -35,6 +36,8 @@ class OpenGraphParser {
val videoType: String = "",
/** `og:type` — what the page says it *is*, e.g. `music.song`, `video.other`, `article`. */
val type: String = "",
+ /** The best icon ` ` the page declares, verbatim (may be relative). Empty when none. */
+ val icon: String = "",
)
companion object {
@@ -112,6 +115,17 @@ class OpenGraphParser {
)
private val CONTENT = "content"
+ private val HREF = "href"
+ private val REL = "rel"
+ private val SIZES = "sizes"
+ private val TYPE = "type"
+
+ // How an icon with no `sizes` is ranked. An `apple-touch-icon` is 180px by Apple's
+ // convention; a plain `icon` without sizes is usually the 16/32px tab favicon. A scalable
+ // one (SVG, or `sizes="any"`) beats every bitmap: it is sharp at any card size.
+ private const val APPLE_TOUCH_ICON_DEFAULT_SIZE = 180
+ private const val ICON_DEFAULT_SIZE = 32
+ private const val SCALABLE_ICON_SIZE = Int.MAX_VALUE
}
/** Which field of [Result] a meta tag's key fills, or null when the key is not one we read. */
@@ -156,8 +170,33 @@ class OpenGraphParser {
var video = ""
var videoType = ""
var type = ""
+ var documentTitle = ""
+ var icon = ""
+ var iconSize = -1
metaTags.forEach {
+ when (it.element) {
+ HeadElement.META -> {}
+
+ HeadElement.TITLE -> {
+ if (documentTitle.isEmpty()) documentTitle = it.attr(CONTENT)
+ return@forEach
+ }
+
+ HeadElement.LINK -> {
+ val href = it.attr(HREF)
+ if (href.isNotBlank()) {
+ val size = iconSize(it)
+ // Strictly greater, so among equals the first declared wins.
+ if (size > iconSize) {
+ icon = href
+ iconSize = size
+ }
+ }
+ return@forEach
+ }
+ }
+
// A meta tag names its key in exactly one of these three attributes, but which one
// varies by site, so each is tried in turn until one is a key we read.
val field =
@@ -179,6 +218,32 @@ class OpenGraphParser {
null -> Unit
}
}
- return Result(title, description, image, audio, audioType, video, videoType, type)
+ return Result(title.ifEmpty { documentTitle }, description, image, audio, audioType, video, videoType, type, icon)
+ }
+
+ /** The pixel size an icon ` ` is ranked by: its largest declared size, or a default. */
+ private fun iconSize(link: MetaTag): Int {
+ val sizes = link.attr(SIZES)
+ if (sizes.contains("any", ignoreCase = true)) return SCALABLE_ICON_SIZE
+ if (link.attr(TYPE).equals("image/svg+xml", ignoreCase = true)) return SCALABLE_ICON_SIZE
+ if (link
+ .attr(HREF)
+ .substringBefore('?')
+ .substringBefore('#')
+ .endsWith(".svg", ignoreCase = true)
+ ) {
+ return SCALABLE_ICON_SIZE
+ }
+
+ // `sizes="16x16 32x32"`: rank by the largest, which is the one a decoder will pick.
+ val declared =
+ sizes
+ .split(' ')
+ .mapNotNull { it.lowercase().substringBefore('x', "").toIntOrNull() }
+ .maxOrNull()
+ if (declared != null) return declared
+
+ val isAppleTouch = link.attr(REL).contains("apple-touch-icon", ignoreCase = true)
+ return if (isAppleTouch) APPLE_TOUCH_ICON_DEFAULT_SIZE else ICON_DEFAULT_SIZE
}
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt
index 5cb955845d..4977a6cd67 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt
@@ -43,6 +43,8 @@ class UrlInfoItem(
val videoType: String = "",
/** `og:type` — what the page says it is, e.g. `music.song`, `video.other`, `article`. */
val type: String = "",
+ /** The page's best declared icon ` ` (favicon / apple-touch-icon). Empty when none. */
+ val icon: String = "",
) {
/** The page's host, or null when [url] is not an absolute URL. */
val verifiedHost = absoluteUrlHost(url)
@@ -66,6 +68,24 @@ class UrlInfoItem(
// which is what it did before. Only the playable gate below treats unresolvable as refusal.
val imageUrlFullPath = resolve(image) ?: image
+ /**
+ * The page's icon, absolute: the one it declared, else `/favicon.ico` at its origin -- the
+ * same guess every browser makes, since most sites that declare nothing still serve one.
+ *
+ * A `data:` icon is used as-is when it is an image (the image loader decodes those inline),
+ * and taken at its word when it is not: `href="data:,"` is how a site tells browsers it has no
+ * favicon, so guessing `/favicon.ico` there is a request that is known to miss.
+ *
+ * Null for a non-HTML URL (an image or a video is its own preview) or an unresolvable one.
+ */
+ val iconUrlFullPath: String? =
+ when {
+ !mimeType.startsWith("text/html", ignoreCase = true) -> null
+ icon.startsWith("data:image/", ignoreCase = true) -> if (icon.length <= MAX_INLINE_ICON_LENGTH) icon else resolve(DEFAULT_FAVICON)
+ icon.startsWith("data:", ignoreCase = true) -> null
+ else -> resolve(icon) ?: resolve(DEFAULT_FAVICON)
+ }
+
/**
* The declared `og:video`, absolute, but only when the declaration holds up: a [videoType] the
* renderer can actually play (a `video/` MIME, an `audio/` one, or an HLS playlist type), or
@@ -119,13 +139,29 @@ class UrlInfoItem(
else -> audioType.ifEmpty { null }
}
+ /**
+ * Whether the page has text to show without an image: a title (OpenGraph's or the document's
+ * own ``) or a description. Such a page gets the compact icon + text card rather than
+ * a bare link.
+ */
+ val hasTextPreview: Boolean = title.isNotBlank() || description.isNotBlank()
+
/**
* Whether the fetch produced something worth rendering. An image is the usual evidence, but a
* page that declared playable media counts too — a track page that ships no cover art would
* otherwise be thrown away as Empty and fall back to a bare link, which is exactly the player
- * we went to the trouble of finding.
+ * we went to the trouble of finding. So does a page with no OpenGraph at all that still has a
+ * title or description: it renders as the compact card instead of a bare link.
*/
- fun fetchComplete(): Boolean = url.isNotEmpty() && (image.isNotEmpty() || playableMediaUrl != null)
+ fun fetchComplete(): Boolean = url.isNotEmpty() && (image.isNotEmpty() || playableMediaUrl != null || hasTextPreview)
fun allFetchComplete(): Boolean = title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()
+
+ companion object {
+ private const val DEFAULT_FAVICON = "/favicon.ico"
+
+ // Previews are cached, so an inline icon is held for as long as its card is. Real inline
+ // favicons are a few KB; anything far past that is not worth keeping in memory.
+ private const val MAX_INLINE_ICON_LENGTH = 64 * 1024
+ }
}
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCacheTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCacheTest.kt
new file mode 100644
index 0000000000..d175a52fb8
--- /dev/null
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCacheTest.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses
+
+import com.vitorpamplona.amethyst.commons.model.AddressableNote
+import com.vitorpamplona.amethyst.commons.model.User
+import com.vitorpamplona.amethyst.commons.model.UserContext
+import com.vitorpamplona.quartz.nip01Core.core.Address
+import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class UserStatusCacheTest {
+ private val pubKey = "b".repeat(64)
+ private val sig = "c".repeat(128)
+ private val author = User(pubKey, UserContext { addr -> AddressableNote(addr) })
+
+ private fun status(
+ id: Char,
+ content: String,
+ createdAt: Long,
+ ) = UserStatusEvent(id.toString().repeat(64), pubKey, createdAt, arrayOf(arrayOf("d", UserStatusEvent.MUSIC)), content, sig)
+
+ @Test
+ fun aClearedStatusLeavesTheList() {
+ val cache = UserStatusCache()
+ val note = AddressableNote(Address(UserStatusEvent.KIND, pubKey, UserStatusEvent.MUSIC))
+
+ note.loadEvent(status('1', "Song - Band", 100), author, emptyList())
+ cache.addStatus(note)
+ assertEquals(listOf(note), cache.statuses.value)
+
+ // The same address now holds the newer, blank version: nothing to show any more.
+ note.loadEvent(status('2', "", 200), author, emptyList())
+ cache.addStatus(note)
+ assertEquals(emptyList(), cache.statuses.value)
+
+ // And a new track brings it back.
+ note.loadEvent(status('3', "Other Song - Band", 300), author, emptyList())
+ cache.addStatus(note)
+ assertEquals(listOf(note), cache.statuses.value)
+ }
+}
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisherTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisherTest.kt
new file mode 100644
index 0000000000..c4761cb6b1
--- /dev/null
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisherTest.kt
@@ -0,0 +1,229 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class NowPlayingPublisherTest {
+ private val base = 1_700_000_000L
+
+ private class Recorder {
+ val published = mutableListOf>()
+ var clears = 0
+ }
+
+ private fun TestScope.now() = base + testScheduler.currentTime / 1000
+
+ private fun TestScope.start(source: MutableStateFlow): Recorder {
+ val recorder = Recorder()
+ val publisher =
+ NowPlayingPublisher(
+ publish = { track, expiration -> recorder.published.add(track to expiration) },
+ clear = { recorder.clears++ },
+ nowSeconds = { now() },
+ )
+ backgroundScope.launch { publisher.run(source) }
+ runCurrent()
+ return recorder
+ }
+
+ private fun TestScope.track(
+ title: String,
+ remainingSeconds: Long? = 180,
+ ) = NowPlaying(
+ title = title,
+ artist = "Artist",
+ source = NowPlayingSource.InApp,
+ endsAt = remainingSeconds?.let { now() + it },
+ )
+
+ @Test
+ fun publishesAfterTheStartDelayWithTheTrackEndAsExpiration() =
+ runTest {
+ val source = MutableStateFlow(null)
+ val recorder = start(source)
+
+ val song = track("Song")
+ source.value = song
+ advanceTimeBy(4_000)
+ assertEquals(0, recorder.published.size)
+
+ advanceTimeBy(2_000)
+ assertEquals(1, recorder.published.size)
+ assertEquals(song, recorder.published[0].first)
+ assertEquals(song.endsAt, recorder.published[0].second)
+ }
+
+ @Test
+ fun skippingThroughTracksOnlyPublishesTheOneThatKeepsPlaying() =
+ runTest {
+ val source = MutableStateFlow(null)
+ val recorder = start(source)
+
+ source.value = track("One")
+ advanceTimeBy(2_000)
+ source.value = track("Two")
+ advanceTimeBy(2_000)
+ source.value = track("Three")
+ advanceTimeBy(10_000)
+
+ assertEquals(listOf("Three"), recorder.published.map { it.first.title })
+ }
+
+ @Test
+ fun aShortPauseNeitherClearsNorRepublishes() =
+ runTest {
+ val source = MutableStateFlow(null)
+ val recorder = start(source)
+
+ source.value = track("Song", remainingSeconds = 180)
+ advanceTimeBy(6_000)
+ assertEquals(1, recorder.published.size)
+
+ source.value = null
+ advanceTimeBy(10_000)
+ // Resumed 10s later: the end moved by 10s, within the tolerance.
+ source.value = track("Song", remainingSeconds = 174)
+ advanceTimeBy(60_000)
+
+ assertEquals(0, recorder.clears)
+ assertEquals(1, recorder.published.size)
+ }
+
+ @Test
+ fun stoppingClearsTheStatusAfterTheClearDelay() =
+ runTest {
+ val source = MutableStateFlow(null)
+ val recorder = start(source)
+
+ source.value = track("Song")
+ advanceTimeBy(6_000)
+ source.value = null
+ advanceTimeBy(29_000)
+ assertEquals(0, recorder.clears)
+
+ advanceTimeBy(2_000)
+ assertEquals(1, recorder.clears)
+ }
+
+ @Test
+ fun aStatusThatAlreadyExpiredIsNotCleared() =
+ runTest {
+ val source = MutableStateFlow(null)
+ val recorder = start(source)
+
+ source.value = track("Short", remainingSeconds = 20)
+ advanceTimeBy(30_000)
+ source.value = null
+ advanceTimeBy(120_000)
+
+ assertEquals(1, recorder.published.size)
+ assertEquals(0, recorder.clears)
+ }
+
+ @Test
+ fun seekingRepublishesWithTheNewEnd() =
+ runTest {
+ val source = MutableStateFlow(null)
+ val recorder = start(source)
+
+ source.value = track("Song", remainingSeconds = 180)
+ advanceTimeBy(6_000)
+ val seeked = track("Song", remainingSeconds = 60)
+ source.value = seeked
+ runCurrent()
+
+ assertEquals(2, recorder.published.size)
+ assertEquals(seeked.endsAt, recorder.published[1].second)
+ }
+
+ @Test
+ fun anUnknownLengthIsRefreshedBeforeItExpires() =
+ runTest {
+ val source = MutableStateFlow(null)
+ val recorder = start(source)
+
+ source.value = track("Radio", remainingSeconds = null)
+ advanceTimeBy(6_000)
+ assertEquals(1, recorder.published.size)
+ assertEquals(now() - 1 + 600, recorder.published[0].second)
+
+ // Refreshed a minute before the 10-minute expiration.
+ advanceTimeBy(541_000)
+ assertEquals(2, recorder.published.size)
+ }
+
+ @Test
+ fun pickHonorsSettingsAndTheBlockList() {
+ val inApp = NowPlaying("In app", source = NowPlayingSource.InApp)
+ val spotify = NowPlaying("Other", source = NowPlayingSource.OtherApp("com.spotify.music", "Spotify"))
+
+ assertNull(pickNowPlaying(NowPlayingSettings(), inApp, spotify))
+ assertEquals(inApp, pickNowPlaying(NowPlayingSettings(shareInApp = true, shareOtherApps = true), inApp, spotify))
+ assertEquals(spotify, pickNowPlaying(NowPlayingSettings(shareOtherApps = true), inApp, spotify))
+ assertEquals(spotify, pickNowPlaying(NowPlayingSettings(shareInApp = true, shareOtherApps = true), null, spotify))
+ assertNull(
+ pickNowPlaying(
+ NowPlayingSettings(shareOtherApps = true, blockedApps = setOf("com.spotify.music")),
+ null,
+ spotify,
+ ),
+ )
+ }
+
+ @Test
+ fun statusTextFollowsTheNipExample() {
+ assertEquals("Intergalactic - Beastie Boys", NowPlaying("Intergalactic", "Beastie Boys", NowPlayingSource.InApp).statusText())
+ assertEquals("Intergalactic", NowPlaying("Intergalactic", null, NowPlayingSource.InApp).statusText())
+ }
+
+ @Test
+ fun aPositionTickIsTheSameMomentButASeekIsNot() {
+ val song = NowPlaying("Song", "Band", NowPlayingSource.InApp, endsAt = 1_000)
+
+ assertTrue(song.isSameMoment(song.copy(endsAt = 1_004)))
+ assertFalse(song.isSameMoment(song.copy(endsAt = 1_060)))
+ assertFalse(song.isSameMoment(song.copy(title = "Other")))
+ assertFalse(song.isSameMoment(song.copy(source = NowPlayingSource.OtherApp("x", "X"))))
+ assertFalse(song.isSameMoment(null))
+ assertTrue(song.copy(endsAt = null).isSameMoment(song.copy(endsAt = null)))
+ }
+
+ @Test
+ fun endsAtAccountsForPositionAndSpeed() {
+ assertEquals(1_000L + 60 + 1, NowPlaying.endsAt(1_000, durationMs = 180_000, positionMs = 120_000))
+ assertEquals(1_000L + 30 + 1, NowPlaying.endsAt(1_000, durationMs = 180_000, positionMs = 120_000, speed = 2f))
+ assertNull(NowPlaying.endsAt(1_000, durationMs = null, positionMs = 0))
+ assertNull(NowPlaying.endsAt(1_000, durationMs = 1_000, positionMs = 2_000))
+ }
+}
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolverTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolverTest.kt
new file mode 100644
index 0000000000..d09a83b28f
--- /dev/null
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolverTest.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying
+
+import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
+import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
+import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class NowPlayingResolverTest {
+ private val id = "a".repeat(64)
+ private val pubKey = "b".repeat(64)
+ private val sig = "c".repeat(128)
+
+ @Test
+ fun musicTrackPointsAtItsAddress() {
+ val event =
+ MusicTrackEvent(
+ id,
+ pubKey,
+ 1L,
+ arrayOf(arrayOf("d", "song"), arrayOf("title", "Song"), arrayOf("artist", "Band")),
+ "",
+ sig,
+ )
+
+ val track = NowPlayingResolver.fromEvent(event, fallbackArtist = "Uploader", endsAt = 10L)!!
+
+ assertEquals("Song - Band", track.statusText())
+ assertEquals("${MusicTrackEvent.KIND}:$pubKey:song", track.addressId)
+ assertNull(track.eventId)
+ assertEquals(10L, track.endsAt)
+ assertEquals(NowPlayingSource.InApp, track.source)
+ }
+
+ @Test
+ fun podcastEpisodeUsesTheShowAsArtistAndPointsAtTheEvent() {
+ val event = PodcastEpisodeEvent(id, pubKey, 1L, arrayOf(arrayOf("title", "Episode 1")), "", sig)
+
+ val track = NowPlayingResolver.fromEvent(event, fallbackArtist = "The Show")!!
+
+ assertEquals("Episode 1 - The Show", track.statusText())
+ assertEquals(id, track.eventId)
+ assertNull(track.addressId)
+ }
+
+ @Test
+ fun otherEventsAreNotShared() {
+ val event = TextNoteEvent(id, pubKey, 1L, emptyArray(), "a video", sig)
+
+ assertNull(NowPlayingResolver.fromEvent(event, fallbackTitle = "video", fallbackArtist = "someone"))
+ }
+}
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/TitleAndIconFallbackTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/TitleAndIconFallbackTest.kt
new file mode 100644
index 0000000000..4822fceaa4
--- /dev/null
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/TitleAndIconFallbackTest.kt
@@ -0,0 +1,213 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.preview
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * A page with no OpenGraph still has what a browser tab shows: its `` and its icon
+ * ` `s. These are read only when asked for, and only ever as a fallback.
+ */
+class TitleAndIconFallbackTest {
+ private fun headTags(html: String) = MetaTagsParser.parse(html, includeTitleAndIcons = true).toList()
+
+ private fun extract(html: String) = OpenGraphParser().extractUrlInfo(MetaTagsParser.parse(html, includeTitleAndIcons = true))
+
+ // Trimmed from the nsite that prompted this: no og:*, no twitter:*, only a title, a meta
+ // description and an SVG favicon.
+ private val noOpenGraphPage =
+ """
+ |
+ |
+ |
+ |
+ |
+ |Private Provider — Confidential AI
+ |
+ |
+ |
+ |
+ | Hi
+ |
+ """.trimMargin()
+
+ @Test
+ fun aPageWithoutOpenGraphStillYieldsTitleDescriptionAndIcon() {
+ val info = extract(noOpenGraphPage)
+
+ assertEquals("Private Provider — Confidential AI", info.title)
+ assertEquals("A phone-first AI workspace for Android.", info.description)
+ assertEquals("favicon.svg", info.icon)
+ assertEquals("", info.image)
+ }
+
+ @Test
+ fun theDefaultParseStillYieldsOnlyMetaTags() {
+ val tags = MetaTagsParser.parse(noOpenGraphPage).toList()
+
+ assertEquals(3, tags.size)
+ assertTrue(tags.all { it.element == HeadElement.META })
+ }
+
+ @Test
+ fun onlyIconLinksAreYielded() {
+ val tags = headTags(noOpenGraphPage)
+
+ val links = tags.filter { it.element == HeadElement.LINK }
+ assertEquals(1, links.size)
+ assertEquals("favicon.svg", links[0].attr("href"))
+ }
+
+ @Test
+ fun openGraphTitleWinsOverTheDocumentTitle() {
+ val info =
+ extract(
+ """
+ |
+ | Document Title
+ |
+ |
+ """.trimMargin(),
+ )
+
+ assertEquals("OG Title", info.title)
+ }
+
+ @Test
+ fun titleWhitespaceIsCollapsed() {
+ val info = extract("\n Spread\n\t Out ")
+
+ assertEquals("Spread Out", info.title)
+ }
+
+ @Test
+ fun anUnclosedTitleIsNotATitle() {
+ val info = extract("runs to the end of the truncated body")
+
+ assertEquals("", info.title)
+ }
+
+ @Test
+ fun aTitleOutsideTheHeadIsIgnored() {
+ // An SVG in the body carries its own ; the scan stops at before it.
+ val info = extract(" Close ")
+
+ assertEquals("", info.title)
+ }
+
+ @Test
+ fun theFirstTitleWins() {
+ val info = extract("First Second ")
+
+ assertEquals("First", info.title)
+ }
+
+ @Test
+ fun theLargestIconWins() {
+ val info =
+ extract(
+ """
+ |
+ |
+ |
+ |
+ |
+ |
+ """.trimMargin(),
+ )
+
+ assertEquals("/apple-touch-icon.png", info.icon)
+ }
+
+ @Test
+ fun aScalableIconBeatsBitmaps() {
+ val info =
+ extract(
+ """
+ |
+ |
+ |
+ |
+ """.trimMargin(),
+ )
+
+ assertEquals("/icon.svg?v=2", info.icon)
+ }
+
+ @Test
+ fun maskIconIsNotUsed() {
+ // Safari's monochrome pinned-tab silhouette renders as a black blob anywhere else.
+ val info =
+ extract(
+ """
+ |
+ |
+ |
+ |
+ """.trimMargin(),
+ )
+
+ assertEquals("/favicon-32.png", info.icon)
+ }
+
+ @Test
+ fun iconLinksAfterTheHeadAreIgnored() {
+ val info = extract(""" """)
+
+ assertEquals("", info.icon)
+ }
+
+ @Test
+ fun aBodyTitleIsIgnoredWhenTheHeadIsNeverClosed() {
+ // `` is optional: `` closes the head implicitly. An inline SVG's
+ // ("Close", "Menu") is the classic thing to then mistake for the page title.
+ val info = extract(""" Close """)
+
+ assertEquals("", info.title)
+ assertEquals("D", info.description)
+ }
+
+ @Test
+ fun aBodyIconLinkIsIgnoredWhenTheHeadIsNeverClosed() {
+ val info = extract("""T """)
+
+ assertEquals("T", info.title)
+ assertEquals("", info.icon)
+ }
+
+ @Test
+ fun aTitleWithAttributesIsRead() {
+ val info = extract("""Helmet """)
+
+ assertEquals("Helmet", info.title)
+ }
+
+ @Test
+ fun aLongTitleIsNotCutInsideASurrogatePair() {
+ // 299 ASCII chars then an emoji: a plain take(300) keeps only its high surrogate.
+ val info = extract("" + "a".repeat(299) + "\uD83D\uDE00 tail ")
+
+ assertTrue(info.title.isNotEmpty())
+ assertTrue(!info.title.last().isHighSurrogate(), "title ends in a dangling high surrogate")
+ }
+}
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt
index 78a918541f..3410eec81c 100644
--- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt
@@ -77,6 +77,7 @@ class UrlPreview {
data.video,
data.videoType,
data.type,
+ data.icon,
)
}
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolution.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolution.jvmAndroid.kt
index bda5b0c2a9..b27eb59efe 100644
--- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolution.jvmAndroid.kt
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolution.jvmAndroid.kt
@@ -29,7 +29,7 @@ actual fun resolveHttpUrl(
reference: String,
): String? =
runCatching {
- val baseUri = base?.let { runCatching { URI(it).toURL().toURI() }.getOrNull() }
+ val baseUri = base?.let { runCatching { URI(it).toURL().toURI().withRootPath() }.getOrNull() }
val resolved = if (baseUri != null) baseUri.resolve(reference) else URI(reference)
if (!resolved.scheme.equals("http", ignoreCase = true) &&
!resolved.scheme.equals("https", ignoreCase = true)
@@ -39,3 +39,23 @@ actual fun resolveHttpUrl(
resolved.toURL().toString()
}
}.getOrNull()
+
+/**
+ * `https://host` as `https://host/`. RFC 3986 resolves a relative reference against an empty base path
+ * as if the path were `/`, and the JDK does, but Android's `java.net.URI` does not: it glued
+ * `y18.svg` onto `https://news.ycombinator.com` as `https://news.ycombinator.comy18.svg`, so the
+ * page's icon (or a relative og:image) pointed at a host that does not exist.
+ */
+private fun URI.withRootPath(): URI =
+ if (isOpaque || !rawPath.isNullOrEmpty() || rawAuthority == null) {
+ this
+ } else {
+ // From the raw (still-encoded) parts: the multi-argument constructor would encode them again.
+ URI(
+ buildString {
+ append(scheme).append("://").append(rawAuthority).append('/')
+ rawQuery?.let { append('?').append(it) }
+ rawFragment?.let { append('#').append(it) }
+ },
+ )
+ }
diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NowPlayingSettingsStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NowPlayingSettingsStoreTest.kt
new file mode 100644
index 0000000000..e133edfa5a
--- /dev/null
+++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NowPlayingSettingsStoreTest.kt
@@ -0,0 +1,106 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.model.preferences
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettings
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import okio.Path.Companion.toOkioPath
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.File
+
+class NowPlayingSettingsStoreTest {
+ @get:Rule
+ val folder = TemporaryFolder()
+
+ private fun raw(): DataStore {
+ val file = File(folder.root, "shared.preferences_pb")
+ return PreferenceDataStoreFactory.createWithPath(
+ scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
+ produceFile = { file.toOkioPath() },
+ )
+ }
+
+ @Test
+ fun appLabelsSurviveTheEncoding() {
+ val apps = mapOf("com.spotify.music" to "Spotify", "org.mpris.MediaPlayer2.vlc" to "VLC media player")
+
+ assertEquals(apps, NowPlayingSettingsStore.decodeApps(NowPlayingSettingsStore.encodeApps(apps)))
+ assertEquals(emptyMap(), NowPlayingSettingsStore.decodeApps(setOf("no-separator", "\tno-id")))
+ }
+
+ @Test
+ fun settingsAreWrittenAndRestoredPerAccount() =
+ runBlocking {
+ val store = raw()
+ val expected =
+ NowPlayingSettings(
+ shareInApp = true,
+ shareOtherApps = true,
+ blockedApps = setOf("com.google.android.youtube"),
+ knownApps = mapOf("com.google.android.youtube" to "YouTube", "com.spotify.music" to "Spotify"),
+ )
+
+ val writerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ val written = NowPlayingSettingsState()
+ NowPlayingSettingsStore(store, writerScope, "alice", written)
+ // Let the store finish its initial restore before changing anything.
+ withTimeout(5_000) { store.data.first() }
+ Thread.sleep(200)
+ written.setShareInApp(true)
+ written.setShareOtherApps(true)
+ written.rememberApp("com.google.android.youtube", "YouTube")
+ written.rememberApp("com.spotify.music", "Spotify")
+ written.setAppBlocked("com.google.android.youtube", true)
+
+ withTimeout(5_000) {
+ // The block is the last change made, so once it is on disk everything is.
+ while (store.data.first()[stringSetPreferencesKey("nowPlaying.alice.blockedApps")].isNullOrEmpty()) Thread.sleep(20)
+ }
+ writerScope.cancel()
+
+ val readerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ val restored = NowPlayingSettingsState()
+ NowPlayingSettingsStore(store, readerScope, "alice", restored)
+ withTimeout(5_000) { restored.flow.first { it.isEnabled() } }
+ assertEquals(expected, restored.flow.value)
+
+ // Another account on the same file starts from the defaults.
+ val other = NowPlayingSettingsState()
+ NowPlayingSettingsStore(store, readerScope, "bob", other)
+ Thread.sleep(200)
+ assertEquals(NowPlayingSettings(), other.flow.value)
+ readerScope.cancel()
+ }
+}
diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItemTextPreviewTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItemTextPreviewTest.kt
new file mode 100644
index 0000000000..5998927fbe
--- /dev/null
+++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItemTextPreviewTest.kt
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.preview
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * A page with no OpenGraph image is still worth a card when it has text: the compact icon + text
+ * card, built from its title, description and favicon.
+ */
+class UrlInfoItemTextPreviewTest {
+ private fun page(
+ url: String = "https://example.nsite.lol/",
+ title: String = "",
+ description: String = "",
+ image: String = "",
+ icon: String = "",
+ mimeType: String = "text/html; charset=utf-8",
+ ) = UrlInfoItem(url = url, title = title, description = description, image = image, mimeType = mimeType, icon = icon)
+
+ @Test
+ fun aTitleAloneIsEnoughToKeepThePreview() {
+ assertTrue(page(title = "Private Provider").fetchComplete())
+ }
+
+ @Test
+ fun aDescriptionAloneIsEnoughToKeepThePreview() {
+ assertTrue(page(description = "A workspace").fetchComplete())
+ }
+
+ @Test
+ fun aPageWithNothingStaysEmpty() {
+ assertFalse(page().fetchComplete())
+ assertFalse(page(title = " ").fetchComplete())
+ }
+
+ @Test
+ fun aRelativeIconIsResolvedAgainstThePage() {
+ assertEquals(
+ "https://example.nsite.lol/docs/favicon.svg",
+ page(url = "https://example.nsite.lol/docs/index.html", icon = "favicon.svg").iconUrlFullPath,
+ )
+ }
+
+ @Test
+ fun noDeclaredIconFallsBackToTheOriginFavicon() {
+ assertEquals(
+ "https://example.nsite.lol/favicon.ico",
+ page(url = "https://example.nsite.lol/a/b/c.html").iconUrlFullPath,
+ )
+ }
+
+ @Test
+ fun aNonHttpIconFallsBackToTheOriginFavicon() {
+ assertEquals(
+ "https://example.nsite.lol/favicon.ico",
+ page(icon = "file:///etc/passwd").iconUrlFullPath,
+ )
+ }
+
+ @Test
+ fun nonHtmlUrlsHaveNoIcon() {
+ assertNull(page(url = "https://example.com/a.png", image = "https://example.com/a.png", mimeType = "image/png").iconUrlFullPath)
+ }
+
+ @Test
+ fun anUppercaseHtmlMimeStillGetsAnIcon() {
+ // UrlPreview stores MediaType.toString(), which keeps the server's spelling.
+ assertEquals(
+ "https://example.nsite.lol/favicon.ico",
+ page(mimeType = "Text/HTML; charset=UTF-8").iconUrlFullPath,
+ )
+ }
+
+ @Test
+ fun anEmptyDataIconMeansTheSiteHasNoIcon() {
+ // ` ` is the common way to tell browsers not to request
+ // /favicon.ico. Requesting it anyway is a guaranteed miss.
+ assertNull(page(icon = "data:,").iconUrlFullPath)
+ }
+
+ @Test
+ fun anInlineImageIconIsUsedAsIs() {
+ val inline = "data:image/png;base64,iVBORw0KGgo="
+ assertEquals(inline, page(icon = inline).iconUrlFullPath)
+ }
+}
diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/MetaTagsParserBenchmark.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/MetaTagsParserBenchmark.kt
index 228498fdf1..2a2d0f70db 100644
--- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/MetaTagsParserBenchmark.kt
+++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/MetaTagsParserBenchmark.kt
@@ -160,6 +160,19 @@ class MetaTagsParserBenchmark {
}
bench("no , whole doc", open, 2_000) { MetaTagsParser.parse(it).count() }
+ // What production runs (HtmlParser asks for the and icon s too). Should sit
+ // on top of the rows above: the 60 preload links never reach the attribute parser.
+ bench("spa head, +title/icons", spa, 50_000) { MetaTagsParser.parse(it, includeTitleAndIcons = true).count() }
+ bench("spa head, og: + fallbacks", spa, 50_000) {
+ OpenGraphParser().extractUrlInfo(MetaTagsParser.parse(it, includeTitleAndIcons = true)).title.length
+ }
+ bench("heavy head, +title/icons", heavy, 2_000) { MetaTagsParser.parse(it, includeTitleAndIcons = true).count() }
+ bench("no , +title/icons", open, 2_000) { MetaTagsParser.parse(it, includeTitleAndIcons = true).count() }
+
+ val spaFallbacks = OpenGraphParser().extractUrlInfo(MetaTagsParser.parse(spa, includeTitleAndIcons = true))
+ assertEquals("Example — Your Network. Your Rules.", spaFallbacks.title)
+ assertEquals("/favicon.svg", spaFallbacks.icon)
+
assertTrue(MetaTagsParser.parse(open).count() >= 64)
}
}
diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolutionTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolutionTest.kt
new file mode 100644
index 0000000000..353aacd5d1
--- /dev/null
+++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolutionTest.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.util
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+/**
+ * A relative reference against a base with no path resolves as if the path were `/` (RFC 3986).
+ * Android's `java.net.URI` got this wrong — `y18.svg` against `https://news.ycombinator.com` became
+ * `https://news.ycombinator.comy18.svg` — so the base is normalized before resolving.
+ */
+class HttpUrlResolutionTest {
+ @Test
+ fun relativeReferenceAgainstAPathlessBase() {
+ assertEquals("https://news.ycombinator.com/y18.svg", resolveHttpUrl("https://news.ycombinator.com", "y18.svg"))
+ assertEquals("https://news.ycombinator.com/favicon.ico", resolveHttpUrl("https://news.ycombinator.com", "/favicon.ico"))
+ }
+
+ @Test
+ fun pathlessBaseKeepsItsQueryAndEncoding() {
+ assertEquals("https://a.example/icon.png", resolveHttpUrl("https://a.example?q=a%20b", "icon.png"))
+ // Encoded characters are carried over as they are, not encoded a second time.
+ assertEquals("https://a.example/icons/a%20b.png", resolveHttpUrl("https://a.example?q=a%20b", "icons/a%20b.png"))
+ }
+
+ @Test
+ fun basesWithAPathAreUnchanged() {
+ assertEquals("https://a.example/dir/icon.png", resolveHttpUrl("https://a.example/dir/page.html", "icon.png"))
+ assertEquals("https://cdn.example/x.png", resolveHttpUrl("https://a.example/dir/", "//cdn.example/x.png"))
+ }
+
+ @Test
+ fun nonHttpStaysRefused() {
+ assertNull(resolveHttpUrl("https://a.example", "file:///etc/passwd"))
+ }
+}
diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml
index 0bcd36e69f..0ce1c6a5de 100644
--- a/commonsUI/src/commonMain/composeResources/values/strings.xml
+++ b/commonsUI/src/commonMain/composeResources/values/strings.xml
@@ -6362,6 +6362,27 @@
This file type can run code. Check that the name matches what you meant to get.
Save
From %1$s
+ Now Playing
+ music listening status song podcast spotify nip38 share playing
+ Your status is public. It appears under your name and disappears when the track ends or you stop playing.
+ How others see it
+ Song title - Artist
+ Live
+ Example
+ Off
+ You
+ Share from
+ Amethyst
+ Music tracks and podcast episodes you play here
+ Other apps
+ Spotify, YouTube Music, podcast players and any app with media controls
+ Notification access needed
+ Android only shows other apps’ media controls to apps with notification access. Amethyst reads what is playing, never the content of your notifications.
+ Grant access
+ Manage
+ Notification access is on
+ Apps
+ Apps show up here after they play something. Turn one off to stop sharing it.
Prediction market
Open
Closed
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewCard.kt
index a084cb0edf..66853c6bac 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewCard.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewCard.kt
@@ -20,26 +20,37 @@
*/
package com.vitorpamplona.amethyst.commons.ui.components
+import androidx.collection.LruCache
import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
+import coil3.network.HttpException
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem
@@ -50,9 +61,12 @@ import com.vitorpamplona.amethyst.commons.resources.link_actions_dialog_title
import com.vitorpamplona.amethyst.commons.resources.url_preview_open_in_browser
import com.vitorpamplona.amethyst.commons.ui.components.util.setText
import com.vitorpamplona.amethyst.commons.ui.stringRes
+import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.commons.ui.theme.Size14Modifier
+import com.vitorpamplona.amethyst.commons.ui.theme.Size24Modifier
+import com.vitorpamplona.amethyst.commons.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.commons.ui.theme.innerPostModifier
import com.vitorpamplona.amethyst.commons.ui.theme.previewCardImageModifier
import kotlinx.coroutines.launch
@@ -101,22 +115,50 @@ fun UrlPreviewCard(
}
}
- Column(
- modifier =
- MaterialTheme.colorScheme.innerPostModifier
- .combinedClickable(
- onClick = {
- if (onCardClick != null) {
- onCardClick()
- } else {
- runCatching { uri.openUri(url) }
- }
- },
- onLongClick = {
- popupExpanded.value = true
- },
- ),
- ) {
+ val cardModifier =
+ MaterialTheme.colorScheme.innerPostModifier
+ .combinedClickable(
+ onClick = {
+ if (onCardClick != null) {
+ onCardClick()
+ } else {
+ runCatching { uri.openUri(url) }
+ }
+ },
+ onLongClick = {
+ popupExpanded.value = true
+ },
+ )
+
+ // Only meaningful when the card's own tap does something else (e.g. opening the comment
+ // thread); otherwise it would duplicate the card's open-in-browser tap.
+ val hasCardClick = onCardClick != null
+ val onOpenInBrowser: (() -> Unit)? =
+ remember(hasCardClick, url, uri) {
+ if (hasCardClick) {
+ { runCatching { uri.openUri(url) } }
+ } else {
+ null
+ }
+ }
+
+ // A page with no picture to lead with -- most often one with no OpenGraph at all, previewed
+ // from its ``, meta description and favicon -- gets the short, wide card. Painting the
+ // big layout without its image would leave a text block that only looks broken.
+ if (previewInfo.imageUrlFullPath.isBlank() && previewInfo.hasTextPreview) {
+ CompactUrlPreviewCard(previewInfo, cardModifier, onOpenInBrowser)
+ } else {
+ LargeUrlPreviewCard(previewInfo, cardModifier, onOpenInBrowser)
+ }
+}
+
+@Composable
+private fun LargeUrlPreviewCard(
+ previewInfo: UrlInfoItem,
+ modifier: Modifier,
+ onOpenInBrowser: (() -> Unit)?,
+) {
+ Column(modifier = modifier) {
// A Loaded preview no longer implies an image: a player page that ships no cover art is
// kept (it has media to play), and painting its empty string left a blank 180dp box.
if (previewInfo.imageUrlFullPath.isNotBlank()) {
@@ -141,20 +183,7 @@ fun UrlPreviewCard(
overflow = TextOverflow.Ellipsis,
)
- // Only meaningful when the card's own tap does something else (e.g. opening the
- // comment thread); otherwise it would duplicate the card's open-in-browser tap.
- if (onCardClick != null) {
- IconButton(
- onClick = { runCatching { uri.openUri(url) } },
- ) {
- Icon(
- symbol = MaterialSymbols.AutoMirrored.OpenInNew,
- contentDescription = stringRes(Res.string.url_preview_open_in_browser),
- modifier = Size14Modifier,
- tint = Color.Gray,
- )
- }
- }
+ onOpenInBrowser?.let { OpenInBrowserButton(it) }
}
Text(
@@ -177,3 +206,121 @@ fun UrlPreviewCard(
Spacer(modifier = DoubleVertSpacer)
}
}
+
+/**
+ * The short, wide card: the site's icon on the left, its title, description and host on the
+ * right. Built from what every page has even without OpenGraph -- what a browser tab shows.
+ */
+@Composable
+private fun CompactUrlPreviewCard(
+ previewInfo: UrlInfoItem,
+ modifier: Modifier,
+ onOpenInBrowser: (() -> Unit)?,
+) {
+ val host = previewInfo.verifiedHost ?: previewInfo.url
+
+ Row(
+ modifier = modifier.padding(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ UrlPreviewIcon(previewInfo.iconUrlFullPath, host)
+
+ Spacer(modifier = DoubleHorzSpacer)
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = previewInfo.title.ifBlank { host },
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+
+ if (previewInfo.description.isNotBlank()) {
+ Text(
+ text = previewInfo.description,
+ style = MaterialTheme.typography.bodySmall,
+ color = Color.Gray,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+
+ // When there is no title the host already stands in for it on the first line.
+ if (previewInfo.title.isNotBlank()) {
+ Text(
+ text = host,
+ style = MaterialTheme.typography.bodySmall,
+ color = Color.Gray,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+
+ onOpenInBrowser?.let { OpenInBrowserButton(it) }
+ }
+}
+
+/**
+ * Icon URLs the server refused (404 and the like) in this process. The image loader caches
+ * successes but not failures, so without this a site with no `/favicon.ico` would be asked for it
+ * again -- and 404 again -- every time its card scrolled back into view.
+ */
+private val failedIconUrls = LruCache(200)
+
+/**
+ * The site's icon in a fixed square. A generic globe sits there until the icon actually loads,
+ * so a site whose favicon is missing (the `/favicon.ico` guess is only a guess) or undecodable
+ * still gets a tidy card instead of an empty hole.
+ */
+@Composable
+private fun UrlPreviewIcon(
+ iconUrl: String?,
+ contentDescription: String,
+) {
+ Box(
+ modifier =
+ UrlPreviewIconModifier
+ .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ var loaded by remember(iconUrl) { mutableStateOf(false) }
+
+ if (!loaded) {
+ Icon(
+ symbol = MaterialSymbols.Language,
+ contentDescription = null,
+ modifier = Size24Modifier,
+ tint = Color.Gray,
+ )
+ }
+
+ if (iconUrl != null && failedIconUrls[iconUrl] == null) {
+ AsyncImage(
+ model = iconUrl,
+ contentDescription = contentDescription,
+ contentScale = ContentScale.Fit,
+ modifier = UrlPreviewIconImageModifier,
+ onSuccess = { loaded = true },
+ // Only a server's answer is remembered. A network error (offline, a timeout) says
+ // nothing about the icon and must be retried once the connection is back.
+ onError = { if (it.result.throwable is HttpException) failedIconUrls.put(iconUrl, Unit) },
+ )
+ }
+ }
+}
+
+@Composable
+private fun OpenInBrowserButton(onClick: () -> Unit) {
+ IconButton(onClick = onClick) {
+ Icon(
+ symbol = MaterialSymbols.AutoMirrored.OpenInNew,
+ contentDescription = stringRes(Res.string.url_preview_open_in_browser),
+ modifier = Size14Modifier,
+ tint = Color.Gray,
+ )
+ }
+}
+
+private val UrlPreviewIconModifier = Modifier.size(48.dp).clip(SmallBorder)
+private val UrlPreviewIconImageModifier = Modifier.fillMaxSize().padding(6.dp)
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/previews/PreviewUrl.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/previews/PreviewUrl.kt
index a92fd8c1b4..5cf4b69cab 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/previews/PreviewUrl.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/creators/previews/PreviewUrl.kt
@@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -38,6 +39,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
@@ -225,11 +227,13 @@ private fun MyLoadUrlPreviewDirect(
)
} else {
Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier.aspectRatio(1f)) {
+ // A page with no OpenGraph image still has its icon: shown whole, not cropped.
+ val hasImage = state.previewInfo.imageUrlFullPath.isNotBlank()
AsyncImage(
- model = state.previewInfo.imageUrlFullPath,
+ model = if (hasImage) state.previewInfo.imageUrlFullPath else state.previewInfo.iconUrlFullPath,
contentDescription = state.previewInfo.title,
- contentScale = ContentScale.Crop,
- modifier = Modifier.fillMaxSize(),
+ contentScale = if (hasImage) ContentScale.Crop else ContentScale.Fit,
+ modifier = if (hasImage) Modifier.fillMaxSize() else Modifier.fillMaxSize().padding(20.dp),
)
Text(
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsScreen.kt
new file mode 100644
index 0000000000..8dc824086f
--- /dev/null
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsScreen.kt
@@ -0,0 +1,559 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
+import com.vitorpamplona.amethyst.commons.hashtags.Tunestr
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+import com.vitorpamplona.amethyst.commons.model.AddressableNote
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingAccess
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState
+import com.vitorpamplona.amethyst.commons.relayClient.event.observeNote
+import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserName
+import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserStatuses
+import com.vitorpamplona.amethyst.commons.resources.Res
+import com.vitorpamplona.amethyst.commons.resources.now_playing_access_granted
+import com.vitorpamplona.amethyst.commons.resources.now_playing_access_needed
+import com.vitorpamplona.amethyst.commons.resources.now_playing_access_needed_title
+import com.vitorpamplona.amethyst.commons.resources.now_playing_apps
+import com.vitorpamplona.amethyst.commons.resources.now_playing_apps_empty
+import com.vitorpamplona.amethyst.commons.resources.now_playing_description
+import com.vitorpamplona.amethyst.commons.resources.now_playing_grant_access
+import com.vitorpamplona.amethyst.commons.resources.now_playing_manage_access
+import com.vitorpamplona.amethyst.commons.resources.now_playing_preview_example
+import com.vitorpamplona.amethyst.commons.resources.now_playing_preview_live
+import com.vitorpamplona.amethyst.commons.resources.now_playing_preview_off
+import com.vitorpamplona.amethyst.commons.resources.now_playing_preview_sample
+import com.vitorpamplona.amethyst.commons.resources.now_playing_preview_title
+import com.vitorpamplona.amethyst.commons.resources.now_playing_preview_you
+import com.vitorpamplona.amethyst.commons.resources.now_playing_section_sources
+import com.vitorpamplona.amethyst.commons.resources.now_playing_settings
+import com.vitorpamplona.amethyst.commons.resources.now_playing_share_in_app
+import com.vitorpamplona.amethyst.commons.resources.now_playing_share_in_app_description
+import com.vitorpamplona.amethyst.commons.resources.now_playing_share_other_apps
+import com.vitorpamplona.amethyst.commons.resources.now_playing_share_other_apps_description
+import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
+import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
+import com.vitorpamplona.amethyst.commons.ui.note.ClickableUserPicture
+import com.vitorpamplona.amethyst.commons.ui.stringRes
+import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
+import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
+import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent
+import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
+
+/**
+ * Settings for sharing what the user listens to as their NIP-38 music status.
+ *
+ * [access] is the platform permission other apps' playback needs (Android: notification access);
+ * null where none is needed. [appIcon] draws a real app icon where the platform can (Android);
+ * without it each app gets a colored monogram.
+ */
+@Composable
+fun NowPlayingSettingsScreen(
+ accountViewModel: AccountViewModel,
+ nav: INav,
+ access: NowPlayingAccess? = null,
+ appIcon: (@Composable (appId: String, label: String) -> Unit)? = null,
+) {
+ Scaffold(
+ topBar = {
+ TopBarWithBackButton(stringRes(Res.string.now_playing_settings), nav)
+ },
+ ) { padding ->
+ Column(
+ modifier =
+ Modifier
+ .padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ ) {
+ NowPlayingSettingsContent(
+ state = accountViewModel.account.nowPlayingSettings,
+ access = access,
+ appIcon = appIcon,
+ preview = { isOn -> AccountStatusPreview(accountViewModel, nav, isOn) },
+ )
+ }
+ }
+}
+
+/**
+ * The body of the settings screen: a preview of the status as others see it, the two sources,
+ * notification access, and the per-app list. It does not scroll, so front ends with their own
+ * settings layout (desktop) can embed it. [showInApp] hides the in-app source where the app has
+ * no music or podcast player of its own; [preview] replaces the anonymous preview card with one
+ * showing the account (it receives whether sharing is on at all).
+ */
+@Composable
+fun NowPlayingSettingsContent(
+ state: NowPlayingSettingsState,
+ access: NowPlayingAccess?,
+ modifier: Modifier = Modifier,
+ showInApp: Boolean = true,
+ appIcon: (@Composable (appId: String, label: String) -> Unit)? = null,
+ preview: @Composable (isOn: Boolean) -> Unit = { isOn ->
+ NowPlayingPreviewCard(
+ avatar = { MonogramBadge(stringRes(Res.string.now_playing_preview_you), size = 44, shape = CircleShape) },
+ name = stringRes(Res.string.now_playing_preview_you),
+ liveStatus = null,
+ isOn = isOn,
+ )
+ },
+) {
+ val settings by state.flow.collectAsState()
+ val isOn = (showInApp && settings.shareInApp) || settings.shareOtherApps
+ // One check per resume for both the confirmation row and the alert card.
+ val accessGranted = access == null || rememberAccessGranted(access)
+
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ SectionTitle(stringRes(Res.string.now_playing_preview_title))
+ preview(isOn)
+ Text(
+ text = stringRes(Res.string.now_playing_description),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 4.dp),
+ )
+ }
+
+ SettingsSection(Res.string.now_playing_section_sources) {
+ if (showInApp) {
+ SettingsSwitchTile(
+ icon = MaterialSymbols.Headphones,
+ title = Res.string.now_playing_share_in_app,
+ description = Res.string.now_playing_share_in_app_description,
+ checked = settings.shareInApp,
+ onCheckedChange = state::setShareInApp,
+ )
+ SettingsDivider()
+ }
+ SettingsSwitchTile(
+ icon = MaterialSymbols.Apps,
+ title = Res.string.now_playing_share_other_apps,
+ description = Res.string.now_playing_share_other_apps_description,
+ checked = settings.shareOtherApps,
+ onCheckedChange = state::setShareOtherApps,
+ )
+ if (settings.shareOtherApps && access != null && accessGranted) {
+ AccessGrantedRow(access)
+ }
+ }
+
+ if (settings.shareOtherApps && access != null && !accessGranted) {
+ AccessNeededCard(access)
+ }
+
+ if (settings.shareOtherApps) {
+ SettingsSection(Res.string.now_playing_apps) {
+ if (settings.knownApps.isEmpty()) {
+ Text(
+ text = stringRes(Res.string.now_playing_apps_empty),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp),
+ )
+ } else {
+ settings.knownApps.entries
+ .sortedBy { it.value.lowercase() }
+ .forEachIndexed { index, (id, label) ->
+ if (index > 0) SettingsDivider()
+ AppToggleRow(
+ id = id,
+ label = label,
+ checked = id !in settings.blockedApps,
+ appIcon = appIcon,
+ onCheckedChange = { allowed -> state.setAppBlocked(id, !allowed) },
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+/** The account's own avatar, name and live music status, exactly as other users see them. */
+@Composable
+private fun AccountStatusPreview(
+ accountViewModel: AccountViewModel,
+ nav: INav,
+ isOn: Boolean,
+) {
+ val user = remember(accountViewModel) { accountViewModel.account.userProfile() }
+ val name by observeUserName(user, accountViewModel)
+ val statuses by observeUserStatuses(user, accountViewModel)
+ val music = statuses.firstOrNull { it.address.dTag == UserStatusEvent.MUSIC }
+
+ val avatar = @Composable { ClickableUserPicture(user, 44.dp, accountViewModel) }
+
+ if (music == null) {
+ NowPlayingPreviewCard(avatar, name, liveStatus = null, isOn = isOn)
+ } else {
+ LiveMusicStatus(music, accountViewModel) { text ->
+ NowPlayingPreviewCard(avatar, name, liveStatus = text, isOn = isOn)
+ }
+ }
+}
+
+@Composable
+private fun LiveMusicStatus(
+ note: AddressableNote,
+ accountViewModel: AccountViewModel,
+ content: @Composable (String?) -> Unit,
+) {
+ val noteState by observeNote(note, accountViewModel)
+ content(
+ noteState.note.event
+ ?.content
+ ?.ifBlank { null },
+ )
+}
+
+/**
+ * A miniature of how the status renders under the user's name in feeds: the avatar, the name and
+ * the music line with the same icon and color the real status uses. Shows [liveStatus] when one
+ * is published, otherwise an example, dimmed while sharing is off.
+ */
+@Composable
+fun NowPlayingPreviewCard(
+ avatar: @Composable () -> Unit,
+ name: String,
+ liveStatus: String?,
+ isOn: Boolean,
+) {
+ val contentAlpha = if (liveStatus != null || isOn) 1f else 0.55f
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(20.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Sized, not clipped: the avatar rounds itself, and the follow mark and user cards it
+ // draws sit in the corners of its square box, which a circular clip would cut off.
+ Box(Modifier.size(44.dp)) { avatar() }
+
+ Column(
+ modifier = Modifier.weight(1f).padding(start = 14.dp, end = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Text(
+ text = name,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = CustomHashTagIcons.Tunestr,
+ contentDescription = null,
+ modifier = Modifier.size(15.dp).padding(end = 4.dp),
+ tint = MaterialTheme.colorScheme.placeholderText.copy(alpha = contentAlpha),
+ )
+ Text(
+ text = liveStatus ?: stringRes(Res.string.now_playing_preview_example),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.placeholderText.copy(alpha = contentAlpha),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+
+ StatusChip(
+ when {
+ liveStatus != null -> PreviewChip.LIVE
+ isOn -> PreviewChip.SAMPLE
+ else -> PreviewChip.OFF
+ },
+ )
+ }
+ }
+}
+
+private enum class PreviewChip { LIVE, SAMPLE, OFF }
+
+@Composable
+private fun StatusChip(chip: PreviewChip) {
+ val (container, content) =
+ when (chip) {
+ PreviewChip.LIVE -> MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer
+ else -> MaterialTheme.colorScheme.surfaceContainerHighest to MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ val label =
+ when (chip) {
+ PreviewChip.LIVE -> Res.string.now_playing_preview_live
+ PreviewChip.SAMPLE -> Res.string.now_playing_preview_sample
+ PreviewChip.OFF -> Res.string.now_playing_preview_off
+ }
+
+ Row(
+ modifier =
+ Modifier
+ .clip(RoundedCornerShape(50))
+ .background(container)
+ .padding(horizontal = 10.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (chip == PreviewChip.LIVE) {
+ Box(Modifier.size(6.dp).clip(CircleShape).background(content))
+ Spacer(Modifier.width(6.dp))
+ }
+ Text(
+ text = stringRes(label),
+ style = MaterialTheme.typography.labelMedium,
+ color = content,
+ )
+ }
+}
+
+/** Sub-row under the "Other apps" tile once access is granted: a quiet confirmation + Manage. */
+@Composable
+private fun AccessGrantedRow(access: NowPlayingAccess) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(start = 68.dp, end = 8.dp, bottom = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ SymbolIcon(
+ symbol = MaterialSymbols.CheckCircle,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Text(
+ text = stringRes(Res.string.now_playing_access_granted),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.weight(1f).padding(start = 6.dp),
+ )
+ TextButton(onClick = access::requestAccess) {
+ Text(stringRes(Res.string.now_playing_manage_access))
+ }
+ }
+}
+
+/** The one thing blocking other-app sharing, as a tonal card with a single clear action. */
+@Composable
+private fun AccessNeededCard(access: NowPlayingAccess) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(20.dp),
+ // A light wash of the error color rather than the full errorContainer: it flags the one
+ // step left without shouting louder than the rest of the screen, in either theme.
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.error.copy(alpha = 0.10f),
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
+ ) {
+ Column(Modifier.padding(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ modifier =
+ Modifier
+ .size(36.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(MaterialTheme.colorScheme.error.copy(alpha = 0.16f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ SymbolIcon(
+ symbol = MaterialSymbols.NotificationsOff,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.error,
+ )
+ }
+ Text(
+ text = stringRes(Res.string.now_playing_access_needed_title),
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(start = 16.dp),
+ )
+ }
+ Text(
+ text = stringRes(Res.string.now_playing_access_needed),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 52.dp, top = 4.dp),
+ )
+ Spacer(Modifier.height(12.dp))
+ Button(
+ onClick = access::requestAccess,
+ modifier = Modifier.align(Alignment.End),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError,
+ ),
+ ) {
+ Text(stringRes(Res.string.now_playing_grant_access))
+ }
+ }
+ }
+}
+
+/** The user grants access in system settings and comes back, so this re-checks on every resume. */
+@Composable
+private fun rememberAccessGranted(access: NowPlayingAccess): Boolean {
+ var granted by remember(access) { mutableStateOf(access.hasAccess()) }
+ LifecycleResumeEffect(access) {
+ granted = access.hasAccess()
+ onPauseOrDispose {}
+ }
+ return granted
+}
+
+@Composable
+private fun AppToggleRow(
+ id: String,
+ label: String,
+ checked: Boolean,
+ appIcon: (@Composable (appId: String, label: String) -> Unit)?,
+ onCheckedChange: (Boolean) -> Unit,
+) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable { onCheckedChange(!checked) }
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)), contentAlignment = Alignment.Center) {
+ if (appIcon != null) appIcon(id, label) else MonogramBadge(label, size = 36, shape = RoundedCornerShape(10.dp))
+ }
+ Column(
+ modifier = Modifier.weight(1f).padding(start = 16.dp, end = 12.dp),
+ ) {
+ Text(text = label, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ // The package name tells apart two apps with the same name; skip it when it only
+ // repeats the name (desktop player ids such as `spotify` or `vlc`).
+ if (!label.lowercase().filterNot { it.isWhitespace() }.contains(id.lowercase())) {
+ Text(
+ text = id,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ Switch(checked = checked, onCheckedChange = onCheckedChange)
+ }
+}
+
+// The accent palette of the Messages settings, so app badges read as the same family.
+private val MONOGRAM_COLORS =
+ listOf(
+ Color(0xFF2EBD85),
+ Color(0xFFF6A609),
+ Color(0xFF2E90FA),
+ Color(0xFF9E77ED),
+ Color(0xFF5B6AD0),
+ Color(0xFFEC4899),
+ Color(0xFF14B8A6),
+ Color(0xFFEF4444),
+ Color(0xFF06B6D4),
+ )
+
+/** A tinted badge with the first letter of [label], colored by the label so it stays stable. */
+@Composable
+fun MonogramBadge(
+ label: String,
+ size: Int,
+ shape: Shape,
+) {
+ val accent = MONOGRAM_COLORS[(label.hashCode() and Int.MAX_VALUE) % MONOGRAM_COLORS.size]
+ Box(
+ modifier = Modifier.size(size.dp).clip(shape).background(accent.copy(alpha = 0.18f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = label.trim().take(1).uppercase(),
+ style = if (size >= 40) MaterialTheme.typography.titleMedium else MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ color = accent,
+ )
+ }
+}
+
+@Composable
+private fun SectionTitle(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(horizontal = 4.dp),
+ )
+}
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt
index 0bf5bee248..35f29cf0cc 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt
@@ -67,6 +67,8 @@ import com.vitorpamplona.amethyst.commons.resources.nests_servers_search_keyword
import com.vitorpamplona.amethyst.commons.resources.nests_servers_title
import com.vitorpamplona.amethyst.commons.resources.notification_settings
import com.vitorpamplona.amethyst.commons.resources.notification_settings_search_keywords
+import com.vitorpamplona.amethyst.commons.resources.now_playing_search_keywords
+import com.vitorpamplona.amethyst.commons.resources.now_playing_settings
import com.vitorpamplona.amethyst.commons.resources.ots_explorer_search_keywords
import com.vitorpamplona.amethyst.commons.resources.ots_explorer_settings
import com.vitorpamplona.amethyst.commons.resources.payment_targets
@@ -160,6 +162,7 @@ fun buildSettingsCatalog(
symEntry(Res.string.drawer_settings, MaterialSymbols.AutoMirrored.ViewList, Res.string.drawer_search_keywords, Route.DrawerSettings),
symEntry(Res.string.video_player_settings, MaterialSymbols.VideoSettings, Res.string.video_player_search_keywords, Route.VideoPlayerSettings),
symEntry(Res.string.audio_visualizer_settings, MaterialSymbols.MusicNote, Res.string.audio_visualizer_search_keywords, Route.AudioVisualizerSettings),
+ symEntry(Res.string.now_playing_settings, MaterialSymbols.MusicNote, Res.string.now_playing_search_keywords, Route.NowPlayingSettings),
symEntry(Res.string.favorite_dvms_title, MaterialSymbols.AutoAwesome, Res.string.favorite_dvms_search_keywords, Route.EditFavoriteAlgoFeeds),
symEntry(Res.string.profile_badges_title, MaterialSymbols.MilitaryTech, Res.string.profile_badges_search_keywords, Route.ProfileBadges),
symEntry(Res.string.payment_targets, MaterialSymbols.Payment, Res.string.payment_targets_search_keywords, Route.EditPaymentTargets),
diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsRenderTest.kt
new file mode 100644
index 0000000000..9c5b1c853a
--- /dev/null
+++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsRenderTest.kt
@@ -0,0 +1,190 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ImageComposeScene
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingAccess
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettings
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState
+import com.vitorpamplona.amethyst.commons.ui.theme.AmethystPreviewTheme
+import org.jetbrains.skia.EncodedImageFormat
+import java.awt.image.BufferedImage
+import java.io.ByteArrayInputStream
+import java.io.File
+import javax.imageio.ImageIO
+import kotlin.test.Test
+import kotlin.test.assertTrue
+
+/**
+ * Renders the Now Playing settings headlessly in both themes. Like the other render tests it
+ * checks structure, not exact pixels: every string resolves (a missing one throws here) and the
+ * screen follows the theme. Set NOW_PLAYING_RENDER_DIR to also write the PNGs for a human to look at.
+ */
+class NowPlayingSettingsRenderTest {
+ private val width = 820
+ private val height = 1900
+
+ private class FakeAccess(
+ val granted: Boolean,
+ ) : NowPlayingAccess {
+ override fun hasAccess() = granted
+
+ override fun requestAccess() {}
+ }
+
+ private fun state(settings: NowPlayingSettings) = NowPlayingSettingsState().apply { restore(settings) }
+
+ private val apps =
+ mapOf(
+ "com.spotify.music" to "Spotify",
+ "com.google.android.apps.youtube.music" to "YouTube Music",
+ "de.danoeh.antennapod" to "AntennaPod",
+ )
+
+ private val scenarios: Map Unit> =
+ mapOf(
+ "off" to { NowPlayingSettingsContent(state(NowPlayingSettings()), FakeAccess(false)) },
+ "needs-access" to {
+ NowPlayingSettingsContent(state(NowPlayingSettings(shareInApp = true, shareOtherApps = true)), FakeAccess(false))
+ },
+ "on" to {
+ NowPlayingSettingsContent(
+ state(
+ NowPlayingSettings(
+ shareInApp = true,
+ shareOtherApps = true,
+ knownApps = apps,
+ blockedApps = setOf("com.google.android.apps.youtube.music"),
+ ),
+ ),
+ FakeAccess(true),
+ preview = { isOn ->
+ NowPlayingPreviewCard(
+ avatar = { MonogramBadge("Vitor", size = 44, shape = CircleShape) },
+ name = "Vitor",
+ liveStatus = "One More Time - Daft Punk",
+ isOn = isOn,
+ )
+ },
+ )
+ },
+ "desktop" to {
+ NowPlayingSettingsContent(
+ state(NowPlayingSettings(shareOtherApps = true, knownApps = mapOf("spotify" to "Spotify", "vlc" to "VLC media player"))),
+ access = null,
+ showInApp = false,
+ )
+ },
+ )
+
+ private fun render(
+ dark: Boolean,
+ content: @Composable () -> Unit,
+ ): BufferedImage {
+ val scene =
+ ImageComposeScene(width = width, height = height, density = Density(2f)) {
+ AmethystPreviewTheme(dark = dark) {
+ Column(
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ ) {
+ content()
+ }
+ }
+ }
+ return try {
+ val png = scene.render().encodeToData(EncodedImageFormat.PNG)!!.bytes
+ System.getenv("NOW_PLAYING_RENDER_DIR")?.let { dir ->
+ val name = scenarios.entries.firstOrNull { it.value === content }?.key ?: return@let
+ File(dir, "now-playing-$name-${if (dark) "dark" else "light"}.png").writeBytes(png)
+ }
+ ImageIO.read(ByteArrayInputStream(png))
+ } finally {
+ scene.close()
+ }
+ }
+
+ private fun BufferedImage.distinctColours(): Int {
+ val seen = mutableSetOf()
+ for (x in 0 until width step 3) {
+ for (y in 0 until height step 3) seen += getRGB(x, y)
+ }
+ return seen.size
+ }
+
+ @Test
+ fun theAvatarCornersAreNotClipped() {
+ // ClickableUserPicture draws the follow mark in the top-right corner of its square box,
+ // outside the round picture. A circular clip around the slot used to cut it off.
+ val marker = Color(0xFFFF00FF)
+ val image =
+ render(false) {
+ NowPlayingPreviewCard(
+ avatar = {
+ Box(Modifier.fillMaxSize()) {
+ Box(Modifier.size(12.dp).align(Alignment.TopEnd).background(marker))
+ }
+ },
+ name = "Vitor",
+ liveStatus = null,
+ isOn = true,
+ )
+ }
+
+ var markerPixels = 0
+ for (x in 0 until width) {
+ for (y in 0 until height) {
+ if (image.getRGB(x, y) == marker.toArgb()) markerPixels++
+ }
+ }
+ // 12dp at density 2 is a 24x24 square: all of it must reach the screen.
+ assertTrue(markerPixels >= 24 * 24 * 95 / 100, "only $markerPixels of ${24 * 24} corner pixels were drawn")
+ }
+
+ @Test
+ fun everyStateRendersInBothThemes() {
+ scenarios.values.forEach { content ->
+ val light = render(false, content)
+ val dark = render(true, content)
+
+ assertTrue(light.distinctColours() > 20, "the light screen drew ${light.distinctColours()} colours")
+ assertTrue(dark.distinctColours() > 20, "the dark screen drew ${dark.distinctColours()} colours")
+ assertTrue(light.getRGB(2, 2) != dark.getRGB(2, 2), "the screen ignored the theme")
+ }
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
index 88859f79c6..42ceaf2b33 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
@@ -97,6 +97,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinderAccoun
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
import com.vitorpamplona.amethyst.commons.tor.TorType
+import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.NowPlayingSettingsContent
import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady
import com.vitorpamplona.amethyst.commons.wot.LocalWoTService
import com.vitorpamplona.amethyst.desktop.account.AccountManager
@@ -109,6 +110,9 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
+import com.vitorpamplona.amethyst.desktop.nowPlaying.DesktopNowPlayingCoordinator
+import com.vitorpamplona.amethyst.desktop.nowPlaying.DesktopNowPlayingSettings
+import com.vitorpamplona.amethyst.desktop.nowPlaying.OsNowPlayingReader
import com.vitorpamplona.amethyst.desktop.platform.PlatformInfo
import com.vitorpamplona.amethyst.desktop.platform.applyNativeWindowChrome
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
@@ -1459,6 +1463,18 @@ private fun AppInner(
onDispose { iAccount.wotService.close() }
}
+ // Shares what other apps on this computer play as the account's NIP-38
+ // music status, once the user turns it on in Settings > Now Playing.
+ if (!account.isReadOnly) {
+ LaunchedEffect(iAccount) {
+ DesktopNowPlayingCoordinator(
+ settings = DesktopNowPlayingSettings.forAccount(account.pubKeyHex),
+ signer = iAccount.signer,
+ publishEvent = { relayManager.publish(it, iAccount.outboxHomeRelays()) },
+ ).run()
+ }
+ }
+
// Lazy-load Namecoin services. The Core RPC HTTP
// client is sourced from the Tor-aware DesktopHttpClient
// singleton so .onion RPC URLs route through the
@@ -2724,6 +2740,35 @@ private fun settingsEntries(
},
)
+ if (OsNowPlayingReader.forThisOs() != null) {
+ add(
+ SettingsEntry(
+ SettingsMeta(
+ id = "nowPlaying",
+ icon = MaterialSymbols.MusicNote,
+ title = "Now Playing",
+ subtitle = "Share what you listen to as your status",
+ keywords =
+ persistentListOf(
+ "music",
+ "listening",
+ "status",
+ "song",
+ "podcast",
+ "spotify",
+ "nip-38",
+ ),
+ ),
+ ) {
+ NowPlayingSettingsContent(
+ state = DesktopNowPlayingSettings.forAccount(account.pubKeyHex),
+ access = null,
+ showInApp = false,
+ )
+ },
+ )
+ }
+
add(
SettingsEntry(
SettingsMeta(
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingCoordinator.kt
new file mode 100644
index 0000000000..16da363837
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingCoordinator.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.desktop.nowPlaying
+
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingPublisher
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingStatusService
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+
+/**
+ * Desktop: shares what another app on this computer is playing as the account's NIP-38 music
+ * status. The OS is polled every [pollMs] while something plays ([idlePollMs] while nothing
+ * does), and only while the account has
+ * [com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettings.shareOtherApps]
+ * on, so nothing is spawned (and macOS never asks for Automation access) until the user opts in.
+ *
+ * Desktop has no music or podcast player of its own, so there is no in-app source here.
+ */
+class DesktopNowPlayingCoordinator(
+ private val settings: NowPlayingSettingsState,
+ private val signer: NostrSigner,
+ private val publishEvent: (Event) -> Unit,
+ private val reader: OsNowPlayingReader? = OsNowPlayingReader.forThisOs(),
+ private val pollMs: Long = 15_000,
+ private val idlePollMs: Long = 30_000,
+) {
+ /** Runs until the calling coroutine (the logged-in account's composition) is cancelled. */
+ suspend fun run() {
+ val reader = reader ?: return
+
+ coroutineScope {
+ val otherApps = MutableStateFlow(null)
+
+ launch {
+ settings.flow
+ .map { it.shareOtherApps }
+ .distinctUntilChanged()
+ .collectLatest { enabled ->
+ if (!enabled) {
+ otherApps.value = null
+ return@collectLatest
+ }
+ while (true) {
+ val track = reader.read()
+ // Keep the last value when only the position moved, so nothing downstream re-runs.
+ if (track == null || !track.isSameMoment(otherApps.value)) otherApps.value = track
+ // Each poll spawns processes (PowerShell on Windows is the heavy one), so
+ // look less often while nothing plays; a new song shows up within 30s.
+ delay(if (track == null) idlePollMs else pollMs)
+ }
+ }
+ }
+
+ val publisher =
+ NowPlayingPublisher(
+ publish = { track, expiration -> publishEvent(UserStatusAction.createMusic(track, expiration, signer)) },
+ clear = { publishEvent(UserStatusAction.clearMusic(signer)) },
+ )
+
+ NowPlayingStatusService(
+ settings = settings.flow,
+ sources = listOf(otherApps),
+ onOtherAppSeen = { settings.rememberApp(it.id, it.label) },
+ publisher = publisher,
+ ).run()
+ }
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingSettings.kt
new file mode 100644
index 0000000000..a5c7af3dc1
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingSettings.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.desktop.nowPlaying
+
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettings
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState
+import com.vitorpamplona.amethyst.commons.model.preferences.NowPlayingSettingsStore
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.launch
+import java.util.prefs.Preferences
+
+/**
+ * Desktop's per-account [NowPlayingSettingsState]s, persisted with `java.util.prefs` like the
+ * rest of the desktop preferences (the DataStore-backed `NowPlayingSettingsStore` is Android's).
+ * One state per pubkey for the life of the process, so the settings card and the publisher share it.
+ */
+object DesktopNowPlayingSettings {
+ private val prefs: Preferences = Preferences.userNodeForPackage(DesktopNowPlayingSettings::class.java)
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val states = mutableMapOf()
+
+ @Synchronized
+ fun forAccount(pubKeyHex: HexKey): NowPlayingSettingsState =
+ states.getOrPut(pubKeyHex) {
+ NowPlayingSettingsState().also { state ->
+ state.restore(load(pubKeyHex))
+ scope.launch { state.flow.drop(1).collect { save(pubKeyHex, it) } }
+ }
+ }
+
+ private fun key(
+ pubKeyHex: HexKey,
+ name: String,
+ ) = "nowPlaying.${pubKeyHex.take(16)}.$name"
+
+ private fun load(pubKeyHex: HexKey) =
+ NowPlayingSettings(
+ shareInApp = false,
+ shareOtherApps = prefs.getBoolean(key(pubKeyHex, "shareOtherApps"), false),
+ blockedApps = split(prefs.get(key(pubKeyHex, "blockedApps"), "")).toSet(),
+ knownApps = NowPlayingSettingsStore.decodeApps(split(prefs.get(key(pubKeyHex, "knownApps"), "")).toSet()),
+ )
+
+ private fun save(
+ pubKeyHex: HexKey,
+ settings: NowPlayingSettings,
+ ) {
+ prefs.putBoolean(key(pubKeyHex, "shareOtherApps"), settings.shareOtherApps)
+ prefs.put(key(pubKeyHex, "blockedApps"), settings.blockedApps.joinToString(LINE))
+ prefs.put(key(pubKeyHex, "knownApps"), NowPlayingSettingsStore.encodeApps(settings.knownApps).joinToString(LINE))
+ }
+
+ private fun split(value: String) = value.split(LINE).filter { it.isNotEmpty() }
+
+ private const val LINE = "\n"
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MacNowPlayingReader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MacNowPlayingReader.kt
new file mode 100644
index 0000000000..aca2f75602
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MacNowPlayingReader.kt
@@ -0,0 +1,98 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.desktop.nowPlaying
+
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSource
+import com.vitorpamplona.quartz.utils.TimeUtils
+
+/**
+ * macOS: the system-wide "Now Playing" is a private framework apps can no longer read (macOS
+ * 15.4+), so this asks the two players that matter most, Spotify and Apple Music, over
+ * AppleScript. Each is only asked while it is running: addressing an app that is not running
+ * would launch it. The first time, macOS asks the user to allow Amethyst to control the player.
+ */
+class MacNowPlayingReader(
+ private val run: suspend (List) -> String? = { runCommand(it) },
+ private val nowSeconds: () -> Long = TimeUtils::now,
+) : OsNowPlayingReader {
+ private class Player(
+ val bundleId: String,
+ val label: String,
+ val executable: String,
+ // Spotify reports the track duration in milliseconds, Music in seconds.
+ val durationInMs: Boolean,
+ )
+
+ override suspend fun read(): NowPlaying? {
+ val running = run(listOf("ps", "-A", "-o", "comm=")) ?: return null
+
+ for (player in PLAYERS) {
+ if (!running.contains(player.executable)) continue
+ val output = run(listOf("osascript", "-e", script(player))) ?: continue
+ return parse(player.bundleId, player.label, output, player.durationInMs, nowSeconds()) ?: continue
+ }
+
+ return null
+ }
+
+ companion object {
+ private val PLAYERS =
+ listOf(
+ Player("com.spotify.client", "Spotify", "Spotify.app/Contents/MacOS/Spotify", durationInMs = true),
+ Player("com.apple.Music", "Music", "Music.app/Contents/MacOS/Music", durationInMs = false),
+ )
+
+ private fun script(player: Player) =
+ """
+ tell application id "${player.bundleId}"
+ if player state is playing then
+ return (name of current track) & tab & (artist of current track) & tab & (duration of current track) & tab & (player position)
+ end if
+ end tell
+ return ""
+ """.trimIndent()
+
+ /** Parses `titleartistdurationposition seconds`; numbers may use a decimal comma. */
+ fun parse(
+ id: String,
+ label: String,
+ output: String,
+ durationInMs: Boolean,
+ nowSeconds: Long,
+ ): NowPlaying? {
+ val fields = output.trimEnd('\n', '\r').split('\t')
+ if (fields.size < 4) return null
+
+ val title = fields[0].ifBlank { null } ?: return null
+ val duration = fields[2].replace(',', '.').toDoubleOrNull()
+ val durationMs = duration?.let { if (durationInMs) it.toLong() else (it * 1000).toLong() }
+ val positionMs = fields[3].replace(',', '.').toDoubleOrNull()?.let { (it * 1000).toLong() } ?: 0L
+
+ return NowPlaying(
+ title = title,
+ artist = fields[1].ifBlank { null },
+ source = NowPlayingSource.OtherApp(id, label),
+ endsAt = NowPlaying.endsAt(nowSeconds, durationMs, positionMs),
+ )
+ }
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MprisNowPlayingReader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MprisNowPlayingReader.kt
new file mode 100644
index 0000000000..9290de3642
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MprisNowPlayingReader.kt
@@ -0,0 +1,205 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.desktop.nowPlaying
+
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSource
+import com.vitorpamplona.quartz.utils.TimeUtils
+
+/**
+ * Linux/BSD: asks every MPRIS player on the session bus (Spotify, VLC, Rhythmbox, browsers, ...)
+ * what it is playing, through the `dbus-send` tool that ships with D-Bus itself, so no D-Bus
+ * library is linked in.
+ */
+class MprisNowPlayingReader(
+ private val run: suspend (List) -> String? = { runCommand(it) },
+ private val nowSeconds: () -> Long = TimeUtils::now,
+) : OsNowPlayingReader {
+ // A player's name never changes while its bus name lives; asking once saves a process per poll.
+ private val identities = mutableMapOf()
+
+ override suspend fun read(): NowPlaying? {
+ val names = run(LIST_NAMES)?.let(MprisParser::playerNames).orEmpty()
+ identities.keys.retainAll(names.toSet())
+
+ for (busName in names) {
+ val properties = run(getAll(busName))?.let(MprisParser::properties) ?: continue
+ if (properties["PlaybackStatus"]?.firstOrNull() != "Playing") continue
+
+ val identity =
+ identities.getOrPut(busName) {
+ run(getIdentity(busName))?.let(MprisParser::properties)?.get("Identity")?.firstOrNull()
+ }
+ return MprisParser.toNowPlaying(busName, identity, properties, nowSeconds())
+ }
+
+ return null
+ }
+
+ companion object {
+ private val LIST_NAMES =
+ listOf(
+ "dbus-send",
+ "--session",
+ "--print-reply",
+ "--dest=org.freedesktop.DBus",
+ "/org/freedesktop/DBus",
+ "org.freedesktop.DBus.ListNames",
+ )
+
+ private fun getAll(busName: String) =
+ listOf(
+ "dbus-send",
+ "--session",
+ "--print-reply",
+ "--dest=$busName",
+ "/org/mpris/MediaPlayer2",
+ "org.freedesktop.DBus.Properties.GetAll",
+ "string:org.mpris.MediaPlayer2.Player",
+ )
+
+ private fun getIdentity(busName: String) =
+ listOf(
+ "dbus-send",
+ "--session",
+ "--print-reply",
+ "--dest=$busName",
+ "/org/mpris/MediaPlayer2",
+ "org.freedesktop.DBus.Properties.GetAll",
+ "string:org.mpris.MediaPlayer2",
+ )
+ }
+}
+
+/** Parses the text `dbus-send --print-reply` prints for the MPRIS calls above. */
+object MprisParser {
+ private const val PREFIX = "org.mpris.MediaPlayer2."
+ private val INSTANCE_SUFFIX = Regex("""\.instance_?\d+(_\d+)?$""")
+
+ fun playerNames(output: String): List =
+ output
+ .lineSequence()
+ .map { it.trim() }
+ .filter { it.startsWith("string \"$PREFIX") }
+ .map { unquote(it.removePrefix("string ")) }
+ .toList()
+
+ /** The player id the block list keys on: `org.mpris.MediaPlayer2.vlc.instance123` is `vlc`. */
+ fun playerId(busName: String): String = busName.removePrefix(PREFIX).replace(INSTANCE_SUFFIX, "")
+
+ /**
+ * Flattens every `dict entry` of a GetAll reply, nested ones included (the track metadata is
+ * a dictionary inside the `Metadata` entry), into key -> values. Scalars have one value;
+ * string arrays such as `xesam:artist` keep all of them.
+ */
+ fun properties(output: String): Map> {
+ val result = mutableMapOf>()
+ var expectKey = false
+ var key: String? = null
+ var arrayKey: String? = null
+
+ output.lineSequence().map { it.trim() }.forEach { line ->
+ when {
+ line.startsWith("dict entry(") -> {
+ expectKey = true
+ }
+
+ expectKey && line.startsWith("string ") -> {
+ key = unquote(line.removePrefix("string "))
+ expectKey = false
+ }
+
+ line.startsWith("variant") -> {
+ val currentKey = key ?: return@forEach
+ val value = line.removePrefix("variant").trim()
+ if (value.startsWith("array [")) {
+ // Either a string list (artists) or a nested dictionary (Metadata);
+ // a nested dictionary's entries are picked up by the branches above.
+ arrayKey = currentKey
+ } else {
+ scalar(value)?.let { result.getOrPut(currentKey) { mutableListOf() }.add(it) }
+ }
+ key = null
+ }
+
+ line == "]" -> {
+ arrayKey = null
+ }
+
+ arrayKey != null && line.startsWith("string ") -> {
+ result.getOrPut(arrayKey!!) { mutableListOf() }.add(unquote(line.removePrefix("string ")))
+ }
+ }
+ }
+
+ return result
+ }
+
+ fun toNowPlaying(
+ busName: String,
+ identity: String?,
+ properties: Map>,
+ nowSeconds: Long,
+ ): NowPlaying? {
+ val title = properties["xesam:title"]?.firstOrNull()?.ifBlank { null } ?: return null
+ val artist =
+ properties["xesam:artist"]?.filter { it.isNotBlank() }?.joinToString(", ")?.ifBlank { null }
+ ?: properties["xesam:albumArtist"]?.firstOrNull()?.ifBlank { null }
+
+ // MPRIS times are microseconds.
+ val durationMs = properties["mpris:length"]?.firstOrNull()?.toLongOrNull()?.div(1000)
+ val positionMs = properties["Position"]?.firstOrNull()?.toLongOrNull()?.div(1000) ?: 0L
+ val rate = properties["Rate"]?.firstOrNull()?.toFloatOrNull() ?: 1f
+
+ val id = playerId(busName)
+ val label = identity?.ifBlank { null } ?: id.replaceFirstChar { it.uppercase() }
+
+ return NowPlaying(
+ title = title,
+ artist = artist,
+ source = NowPlayingSource.OtherApp(id, label),
+ endsAt = NowPlaying.endsAt(nowSeconds, durationMs, positionMs, rate),
+ url = properties["xesam:url"]?.firstOrNull()?.let(::shareableUrl),
+ )
+ }
+
+ /**
+ * Only links that name a track are shared. Browsers fill `xesam:url` with the page of whatever
+ * tab is playing, and a local player with a file path: neither belongs in a public status.
+ */
+ fun shareableUrl(url: String): String? = url.takeIf { SHAREABLE_URL.matches(it) }
+
+ private val SHAREABLE_URL = Regex("""^https://open\.spotify\.com/(intl-[a-z-]+/)?(track|episode)/[A-Za-z0-9]+(\?.*)?$""")
+
+ /** `string "x"`, `object path "/x"`, `int64 5`, `double 1`, `boolean true` -> the value. */
+ private fun scalar(value: String): String? =
+ when {
+ value.startsWith("string ") -> unquote(value.removePrefix("string "))
+ value.startsWith("object path ") -> unquote(value.removePrefix("object path "))
+ else -> value.substringAfter(' ', "").ifBlank { null }
+ }
+
+ private fun unquote(value: String): String {
+ val start = value.indexOf('"')
+ val end = value.lastIndexOf('"')
+ return if (start >= 0 && end > start) value.substring(start + 1, end) else value
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReader.kt
new file mode 100644
index 0000000000..f8aad95b00
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReader.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.desktop.nowPlaying
+
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying
+import com.vitorpamplona.quartz.utils.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runInterruptible
+import java.util.concurrent.TimeUnit
+import kotlin.concurrent.thread
+
+/** Reads what the OS says another app is playing right now. */
+interface OsNowPlayingReader {
+ /** The track a desktop app is playing, or null when nothing is (or the OS cannot say). */
+ suspend fun read(): NowPlaying?
+
+ companion object {
+ /** The reader for this OS, or null where there is none. */
+ fun forThisOs(osName: String = System.getProperty("os.name").orEmpty()): OsNowPlayingReader? {
+ val os = osName.lowercase()
+ return when {
+ os.contains("linux") || os.contains("bsd") -> MprisNowPlayingReader()
+ os.contains("mac") -> MacNowPlayingReader()
+ os.contains("windows") -> WindowsNowPlayingReader()
+ else -> null
+ }
+ }
+ }
+}
+
+/**
+ * Runs a short-lived command and returns its stdout, or null if it failed or took too long.
+ * Interruptible, so turning sharing off or closing the app does not wait out a slow command, and
+ * the process never outlives the call.
+ */
+internal suspend fun runCommand(
+ command: List,
+ timeoutSeconds: Long = 5,
+): String? =
+ runInterruptible(Dispatchers.IO) {
+ var process: Process? = null
+ try {
+ val started =
+ ProcessBuilder(command)
+ .redirectError(ProcessBuilder.Redirect.DISCARD)
+ .start()
+ process = started
+ started.outputStream.close()
+ // Read while the command runs: `ps -A -o comm=` prints ~100 KB on a busy Mac, past the
+ // pipe buffer, so waiting for the exit before reading deadlocked until the timeout and
+ // the reader never saw a player. The watchdog still kills a command that hangs, which
+ // also ends the read.
+ val watchdog =
+ thread(isDaemon = true, name = "now-playing-command-timeout") {
+ try {
+ if (!started.waitFor(timeoutSeconds, TimeUnit.SECONDS)) started.destroyForcibly()
+ } catch (_: InterruptedException) {
+ // The command finished first.
+ }
+ }
+ val output = started.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
+ val exited = started.waitFor(timeoutSeconds, TimeUnit.SECONDS)
+ watchdog.interrupt()
+ if (!exited || started.exitValue() != 0) null else output
+ } catch (e: InterruptedException) {
+ throw e
+ } catch (e: Exception) {
+ // Missing binary (no dbus-send, no osascript) or a denied permission: nothing to report.
+ Log.d("OsNowPlayingReader") { "${command.first()} failed: ${e.message}" }
+ null
+ } finally {
+ process?.takeIf { it.isAlive }?.destroyForcibly()
+ }
+ }
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/WindowsNowPlayingReader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/WindowsNowPlayingReader.kt
new file mode 100644
index 0000000000..a48ee1e5b9
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/WindowsNowPlayingReader.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.desktop.nowPlaying
+
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSource
+import com.vitorpamplona.quartz.utils.TimeUtils
+import java.util.Base64
+
+/**
+ * Windows: reads the system media transport controls (the media flyout every player feeds:
+ * Spotify, the Media Player app, browsers, ...) through the WinRT
+ * `GlobalSystemMediaTransportControlsSessionManager`, called from the built-in Windows PowerShell
+ * since the JVM has no WinRT bridge.
+ */
+class WindowsNowPlayingReader(
+ private val run: suspend (List) -> String? = { runCommand(it, timeoutSeconds = 10) },
+ private val nowSeconds: () -> Long = TimeUtils::now,
+) : OsNowPlayingReader {
+ override suspend fun read(): NowPlaying? {
+ val output = run(listOf("powershell.exe", "-NoProfile", "-NonInteractive", "-EncodedCommand", ENCODED_SCRIPT)) ?: return null
+ return parse(output, nowSeconds())
+ }
+
+ companion object {
+ private val SCRIPT =
+ """
+ ${'$'}ErrorActionPreference = 'Stop'
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
+ Add-Type -AssemblyName System.Runtime.WindowsRuntime
+ ${'$'}asTask = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { ${'$'}_.Name -eq 'AsTask' -and ${'$'}_.GetParameters().Count -eq 1 -and ${'$'}_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' })[0]
+ function Await(${'$'}op, [Type]${'$'}type) { ${'$'}task = ${'$'}asTask.MakeGenericMethod(${'$'}type).Invoke(${'$'}null, @(${'$'}op)); ${'$'}task.Wait(-1) | Out-Null; ${'$'}task.Result }
+ [void][Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager, Windows.Media.Control, ContentType = WindowsRuntime]
+ ${'$'}manager = Await ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager]::RequestAsync()) ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager])
+ foreach (${'$'}session in ${'$'}manager.GetSessions()) {
+ if (${'$'}session.GetPlaybackInfo().PlaybackStatus -ne 'Playing') { continue }
+ ${'$'}props = Await (${'$'}session.TryGetMediaPropertiesAsync()) ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionMediaProperties])
+ ${'$'}timeline = ${'$'}session.GetTimelineProperties()
+ ${'$'}length = [long](${'$'}timeline.EndTime - ${'$'}timeline.StartTime).TotalMilliseconds
+ ${'$'}position = [long]${'$'}timeline.Position.TotalMilliseconds
+ [Console]::Out.WriteLine((${'$'}session.SourceAppUserModelId, ${'$'}props.Title, ${'$'}props.Artist, ${'$'}length, ${'$'}position) -join "`t")
+ break
+ }
+ """.trimIndent()
+
+ // -EncodedCommand takes UTF-16LE in Base64, which sidesteps every quoting rule of the command line.
+ private val ENCODED_SCRIPT: String = Base64.getEncoder().encodeToString(SCRIPT.toByteArray(Charsets.UTF_16LE))
+
+ /** Parses `appIdtitleartistlengthMspositionMs`. */
+ fun parse(
+ output: String,
+ nowSeconds: Long,
+ ): NowPlaying? {
+ val fields =
+ output
+ .lineSequence()
+ .map { it.trimEnd('\r') }
+ .firstOrNull { it.isNotBlank() }
+ ?.split('\t') ?: return null
+ if (fields.size < 5) return null
+
+ val appId = fields[0].ifBlank { null } ?: return null
+ val title = fields[1].ifBlank { null } ?: return null
+ val durationMs = fields[3].toLongOrNull()?.takeIf { it > 0 }
+ val positionMs = fields[4].toLongOrNull() ?: 0L
+
+ return NowPlaying(
+ title = title,
+ artist = fields[2].ifBlank { null },
+ source = NowPlayingSource.OtherApp(appId, appLabel(appId)),
+ endsAt = NowPlaying.endsAt(nowSeconds, durationMs, positionMs),
+ )
+ }
+
+ /**
+ * A readable name for an app user model id: `Spotify.exe` is `Spotify`,
+ * `Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic` is `ZuneMusic`.
+ */
+ fun appLabel(appId: String): String {
+ val app = appId.substringAfterLast('!').substringBefore('_')
+ val name =
+ app
+ .removeSuffix(".exe")
+ .removeSuffix(".EXE")
+ .substringAfterLast('\\')
+ .substringAfterLast('.')
+ return name.ifBlank { appId }
+ }
+ }
+}
diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt
new file mode 100644
index 0000000000..36b29a93cc
--- /dev/null
+++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt
@@ -0,0 +1,240 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.desktop.nowPlaying
+
+import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSource
+import kotlinx.coroutines.test.runTest
+import java.io.File
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class OsNowPlayingReaderTest {
+ private val listNames =
+ """
+ method return time=1700000000.1 sender=org.freedesktop.DBus -> destination=:1.99 serial=3 reply_serial=2
+ array [
+ string "org.freedesktop.DBus"
+ string ":1.7"
+ string "org.mpris.MediaPlayer2.vlc.instance4242"
+ string "org.mpris.MediaPlayer2.spotify"
+ ]
+ """.trimIndent()
+
+ private val vlcPaused =
+ """
+ method return time=1700000000.2 sender=:1.50 -> destination=:1.99 serial=10 reply_serial=2
+ array [
+ dict entry(
+ string "PlaybackStatus"
+ variant string "Paused"
+ )
+ ]
+ """.trimIndent()
+
+ private val spotifyPlaying =
+ """
+ method return time=1700000000.3 sender=:1.51 -> destination=:1.99 serial=11 reply_serial=2
+ array [
+ dict entry(
+ string "PlaybackStatus"
+ variant string "Playing"
+ )
+ dict entry(
+ string "Metadata"
+ variant array [
+ dict entry(
+ string "mpris:trackid"
+ variant object path "/com/spotify/track/0DiWol3AO6WpXZgp0goxAV"
+ )
+ dict entry(
+ string "mpris:length"
+ variant uint64 320000000
+ )
+ dict entry(
+ string "xesam:artist"
+ variant array [
+ string "Daft Punk"
+ string "Romanthony"
+ ]
+ )
+ dict entry(
+ string "xesam:title"
+ variant string "One More Time "Live" – Café"
+ )
+ dict entry(
+ string "xesam:url"
+ variant string "https://open.spotify.com/track/0DiWol3AO6WpXZgp0goxAV"
+ )
+ ]
+ )
+ dict entry(
+ string "Position"
+ variant int64 20000000
+ )
+ dict entry(
+ string "Rate"
+ variant double 1
+ )
+ ]
+ """.trimIndent()
+
+ private val spotifyIdentity =
+ """
+ method return time=1700000000.4 sender=:1.51 -> destination=:1.99 serial=12 reply_serial=2
+ array [
+ dict entry(
+ string "Identity"
+ variant string "Spotify"
+ )
+ dict entry(
+ string "SupportedMimeTypes"
+ variant array [
+ ]
+ )
+ ]
+ """.trimIndent()
+
+ @Test
+ fun mprisFindsThePlayingPlayer() =
+ runTest {
+ val reader =
+ MprisNowPlayingReader(
+ run = { command ->
+ val dest = command.firstOrNull { it.startsWith("--dest=") }?.removePrefix("--dest=")
+ when {
+ dest == "org.freedesktop.DBus" -> listNames
+ dest == "org.mpris.MediaPlayer2.vlc.instance4242" -> vlcPaused
+ command.last() == "string:org.mpris.MediaPlayer2.Player" -> spotifyPlaying
+ else -> spotifyIdentity
+ }
+ },
+ nowSeconds = { 1_000L },
+ )
+
+ val track = reader.read()!!
+
+ assertEquals("One More Time \"Live\" – Café", track.title)
+ assertEquals("Daft Punk, Romanthony", track.artist)
+ assertEquals(NowPlayingSource.OtherApp("spotify", "Spotify"), track.source)
+ // 320s long, 20s in.
+ assertEquals(1_000L + 300 + 1, track.endsAt)
+ assertEquals("https://open.spotify.com/track/0DiWol3AO6WpXZgp0goxAV", track.url)
+ }
+
+ /**
+ * Runs the real reader, `dbus-send` included, against a live session bus. Opt-in: start an
+ * MPRIS player (or a fake one) under `dbus-run-session` and set MPRIS_LIVE_TITLE to its title.
+ */
+ @Test
+ fun mprisReadsALiveSessionBus() =
+ runTest {
+ val expectedTitle = System.getenv("MPRIS_LIVE_TITLE") ?: return@runTest
+ assertEquals(expectedTitle, MprisNowPlayingReader().read()?.title)
+ }
+
+ @Test
+ fun mprisReportsNothingWhenNoneIsPlaying() =
+ runTest {
+ val reader =
+ MprisNowPlayingReader(
+ run = { command -> if (command.any { it == "--dest=org.freedesktop.DBus" }) listNames else vlcPaused },
+ )
+
+ assertNull(reader.read())
+ }
+
+ @Test
+ fun mprisSharesTrackLinksButNeverPagesOrFiles() {
+ assertEquals(
+ "https://open.spotify.com/track/0DiWol3AO6WpXZgp0goxAV",
+ MprisParser.shareableUrl("https://open.spotify.com/track/0DiWol3AO6WpXZgp0goxAV"),
+ )
+ assertEquals(
+ "https://open.spotify.com/intl-pt/episode/4rOoJ6Egrf8K2IrywzwOMk?si=abc",
+ MprisParser.shareableUrl("https://open.spotify.com/intl-pt/episode/4rOoJ6Egrf8K2IrywzwOMk?si=abc"),
+ )
+ // Chromium reports the playing tab's page; VLC the file it opened.
+ assertNull(MprisParser.shareableUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ"))
+ assertNull(MprisParser.shareableUrl("https://mail.example.com/inbox/secret-thread"))
+ assertNull(MprisParser.shareableUrl("file:///home/me/Music/song.mp3"))
+ assertNull(MprisParser.shareableUrl("https://open.spotify.com.evil.example/track/abc"))
+ }
+
+ @Test
+ fun mprisPlayerIdDropsTheInstanceSuffix() {
+ assertEquals("vlc", MprisParser.playerId("org.mpris.MediaPlayer2.vlc.instance4242"))
+ assertEquals("chromium", MprisParser.playerId("org.mpris.MediaPlayer2.chromium.instance_1_23"))
+ assertEquals("spotify", MprisParser.playerId("org.mpris.MediaPlayer2.spotify"))
+ }
+
+ @Test
+ fun macParsesSpotifyAndMusicUnits() {
+ val spotify = MacNowPlayingReader.parse("com.spotify.client", "Spotify", "Song\tBand\t200000\t50,5\n", durationInMs = true, nowSeconds = 0)!!
+ assertEquals("Song", spotify.title)
+ assertEquals("Band", spotify.artist)
+ assertEquals(149L + 1, spotify.endsAt)
+
+ val music = MacNowPlayingReader.parse("com.apple.Music", "Music", "Song\tBand\t200.0\t50.0\n", durationInMs = false, nowSeconds = 0)!!
+ assertEquals(150L + 1, music.endsAt)
+
+ assertNull(MacNowPlayingReader.parse("com.apple.Music", "Music", "\n", durationInMs = false, nowSeconds = 0))
+ }
+
+ @Test
+ fun windowsParsesTheScriptOutput() {
+ val track = WindowsNowPlayingReader.parse("Spotify.exe\tSong\tBand\t200000\t50000\r\n", nowSeconds = 0)!!
+
+ assertEquals("Song - Band", track.statusText())
+ assertEquals(NowPlayingSource.OtherApp("Spotify.exe", "Spotify"), track.source)
+ assertEquals(150L + 1, track.endsAt)
+
+ assertNull(WindowsNowPlayingReader.parse("", nowSeconds = 0))
+ }
+
+ @Test
+ fun windowsAppLabels() {
+ assertEquals("Spotify", WindowsNowPlayingReader.appLabel("Spotify.exe"))
+ assertEquals("ZuneMusic", WindowsNowPlayingReader.appLabel("Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic"))
+ assertEquals("308046B0AF4A39CB", WindowsNowPlayingReader.appLabel("308046B0AF4A39CB"))
+ }
+
+ private val hasShell = File("/bin/sh").canExecute()
+
+ /** `ps -A -o comm=` prints ~100 KB on a busy Mac; reading only after the exit deadlocked on the pipe. */
+ @Test
+ fun runCommandReturnsOutputLargerThanThePipeBuffer() =
+ runTest {
+ if (!hasShell) return@runTest
+ val output = runCommand(listOf("/bin/sh", "-c", "i=0; while [ \$i -lt 4000 ]; do echo 0123456789012345678901234567890123456789012345678; i=\$((i+1)); done"))
+ assertEquals(4000 * 50, output?.length)
+ }
+
+ @Test
+ fun runCommandGivesUpOnAHungCommand() =
+ runTest {
+ if (!hasShell) return@runTest
+ val started = System.currentTimeMillis()
+ assertNull(runCommand(listOf("/bin/sh", "-c", "sleep 30"), timeoutSeconds = 1))
+ assertTrue(System.currentTimeMillis() - started < 10_000, "the timeout must end the call")
+ }
+}