Merge origin/main into claude/intelligent-babbage-23komv

# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
This commit is contained in:
Claude
2026-10-01 15:50:23 +00:00
50 changed files with 4415 additions and 51 deletions
+24
View File
@@ -90,6 +90,11 @@
<action android:name="android.intent.action.VIEW" />
<data android:scheme="tron" />
</intent>
<!-- Media apps (they expose a MediaBrowserService), so the now-playing settings can show
the name of the app playing instead of its package name. -->
<intent>
<action android:name="android.media.browse.MediaBrowserService" />
</intent>
</queries>
@@ -556,6 +561,25 @@
</intent-filter>
</service>
<!-- Reads other apps' media sessions to share what the user listens to as a NIP-38 music
status. Android only exposes other apps' sessions to an enabled notification listener;
this one never reads notifications. The system binds it only after the user grants
"Notification access", and it stays in the main process with the account. -->
<service
android:name=".service.nowPlaying.OtherAppsNowPlayingService"
android:exported="true"
android:label="@string/app_name"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
<!-- Media sessions do not depend on which notifications are delivered, so ask for the
narrowest type: fewer wakeups, and the access screen shows what is actually read. -->
<meta-data
android:name="android.service.notification.default_filter_types"
android:value="ongoing" />
</service>
<service
android:name=".service.call.CallForegroundService"
android:foregroundServiceType="microphone|camera|phoneCall|mediaProjection"
@@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.commons.model.preferences.BuzzWorkspaceStore
import com.vitorpamplona.amethyst.commons.model.preferences.ConcordDirectInviteDeclineStore
import com.vitorpamplona.amethyst.commons.model.preferences.DrawerSectionCollapsePreferences
import com.vitorpamplona.amethyst.commons.model.preferences.NamecoinSettingsStore
import com.vitorpamplona.amethyst.commons.model.preferences.NowPlayingSettingsStore
import com.vitorpamplona.amethyst.commons.model.preferences.OtsSettingsStore
import com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupAdminCacheStore
import com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupDeletionStore
@@ -111,6 +112,7 @@ import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServ
import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher
import com.vitorpamplona.amethyst.service.notifications.NwcPaymentNotificationWatcher
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
import com.vitorpamplona.amethyst.service.nowPlaying.NowPlayingStatusCoordinator
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
@@ -1073,6 +1075,8 @@ class AppModules(
BuzzAttestationStore(sharedSettingsStore, account.scope, account.pubKey, account.buzzAttestation)
// Concord Direct Invites the user declined (CORD-05 §6) stay declined across restarts.
ConcordDirectInviteDeclineStore(sharedSettingsStore, account.scope, account.pubKey, account.concord.directInviteInbox)
// Whether this account shares what it listens to as its music status, and from which apps.
NowPlayingSettingsStore(sharedSettingsStore, account.scope, account.pubKey, account.nowPlayingSettings)
},
)
@@ -1096,6 +1100,15 @@ class AppModules(
accountFlow = sessionManager.accountContent.map { (it as? AccountState.LoggedIn)?.account },
).also { it.start() }
// Shares what the user listens to (in the app, or in other apps once notification access is
// granted) as the logged-in account's NIP-38 music status. Off until the account opts in.
val nowPlayingStatusCoordinator =
NowPlayingStatusCoordinator(
scope = applicationIOScope,
accountFlow = sessionManager.accountContent.map { (it as? AccountState.LoggedIn)?.account },
relayServices = relayProxyClientConnector.relayServices,
).also { it.start() }
fun subscribedFlow(
address: Address,
account: Account,
@@ -0,0 +1,66 @@
/*
* 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.foundation.Image
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.MonogramBadge
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** The launcher icon of [packageName] for the now-playing app list, or a monogram if it is hidden. */
@Composable
fun AndroidAppIcon(
packageName: String,
label: String,
) {
val context = LocalContext.current
val icon by produceState<ImageBitmap?>(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
@@ -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)
}
@@ -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<Map<Int, InAppPlayback>>(emptyMap())
val flow: StateFlow<Map<Int, InAppPlayback>> = 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,
)
}
}
@@ -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<Account?>,
private val relayServices: Flow<*>,
private val inApp: StateFlow<Map<Int, InAppPlayback>> = InAppPlaybackRegistry.flow,
private val otherApps: StateFlow<NowPlaying?> = 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<String, Resolution>()
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
}
}
@@ -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<NowPlaying?>(null)
val flow: StateFlow<NowPlaying?> = 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<MediaSession.Token, Pair<MediaController, MediaController.Callback>>()
// App labels come from PackageManager, an IPC; players report state up to once a second.
private val labels = mutableMapOf<String, String>()
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<MediaController>) {
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)
}
}
}
}
}
@@ -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())
}
}
@@ -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()
@@ -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<Route.ProfileUiSettings> { ProfileUiSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.CallSettings> { CallSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.NowPlayingSettings> {
val context = LocalContext.current
val access = remember(context) { AndroidNowPlayingAccess(context.applicationContext) }
NowPlayingSettingsScreen(accountViewModel, nav, access) { appId, label -> AndroidAppIcon(appId, label) }
}
composableFromEnd<Route.NotificationSettings> { NotificationSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ResourceUsage> { ResourceUsageScreen(accountViewModel, nav) }
composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) }
@@ -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)
@@ -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))
@@ -585,6 +585,8 @@ sealed class Route {
@Serializable object CallSettings : Route()
@Serializable object NowPlayingSettings : Route()
@Serializable object NotificationSettings : Route()
@Serializable object CalendarReminderSettings : Route()
@@ -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,
@@ -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()
@@ -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
}
}
}
@@ -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()
}
@@ -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<NowPlaying?>) {
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
}
}
@@ -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,
)
}
}
@@ -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<String> = emptySet(),
val knownApps: Map<String, String> = 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<NowPlayingSettings> = 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)
}
}
@@ -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<NowPlayingSettings>,
private val sources: List<Flow<NowPlaying?>>,
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)
}
}
@@ -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<Preferences>,
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<String, String>): Set<String> = apps.mapTo(mutableSetOf()) { (id, label) -> "$id$SEPARATOR$label" }
fun decodeApps(raw: Set<String>): Map<String, String> =
raw
.mapNotNull { entry ->
val split = entry.indexOf(SEPARATOR)
if (split <= 0) null else entry.substring(0, split) to entry.substring(split + 1)
}.toMap()
}
}
@@ -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? {
@@ -22,8 +22,21 @@ package com.vitorpamplona.amethyst.commons.preview
import com.vitorpamplona.amethyst.commons.util.codePointToChars
/** The `<head>` element a [MetaTag] was read from. */
enum class HeadElement {
/** A `<meta>` tag, the only kind [MetaTagsParser.parse] yields unless asked for more. */
META,
/** The document's `<title>`, its text carried as the `content` attribute. */
TITLE,
/** A `<link>` whose `rel` names an icon (`icon`, `shortcut icon`, `apple-touch-icon`, …). */
LINK,
}
data class MetaTag(
private val attrs: Map<String, String>,
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 `<title>` 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 `</head>` that ends the interesting part of the document. */
HEAD_END,
/**
* A `<body>` start tag. `</head>` is optional, so on a page that omits it this is the only
* sign the head is over.
*/
BODY_START,
/** A closed `<title>`: its text is [TagScanner.textStart]..<[TagScanner.textEnd]. */
TITLE,
/** A `<link …>` 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 `<title>` and its icon `<link>`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<MetaTag> =
fun parse(
input: String,
includeTitleAndIcons: Boolean = false,
): Sequence<MetaTag> =
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 `<body>`, a `<title>` 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 `<title>`'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 `<title>` [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..<to]` contains [lower], ASCII-case-insensitively. */
private fun spanContains(
from: Int,
to: Int,
lower: String,
): Boolean {
var i = from
while (i + lower.length <= to) {
if (input.regionMatches(i, lower, 0, lower.length, ignoreCase = true)) return true
i++
}
return false
}
/** Leaves [p] on the `</name` that closes a raw-text element, or at the end of the input. */
private fun skipRawText(endTag: String) {
var i = p
@@ -215,14 +331,25 @@ object MetaTagsParser {
// the same way an unbalanced quote inside a comment does. Switching on the name length
// first keeps the common tag (a `<div>`, a `<link>`) 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 `<link>`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)
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.preview
class OpenGraphParser {
class Result(
/** The OpenGraph/Twitter/meta title, or the document's `<title>` 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 `<link>` 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 `<link>` 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
}
}
@@ -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 `<link>` (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 `<title>`) 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
}
}
@@ -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)
}
}
@@ -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<Pair<NowPlaying, Long>>()
var clears = 0
}
private fun TestScope.now() = base + testScheduler.currentTime / 1000
private fun TestScope.start(source: MutableStateFlow<NowPlaying?>): 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<NowPlaying?>(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<NowPlaying?>(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<NowPlaying?>(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<NowPlaying?>(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<NowPlaying?>(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<NowPlaying?>(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<NowPlaying?>(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))
}
}
@@ -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"))
}
}
@@ -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 `<title>` and its icon
* `<link>`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 =
"""
|<!DOCTYPE html>
|<html lang="en">
|<head>
|<meta charset="utf-8">
|<meta name="viewport" content="width=device-width, initial-scale=1">
|<title>Private Provider &mdash; Confidential AI</title>
|<meta name="description" content="A phone-first AI workspace for Android.">
|<link rel="icon" href="favicon.svg" type="image/svg+xml">
|<link rel="stylesheet" href="styles.css">
|</head>
|<body><h1>Hi</h1></body>
|</html>
""".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(
"""
|<head>
| <title>Document Title</title>
| <meta property="og:title" content="OG Title">
|</head>
""".trimMargin(),
)
assertEquals("OG Title", info.title)
}
@Test
fun titleWhitespaceIsCollapsed() {
val info = extract("<head><title>\n Spread\n\t Out </title></head>")
assertEquals("Spread Out", info.title)
}
@Test
fun anUnclosedTitleIsNotATitle() {
val info = extract("<head><title>runs to the end of the truncated body")
assertEquals("", info.title)
}
@Test
fun aTitleOutsideTheHeadIsIgnored() {
// An SVG in the body carries its own <title>; the scan stops at </head> before it.
val info = extract("<head></head><body><svg><title>Close</title></svg></body>")
assertEquals("", info.title)
}
@Test
fun theFirstTitleWins() {
val info = extract("<head><title>First</title><title>Second</title></head>")
assertEquals("First", info.title)
}
@Test
fun theLargestIconWins() {
val info =
extract(
"""
|<head>
| <link rel="shortcut icon" href="/favicon.ico">
| <link rel="icon" sizes="16x16 32x32" href="/icon-32.png">
| <link rel="apple-touch-icon" href="/apple-touch-icon.png">
| <link rel="icon" sizes="96X96" href="/icon-96.png">
|</head>
""".trimMargin(),
)
assertEquals("/apple-touch-icon.png", info.icon)
}
@Test
fun aScalableIconBeatsBitmaps() {
val info =
extract(
"""
|<head>
| <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
| <link rel="icon" href="/icon.svg?v=2">
|</head>
""".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(
"""
|<head>
| <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
| <link rel="icon" href="/favicon-32.png">
|</head>
""".trimMargin(),
)
assertEquals("/favicon-32.png", info.icon)
}
@Test
fun iconLinksAfterTheHeadAreIgnored() {
val info = extract("""<head></head><body><link rel="icon" href="/late.png"></body>""")
assertEquals("", info.icon)
}
@Test
fun aBodyTitleIsIgnoredWhenTheHeadIsNeverClosed() {
// `</head>` is optional: `<body>` closes the head implicitly. An inline SVG's <title>
// ("Close", "Menu") is the classic thing to then mistake for the page title.
val info = extract("""<head><meta name="description" content="D"><body><svg><title>Close</title></svg></body>""")
assertEquals("", info.title)
assertEquals("D", info.description)
}
@Test
fun aBodyIconLinkIsIgnoredWhenTheHeadIsNeverClosed() {
val info = extract("""<head><title>T</title><body><link rel="icon" href="/late.png"></body>""")
assertEquals("T", info.title)
assertEquals("", info.icon)
}
@Test
fun aTitleWithAttributesIsRead() {
val info = extract("""<head><title data-rh="true">Helmet</title></head>""")
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("<head><title>" + "a".repeat(299) + "\uD83D\uDE00 tail</title></head>")
assertTrue(info.title.isNotEmpty())
assertTrue(!info.title.last().isHighSurrogate(), "title ends in a dangling high surrogate")
}
}
@@ -77,6 +77,7 @@ class UrlPreview {
data.video,
data.videoType,
data.type,
data.icon,
)
}
@@ -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) }
},
)
}
@@ -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<Preferences> {
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<String, String>(), 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()
}
}
@@ -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() {
// `<link rel="icon" href="data:,">` 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)
}
}
@@ -160,6 +160,19 @@ class MetaTagsParserBenchmark {
}
bench("no </head>, whole doc", open, 2_000) { MetaTagsParser.parse(it).count() }
// What production runs (HtmlParser asks for the <title> and icon <link>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 </head>, +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)
}
}
@@ -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"))
}
}
@@ -6362,6 +6362,27 @@
<string name="browser_pill_download_risky">This file type can run code. Check that the name matches what you meant to get.</string>
<string name="browser_pill_download_save">Save</string>
<string name="browser_pill_download_from">From %1$s</string>
<string name="now_playing_settings">Now Playing</string>
<string name="now_playing_search_keywords">music listening status song podcast spotify nip38 share playing</string>
<string name="now_playing_description">Your status is public. It appears under your name and disappears when the track ends or you stop playing.</string>
<string name="now_playing_preview_title">How others see it</string>
<string name="now_playing_preview_example">Song title - Artist</string>
<string name="now_playing_preview_live">Live</string>
<string name="now_playing_preview_sample">Example</string>
<string name="now_playing_preview_off">Off</string>
<string name="now_playing_preview_you">You</string>
<string name="now_playing_section_sources">Share from</string>
<string name="now_playing_share_in_app">Amethyst</string>
<string name="now_playing_share_in_app_description">Music tracks and podcast episodes you play here</string>
<string name="now_playing_share_other_apps">Other apps</string>
<string name="now_playing_share_other_apps_description">Spotify, YouTube Music, podcast players and any app with media controls</string>
<string name="now_playing_access_needed_title">Notification access needed</string>
<string name="now_playing_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.</string>
<string name="now_playing_grant_access">Grant access</string>
<string name="now_playing_manage_access">Manage</string>
<string name="now_playing_access_granted">Notification access is on</string>
<string name="now_playing_apps">Apps</string>
<string name="now_playing_apps_empty">Apps show up here after they play something. Turn one off to stop sharing it.</string>
<string name="prediction_market">Prediction market</string>
<string name="prediction_market_status_open">Open</string>
<string name="prediction_market_status_closed">Closed</string>
@@ -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 `<title>`, 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<String, Unit>(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)
@@ -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(
@@ -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),
)
}
@@ -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),
@@ -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<String, @Composable () -> 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<Int>()
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")
}
}
}
@@ -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(
@@ -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<NowPlaying?>(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()
}
}
}
@@ -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<HexKey, NowPlayingSettingsState>()
@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"
}
@@ -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>) -> 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 `title<TAB>artist<TAB>duration<TAB>position 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),
)
}
}
}
@@ -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>) -> 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<String, String?>()
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<String> =
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<String, List<String>> {
val result = mutableMapOf<String, MutableList<String>>()
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<String, List<String>>,
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
}
}
@@ -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<String>,
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()
}
}
@@ -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>) -> 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 `appId<TAB>title<TAB>artist<TAB>lengthMs<TAB>positionMs`. */
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 }
}
}
}
@@ -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")
}
}