diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 91acb98039..467e9ac3d9 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -90,6 +90,11 @@ + + + + @@ -556,6 +561,25 @@ + + + + + + + + + (null, packageName) { + value = + withContext(Dispatchers.IO) { + runCatching { + context.packageManager + .getApplicationIcon(packageName) + .toBitmap(ICON_PX, ICON_PX) + .asImageBitmap() + }.getOrNull() + } + } + + val current = icon + if (current != null) { + Image(bitmap = current, contentDescription = null, modifier = Modifier.fillMaxSize()) + } else { + MonogramBadge(label, size = 36, shape = RoundedCornerShape(10.dp)) + } +} + +private const val ICON_PX = 108 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/AndroidNowPlayingAccess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/AndroidNowPlayingAccess.kt new file mode 100644 index 0000000000..93bb929353 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/AndroidNowPlayingAccess.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.nowPlaying + +import android.content.Context +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingAccess + +/** Notification access, which Android requires before it shares other apps' media sessions. */ +class AndroidNowPlayingAccess( + private val context: Context, +) : NowPlayingAccess { + override fun hasAccess() = OtherAppsNowPlayingService.hasAccess(context) + + override fun requestAccess() = OtherAppsNowPlayingService.openAccessSettings(context) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/InAppPlayback.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/InAppPlayback.kt new file mode 100644 index 0000000000..3515857edd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/InAppPlayback.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.nowPlaying + +import androidx.compose.runtime.Immutable +import androidx.media3.common.C +import androidx.media3.common.Player +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying +import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemCache +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlin.math.abs + +/** What one of the app's players is audibly playing: its item's `nostr:` URI and where it ends. */ +@Immutable +data class InAppPlayback( + val callbackUri: String, + val title: String?, + val artist: String?, + val endsAt: Long?, +) { + /** Same item, ending within a few seconds: a position tick rather than a change. */ + fun isSameMoment(other: InAppPlayback?): Boolean { + if (other == null || callbackUri != other.callbackUri || title != other.title || artist != other.artist) return false + val a = endsAt + val b = other.endsAt + return if (a == null || b == null) a == b else abs(a - b) <= NowPlaying.MOMENT_TOLERANCE_SECONDS + } +} + +/** + * The players in this process that are audibly playing something with a Nostr event behind it, + * keyed by player. Fed by [InAppPlaybackListener]; read by the now-playing coordinator, which + * resolves the URI to a music track or podcast episode and ignores everything else. + */ +object InAppPlaybackRegistry { + private val playing = MutableStateFlow>(emptyMap()) + + val flow: StateFlow> = playing + + fun update( + player: Player, + playback: InAppPlayback?, + ) { + val key = System.identityHashCode(player) + playing.update { current -> + val previous = current[key] + when { + playback == null -> if (previous != null) current - key else current + // Live streams and some players report timeline/position changes every few + // seconds; keeping the same map avoids re-running everything downstream. + playback.isSameMoment(previous) -> current + // The same item with a corrected end keeps its place; a new item moves to the + // end, so iterating backwards visits the most recently started first. + previous?.callbackUri == playback.callbackUri -> current + (key to playback) + else -> (current - key) + (key to playback) + } + } + } + + /** A released player reports nothing on its way out, so the pool drops it here. */ + fun forget(player: Player) = update(player, null) +} + +/** + * Reports a player's audible playback to [InAppPlaybackRegistry]. A muted player (feed autoplay) + * or one without a `nostr:` callback URI reports nothing. + * + * Attached once per player in `ExoPlayerBuilder`; runs on the player's looper. + */ +class InAppPlaybackListener( + private val registry: InAppPlaybackRegistry = InAppPlaybackRegistry, + private val nowSeconds: () -> Long = TimeUtils::now, +) : Player.Listener { + override fun onEvents( + player: Player, + events: Player.Events, + ) { + if (events.containsAny(*RELEVANT_EVENTS)) { + registry.update(player, snapshot(player)) + } + } + + private fun snapshot(player: Player): InAppPlayback? { + if (!player.isPlaying || player.volume < 0.001f) return null + + val item = player.currentMediaItem ?: return null + val metadata = item.mediaMetadata + val callbackUri = metadata.extras?.getString(MediaItemCache.EXTRA_CALLBACK_URI) ?: return null + if (!callbackUri.startsWith("nostr:")) return null + + val duration = player.duration.takeIf { it != C.TIME_UNSET && !player.isCurrentMediaItemLive } + + return InAppPlayback( + callbackUri = callbackUri, + // MediaItemCache falls back to the media URL (the media id) when there is no title. + title = metadata.title?.toString()?.takeUnless { it == item.mediaId }, + artist = metadata.artist?.toString(), + endsAt = NowPlaying.endsAt(nowSeconds(), duration, player.currentPosition, player.playbackParameters.speed), + ) + } + + companion object { + private val RELEVANT_EVENTS = + intArrayOf( + Player.EVENT_IS_PLAYING_CHANGED, + Player.EVENT_MEDIA_ITEM_TRANSITION, + Player.EVENT_POSITION_DISCONTINUITY, + Player.EVENT_TIMELINE_CHANGED, + Player.EVENT_VOLUME_CHANGED, + Player.EVENT_PLAYBACK_PARAMETERS_CHANGED, + Player.EVENT_MEDIA_METADATA_CHANGED, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/NowPlayingStatusCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/NowPlayingStatusCoordinator.kt new file mode 100644 index 0000000000..40485ae3df --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/NowPlayingStatusCoordinator.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.nowPlaying + +import com.vitorpamplona.amethyst.commons.model.Account +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingPublisher +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingResolver +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingStatusService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Publishes what the user is listening to, in Amethyst or in another app, as the logged-in + * account's NIP-38 music status, following that account's now-playing settings. + * + * App-scoped, like the other background watchers in AppModules: the music keeps playing (and + * other apps keep reporting) after the activity is gone. While the app is in the background the + * relay pool is paused, so every publish holds [relayServices] open for [RELAY_HOLD_MS], long + * enough for the event to go out. + */ +class NowPlayingStatusCoordinator( + private val scope: CoroutineScope, + private val accountFlow: Flow, + private val relayServices: Flow<*>, + private val inApp: StateFlow> = InAppPlaybackRegistry.flow, + private val otherApps: StateFlow = OtherAppsPlaybackRegistry.flow, +) { + private var relayHold: Job? = null + + fun start() { + scope.launch(Dispatchers.IO) { + accountFlow + .distinctUntilChanged { a, b -> a?.signer?.pubKey == b?.signer?.pubKey } + .collectLatest { account -> + if (account == null || !account.isWriteable()) return@collectLatest + run(account) + } + } + } + + private suspend fun run(account: Account) { + // Every player that is audibly playing, most recently started first: the first one that + // is a music track or podcast episode wins, so an unmuted feed video started after a song + // does not hide the song. + val inAppTracks = + inApp + .map { playing -> playing.values.reversed().firstNotNullOfOrNull(::resolve) } + .flowOn(Dispatchers.IO) + + val publisher = + NowPlayingPublisher( + // Relays are woken first: in the background the pool is paused, and the event + // waits in the outbox until a relay connects. + publish = { track, expiration -> + holdRelays() + account.publishNowPlaying(track, expiration) + }, + clear = { + holdRelays() + account.clearNowPlaying() + }, + ) + + NowPlayingStatusService( + settings = account.nowPlayingSettings.flow, + sources = listOf(inAppTracks, otherApps), + onOtherAppSeen = { account.nowPlayingSettings.rememberApp(it.id, it.label) }, + publisher = publisher, + ).run() + } + + /** + * What a player's item is as a status, without its end time. Keyed by the item's `nostr:` URI + * so the NIP-19 parse and the cache lookup run once per item, not once per position update. + * Only touched from the single flow collecting [inApp]. + */ + private val resolved = HashMap() + + private class Resolution( + val track: NowPlaying?, + ) + + private fun resolve(playback: InAppPlayback): NowPlaying? { + val cached = + resolved[playback.callbackUri] ?: run { + val key = NowPlayingResolver.noteKey(playback.callbackUri) + val event = + if (key != null) { + // Not loaded yet: resolve again on the next update instead of caching a miss. + LocalCache.getNoteIfExists(key)?.event ?: return null + } else { + null + } + val track = event?.let { NowPlayingResolver.fromEvent(it, playback.title, playback.artist) } + + if (resolved.size >= MAX_RESOLVED) resolved.clear() + Resolution(track).also { resolved[playback.callbackUri] = it } + } + + return cached.track?.copy(endsAt = playback.endsAt) + } + + private fun holdRelays() { + relayHold?.cancel() + relayHold = + scope.launch(Dispatchers.IO) { + withTimeoutOrNull(RELAY_HOLD_MS) { relayServices.collect {} } + } + } + + companion object { + private const val RELAY_HOLD_MS = 30_000L + private const val MAX_RESOLVED = 64 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/OtherAppsNowPlayingService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/OtherAppsNowPlayingService.kt new file mode 100644 index 0000000000..a461d2b516 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/OtherAppsNowPlayingService.kt @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.nowPlaying + +import android.content.ActivityNotFoundException +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.media.MediaMetadata +import android.media.session.MediaController +import android.media.session.MediaSession +import android.media.session.MediaSessionManager +import android.media.session.PlaybackState +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.provider.Settings +import android.service.notification.NotificationListenerService +import androidx.core.app.NotificationManagerCompat +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSource +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** What another app on this device is playing, as reported by its media session. */ +object OtherAppsPlaybackRegistry { + private val playing = MutableStateFlow(null) + + val flow: StateFlow = playing + + fun update(track: NowPlaying?) { + // Many players report their position every second; only a real change goes downstream. + if (track != null && track.isSameMoment(playing.value)) return + playing.value = track + } +} + +/** + * Reads the media sessions of other apps (Spotify, YouTube Music, podcast players, ...) to share + * what the user is listening to as a NIP-38 music status. + * + * Android only hands out other apps' sessions to an enabled notification listener, which is why + * this is one: it never reads or posts notifications. The user grants it in the system's + * "Notification access" screen ([openAccessSettings]); until then the system never binds it. + * Whether anything is published is still up to the account's now-playing settings. + * + * Runs in the main process (it is not declared in `:napplet`), where the account lives. The + * system may start that process just to bind this listener; AppModules then logs the saved + * account in on its own, as it does on every start, so the publisher has it. + */ +class OtherAppsNowPlayingService : NotificationListenerService() { + private val handler = Handler(Looper.getMainLooper()) + private var sessionManager: MediaSessionManager? = null + private val watched = mutableMapOf>() + + // App labels come from PackageManager, an IPC; players report state up to once a second. + private val labels = mutableMapOf() + + private val sessionsListener = + MediaSessionManager.OnActiveSessionsChangedListener { controllers -> + watch(controllers.orEmpty()) + } + + override fun onListenerConnected() { + super.onListenerConnected() + val manager = getSystemService(MediaSessionManager::class.java) ?: return + sessionManager = manager + + try { + val component = ComponentName(this, OtherAppsNowPlayingService::class.java) + manager.addOnActiveSessionsChangedListener(sessionsListener, component, handler) + watch(manager.getActiveSessions(component)) + } catch (e: SecurityException) { + // Access was revoked between the bind and this call. + Log.w("OtherAppsNowPlaying", "Notification access is not granted", e) + } + } + + override fun onListenerDisconnected() { + sessionManager?.removeOnActiveSessionsChangedListener(sessionsListener) + sessionManager = null + unwatchAll() + OtherAppsPlaybackRegistry.update(null) + super.onListenerDisconnected() + } + + override fun onDestroy() { + sessionManager?.removeOnActiveSessionsChangedListener(sessionsListener) + unwatchAll() + OtherAppsPlaybackRegistry.update(null) + super.onDestroy() + } + + private fun watch(controllers: List) { + val others = controllers.filter { it.packageName != packageName } + val keys = others.mapTo(mutableSetOf()) { it.sessionToken } + + watched.keys.filter { it !in keys }.forEach { key -> + watched.remove(key)?.let { (controller, callback) -> controller.unregisterCallback(callback) } + } + + others.forEach { controller -> + val key = controller.sessionToken + if (key !in watched) { + val callback = + object : MediaController.Callback() { + override fun onPlaybackStateChanged(state: PlaybackState?) = refresh() + + override fun onMetadataChanged(metadata: MediaMetadata?) = refresh() + + override fun onSessionDestroyed() { + watched.remove(key)?.let { (it, cb) -> it.unregisterCallback(cb) } + refresh() + } + } + controller.registerCallback(callback, handler) + watched[key] = controller to callback + } + } + + refresh() + } + + private fun unwatchAll() { + watched.values.forEach { (controller, callback) -> controller.unregisterCallback(callback) } + watched.clear() + } + + /** The first session that is playing, in the system's priority order. */ + private fun refresh() { + val track = watched.values.firstNotNullOfOrNull { (controller, _) -> toNowPlaying(controller) } + OtherAppsPlaybackRegistry.update(track) + } + + private fun toNowPlaying(controller: MediaController): NowPlaying? { + val state = controller.playbackState ?: return null + if (state.state != PlaybackState.STATE_PLAYING) return null + + val metadata = controller.metadata ?: return null + val title = + metadata.getString(MediaMetadata.METADATA_KEY_TITLE)?.ifBlank { null } + ?: metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)?.ifBlank { null } + ?: return null + val artist = + metadata.getString(MediaMetadata.METADATA_KEY_ARTIST)?.ifBlank { null } + ?: metadata.getString(MediaMetadata.METADATA_KEY_ALBUM_ARTIST)?.ifBlank { null } + ?: metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE)?.ifBlank { null } + + val duration = metadata.getLong(MediaMetadata.METADATA_KEY_DURATION).takeIf { it > 0 } + val speed = state.playbackSpeed.takeIf { it > 0f } ?: 1f + val position = + if (state.lastPositionUpdateTime > 0) { + state.position + ((SystemClock.elapsedRealtime() - state.lastPositionUpdateTime) * speed).toLong() + } else { + state.position + } + + return NowPlaying( + title = title, + artist = artist, + source = NowPlayingSource.OtherApp(controller.packageName, appLabel(controller.packageName)), + endsAt = NowPlaying.endsAt(TimeUtils.now(), duration, position, speed), + ) + } + + private fun appLabel(packageName: String): String = + labels.getOrPut(packageName) { + try { + packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)).toString() + } catch (_: PackageManager.NameNotFoundException) { + packageName + } + } + + companion object { + fun hasAccess(context: Context): Boolean = context.packageName in NotificationManagerCompat.getEnabledListenerPackages(context) + + /** Opens the system screen where the user grants or revokes access to this listener. */ + fun openAccessSettings(context: Context) { + val component = ComponentName(context, OtherAppsNowPlayingService::class.java).flattenToString() + // Straight to this app's toggle where the system supports it, else the list of listeners. + val intents = + listOfNotNull( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Intent(Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS) + .putExtra(Settings.EXTRA_NOTIFICATION_LISTENER_COMPONENT_NAME, component) + } else { + null + }, + Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS), + ) + + for (intent in intents) { + try { + context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + return + } catch (e: ActivityNotFoundException) { + Log.w("OtherAppsNowPlaying", "No activity for ${intent.action}", e) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt index aa969245d4..888df68e76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt @@ -33,6 +33,7 @@ import androidx.media3.exoplayer.audio.AudioSink import androidx.media3.exoplayer.audio.DefaultAudioSink import androidx.media3.exoplayer.audio.TeeAudioProcessor import com.vitorpamplona.amethyst.commons.model.MediaAspectRatioCache +import com.vitorpamplona.amethyst.service.nowPlaying.InAppPlaybackListener import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache import com.vitorpamplona.amethyst.service.playback.playerPool.aspectRatio.AspectRatioCacher import com.vitorpamplona.amethyst.service.playback.playerPool.positions.CurrentPlayPositionCacher @@ -89,6 +90,7 @@ class ExoPlayerBuilder( addListener(KeepVideosPlaying(this)) addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache)) addListener(MediaPlayTimeTracker()) + addListener(InAppPlaybackListener()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt index 6e9c18c89c..c6de2f111e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt @@ -25,6 +25,7 @@ import androidx.annotation.OptIn import androidx.media3.common.C import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer +import com.vitorpamplona.amethyst.service.nowPlaying.InAppPlaybackRegistry import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineExceptionHandler @@ -132,6 +133,7 @@ class ExoPlayerPool( if (error != null) { Log.d("PlaybackService") { "ExoPlayerPool discarding errored warm player: $preferredMediaId (${error.errorCodeName})" } PcmTapRegistry.unregisterPlayer(warm) + InAppPlaybackRegistry.forget(warm) warm.release() releaseDecoder() } else { @@ -239,6 +241,7 @@ class ExoPlayerPool( if (error != null) { Log.d("PlaybackService") { "ExoPlayerPool dropping errored player: ${player.currentMediaItem?.mediaId} (${error.errorCodeName})" } PcmTapRegistry.unregisterPlayer(player) + InAppPlaybackRegistry.forget(player) player.release() releaseDecoder() return@withLock @@ -309,6 +312,7 @@ class ExoPlayerPool( } } else { PcmTapRegistry.unregisterPlayer(player) + InAppPlaybackRegistry.forget(player) player.release() // Release if pool is full. } } @@ -345,11 +349,13 @@ class ExoPlayerPool( } warmSnapshot.forEach { PcmTapRegistry.unregisterPlayer(it.player) + InAppPlaybackRegistry.forget(it.player) it.player.release() releaseDecoder() } coldPool.forEach { PcmTapRegistry.unregisterPlayer(it) + InAppPlaybackRegistry.forget(it) it.release() } coldPool.clear() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index b7271fb601..7898b809ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -170,6 +170,7 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.HiddenWord import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.HomeTabsSettingsScreen 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.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 @@ -202,6 +203,8 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.workouts.WorkoutsSc import com.vitorpamplona.amethyst.commons.ui.uriToRoute import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages +import com.vitorpamplona.amethyst.service.nowPlaying.AndroidAppIcon +import com.vitorpamplona.amethyst.service.nowPlaying.AndroidNowPlayingAccess import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthPromptHost import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert @@ -653,6 +656,11 @@ fun BuildNavigation( composableFromEnd { ProfileUiSettingsScreen(accountViewModel, nav) } composableFromEnd { VideoPlayerSettingsScreen(accountViewModel, nav) } composableFromEnd { CallSettingsScreen(accountViewModel, nav) } + composableFromEnd { + val context = LocalContext.current + val access = remember(context) { AndroidNowPlayingAccess(context.applicationContext) } + NowPlayingSettingsScreen(accountViewModel, nav, access) { appId, label -> AndroidAppIcon(appId, label) } + } composableFromEnd { NotificationSettingsScreen(accountViewModel, nav) } composableFromEnd { ResourceUsageScreen(accountViewModel, nav) } composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index e947d9f041..5190d02c82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -165,6 +165,7 @@ import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import org.jetbrains.compose.resources.StringResource @@ -355,7 +356,11 @@ private fun EditStatusBoxes( accountViewModel: AccountViewModel, nav: INav, ) { - val statuses by observeUserStatuses(baseAccountUser, accountViewModel) + val allStatuses by observeUserStatuses(baseAccountUser, accountViewModel) + + // The music status is written by the now-playing publisher, not typed here: editing it would + // overwrite the track and keep its expiration, so only the other statuses are editable. + val statuses = remember(allStatuses) { allStatuses.filter { it.address.dTag != UserStatusEvent.MUSIC } } if (statuses.isEmpty()) { PreviewStatusEditBar(accountViewModel = accountViewModel, nav = nav) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt index 886cbeea46..557380d38c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt @@ -93,6 +93,8 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListS import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.OwnedEmojiPacksState import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState import com.vitorpamplona.amethyst.commons.model.nip46Signer.Nip46ConsentPrompter import com.vitorpamplona.amethyst.commons.model.nip46Signer.Nip46SignerState import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcInfoCache @@ -533,6 +535,11 @@ class Account( // channel list. Restored/persisted per account by BuzzChannelStarStore. val buzzChannelStars = BuzzChannelStars() + // Whether THIS account shares what the user listens to as its NIP-38 music status, and from + // which apps. Per account because the status is signed and published by this key. Restored/ + // persisted per account by NowPlayingSettingsStore on Android. + val nowPlayingSettings = NowPlayingSettingsState() + // The NIP-OA attestation an owner issued to THIS account's key, attached to its Buzz-relay // AUTH so the relay grants virtual membership. Restored/persisted per account by // BuzzAttestationStore. @@ -3351,6 +3358,13 @@ class Account( suspend fun deleteStatus(oldStatus: AddressableNote) = sendMyPublicAndPrivateOutbox(UserStatusAction.delete(oldStatus, signer)) + suspend fun publishNowPlaying( + track: NowPlaying, + expiration: Long, + ) = sendMyPublicAndPrivateOutbox(UserStatusAction.createMusic(track, expiration, signer)) + + suspend fun clearNowPlaying() = sendMyPublicAndPrivateOutbox(UserStatusAction.clearMusic(signer)) + suspend fun removeEmojiPack(emojiPack: Note) = sendMyPublicAndPrivateOutbox(emoji.removeEmojiPack(emojiPack)) suspend fun addEmojiPack(emojiPack: Note) = sendMyPublicAndPrivateOutbox(emoji.addEmojiPack(emojiPack)) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt index 70b8daf092..f2a0d2e832 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt @@ -585,6 +585,8 @@ sealed class Route { @Serializable object CallSettings : Route() + @Serializable object NowPlayingSettings : Route() + @Serializable object NotificationSettings : Route() @Serializable object CalendarReminderSettings : Route() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusAction.kt index 4fac63e096..57499eb3cb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusAction.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusAction.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent @@ -34,6 +35,25 @@ class UserStatusAction { type: String = UserStatusEvent.GENERAL, ): UserStatusEvent = UserStatusEvent.create(newStatus, type, signer = signer) + /** A `music` status for [track] that expires at [expiration], when the track ends. */ + suspend fun createMusic( + track: NowPlaying, + expiration: Long, + signer: NostrSigner, + ): UserStatusEvent = + UserStatusEvent.create( + msg = track.statusText(), + type = UserStatusEvent.MUSIC, + expiration = expiration, + url = track.url, + eventId = track.eventId, + addressableId = track.addressId, + signer = signer, + ) + + /** Clears the `music` status: NIP-38 clears a status by replacing it with blank content. */ + suspend fun clearMusic(signer: NostrSigner): UserStatusEvent = UserStatusEvent.create("", UserStatusEvent.MUSIC, signer = signer) + suspend fun update( oldStatus: AddressableNote, newStatus: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt index fc04d0352a..c5a548cc31 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt @@ -42,11 +42,16 @@ class UserStatusCache : UserDependencies { ) fun addStatus(note: AddressableNote) { - // if it's already there, quick exit - if (statuses.value.contains(note) || note.isEmptyStatus()) return + // A newer version of a listed status can be blank (NIP-38 clears a status that way, and + // a music status is cleared every time playback stops) or already expired: the note is + // the same object, so it has to leave the list rather than be skipped as already there. + if (note.isEmptyStatus() || note.event?.isExpired() == true) { + removeStatus(note) + return + } - // don't add expired statuses - if (note.event?.isExpired() == true) return + // if it's already there, quick exit + if (statuses.value.contains(note)) return statuses.update { (it + note).sortedWith(sortModel).toImmutableList() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlaying.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlaying.kt new file mode 100644 index 0000000000..32287b1968 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlaying.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.math.abs + +/** Where a track is being played. */ +@Immutable +sealed interface NowPlayingSource { + /** Amethyst's own player (a music track or podcast episode opened in the app). */ + data object InApp : NowPlayingSource + + /** + * Another app on this device. [id] is stable across sessions (an Android package name, an + * MPRIS bus name, a Windows app id) and is what the per-app block list keys on; [label] is + * what the settings screen shows. + */ + data class OtherApp( + val id: String, + val label: String, + ) : NowPlayingSource +} + +/** + * A track (song or podcast episode) the user is playing right now, ready to become a NIP-38 + * `music` status. + * + * [endsAt] is the Unix time (seconds) the track will finish at its current position and speed, + * which becomes the status' NIP-40 expiration, as NIP-38 asks for music. It is null when the + * length is unknown (a live stream, an app that does not report a duration). + * + * [url], [addressId] and [eventId] become the status' `r`, `a` and `e` tags, so readers can open + * what is playing: in-app tracks point at their Nostr event. + */ +@Immutable +data class NowPlaying( + val title: String, + val artist: String? = null, + val source: NowPlayingSource, + val endsAt: Long? = null, + val url: String? = null, + val addressId: String? = null, + val eventId: HexKey? = null, +) { + /** The status line, in the `Title - Artist` shape of the NIP-38 example. */ + fun statusText(): String = if (artist.isNullOrBlank()) title else "$title - $artist" + + /** Same track, regardless of the playback position. */ + fun isSameTrack(other: NowPlaying): Boolean = + title == other.title && + artist == other.artist && + url == other.url && + addressId == other.addressId && + eventId == other.eventId + + /** + * Same track from the same source, ending within [toleranceSeconds] of [other]. Sources call + * this to drop the position ticks some players report every second, which would otherwise + * re-emit a "new" value (and re-run everything downstream) for a status that has not changed. + */ + fun isSameMoment( + other: NowPlaying?, + toleranceSeconds: Long = MOMENT_TOLERANCE_SECONDS, + ): Boolean { + if (other == null || source != other.source || !isSameTrack(other)) return false + val a = endsAt + val b = other.endsAt + return if (a == null || b == null) a == b else abs(a - b) <= toleranceSeconds + } + + companion object { + const val MOMENT_TOLERANCE_SECONDS = 5L + + /** + * When a track [durationMs] long, now at [positionMs] and playing at [speed], ends, + * measured from [nowSeconds]. Null when the duration is unknown or the track already ended. + */ + fun endsAt( + nowSeconds: Long, + durationMs: Long?, + positionMs: Long, + speed: Float = 1f, + ): Long? { + if (durationMs == null || durationMs <= 0) return null + val remainingMs = durationMs - positionMs.coerceAtLeast(0) + if (remainingMs <= 0) return null + val effectiveSpeed = if (speed > 0f) speed else 1f + return nowSeconds + (remainingMs / effectiveSpeed / 1000).toLong() + 1 + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingAccess.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingAccess.kt new file mode 100644 index 0000000000..44d3f6db59 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingAccess.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying + +/** + * The OS permission that sharing other apps' playback depends on. On Android that is notification + * access, which only the user can grant in system settings; platforms that need nothing pass no + * port at all. + */ +interface NowPlayingAccess { + fun hasAccess(): Boolean + + /** Takes the user to where the access is granted or revoked. */ + fun requestAccess() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisher.kt new file mode 100644 index 0000000000..55a6899d0e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisher.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying + +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.abs + +/** + * Picks what to share out of the tracks each source reports, in priority order (the in-app + * player first), skipping the sources [settings] does not allow. + */ +fun pickNowPlaying( + settings: NowPlayingSettings, + vararg candidates: NowPlaying?, +): NowPlaying? = candidates.firstOrNull { it != null && settings.isAllowed(it.source) } + +/** + * Turns a stream of "what is playing now" (null when nothing is) into NIP-38 `music` statuses. + * + * - A new track is published only after it has played for [Config.startDelayMs], so skipping + * through a playlist does not sign and send an event per skipped song. + * - The status expires when the track ends (NIP-38 asks music statuses for that), so a crash or a + * dead battery never leaves a stale "listening to". When the length is unknown it expires after + * [Config.unknownLengthTtlSeconds] and is refreshed shortly before that while still playing. + * - A seek or a resume that moves the end by more than [Config.toleranceSeconds] republishes the + * same track with the corrected expiration. + * - When playback stops, the status is cleared (blank content, as NIP-38 says) after + * [Config.clearDelayMs], so a short pause or a buffering stall does not clear and re-post it. + * + * Runs on a single coroutine ([run]); a newer value cancels whatever wait the previous one was in. + */ +class NowPlayingPublisher( + private val publish: suspend (track: NowPlaying, expiration: Long) -> Unit, + private val clear: suspend () -> Unit, + private val nowSeconds: () -> Long = TimeUtils::now, + private val config: Config = Config(), +) { + data class Config( + val startDelayMs: Long = 5_000, + val clearDelayMs: Long = 30_000, + val unknownLengthTtlSeconds: Long = 600, + val refreshLeadSeconds: Long = 60, + val toleranceSeconds: Long = 20, + ) + + private var published: NowPlaying? = null + private var publishedExpiration: Long = 0 + + suspend fun run(tracks: Flow) { + tracks + .distinctUntilChanged(::isEquivalent) + .collectLatest { track -> + if (track == null) stopped() else playing(track) + } + } + + private fun isEquivalent( + old: NowPlaying?, + new: NowPlaying?, + ): Boolean = + when { + old == null || new == null -> old == new + old.source != new.source || !old.isSameTrack(new) -> false + else -> isSameEnd(old.endsAt, new.endsAt) + } + + private fun isSameEnd( + a: Long?, + b: Long?, + ) = if (a == null || b == null) a == b else abs(a - b) <= config.toleranceSeconds + + /** The status on relays still shows [track]. */ + private fun isLive(track: NowPlaying) = published?.isSameTrack(track) == true && publishedExpiration > nowSeconds() + + private suspend fun playing(track: NowPlaying) { + if (!isLive(track)) delay(config.startDelayMs) + + while (true) { + val now = nowSeconds() + val expiration = track.endsAt?.takeIf { it > now } ?: (now + config.unknownLengthTtlSeconds) + + if (!isLive(track) || abs(publishedExpiration - expiration) > config.toleranceSeconds) { + send(track, expiration) + } + + // A known end needs nothing more: the next track, a seek or a stop comes in as a new value. + if (track.endsAt != null) return + + val refreshIn = (publishedExpiration - nowSeconds() - config.refreshLeadSeconds).coerceAtLeast(MIN_REFRESH_SECONDS) + delay(refreshIn * 1000) + } + } + + private suspend fun stopped() { + if (published == null) return + + if (publishedExpiration <= nowSeconds()) { + // Relays already dropped it. + published = null + return + } + + delay(config.clearDelayMs) + + try { + clear() + published = null + publishedExpiration = 0 + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("NowPlayingPublisher", "Could not clear the music status", e) + } + } + + private suspend fun send( + track: NowPlaying, + expiration: Long, + ) { + try { + publish(track, expiration) + published = track + publishedExpiration = expiration + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("NowPlayingPublisher", "Could not publish the music status", e) + } + } + + companion object { + private const val MIN_REFRESH_SECONDS = 60L + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolver.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolver.kt new file mode 100644 index 0000000000..8d203fc781 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolver.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying + +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.podcasts.PodcastEpisode + +/** + * Turns the Nostr event behind an in-app player into a [NowPlaying], or null when the event is + * not something to share as a listening status. + * + * Only music tracks (kind 36787), audio tracks (kind 31337) and podcast episodes (both drafts) + * qualify. Everything else the app plays (feed videos, voice messages, live streams) returns null, + * so autoplaying a clip never announces itself as "listening to". + * + * [fallbackTitle] and [fallbackArtist] are what the player shows (the event's title and the + * author's display name) and fill the gaps an event leaves. + */ +object NowPlayingResolver { + /** + * Resolves the `nostr:` URI the in-app player carries (`Note.toNostrUri()`) to its event with + * [findEvent] (given an event id or an `kind:pubkey:d` address) and then calls [fromEvent]. + */ + fun fromNostrUri( + uri: String, + findEvent: (key: String) -> Event?, + fallbackTitle: String? = null, + fallbackArtist: String? = null, + endsAt: Long? = null, + ): NowPlaying? { + val key = noteKey(uri) ?: return null + val event = findEvent(key) ?: return null + return fromEvent(event, fallbackTitle, fallbackArtist, endsAt) + } + + /** The cache key (event id, or `kind:pubkey:d` address) a `nostr:` note URI points at. */ + fun noteKey(uri: String): String? = + when (val entity = Nip19Parser.uriToRoute(uri)?.entity) { + is NEvent -> entity.hex + is NNote -> entity.hex + is NAddress -> entity.aTag() + else -> null + } + + fun fromEvent( + event: Event, + fallbackTitle: String? = null, + fallbackArtist: String? = null, + endsAt: Long? = null, + ): NowPlaying? { + val title: String? + val artist: String? + + when (event) { + is MusicTrackEvent -> { + title = event.title() + artist = event.artist() ?: fallbackArtist + } + + is AudioTrackEvent -> { + title = event.subject() + artist = fallbackArtist + } + + is PodcastEpisode -> { + title = event.episodeTitle() + artist = fallbackArtist + } + + else -> { + return null + } + } + + val bestTitle = title?.ifBlank { null } ?: fallbackTitle?.ifBlank { null } ?: return null + + return NowPlaying( + title = bestTitle, + artist = artist?.ifBlank { null }, + source = NowPlayingSource.InApp, + endsAt = endsAt, + addressId = (event as? AddressableEvent)?.address()?.toValue(), + eventId = if (event is AddressableEvent) null else event.id, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingSettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingSettings.kt new file mode 100644 index 0000000000..95139b35f0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingSettings.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +/** + * What this account shares as its NIP-38 `music` status. Everything is off by default: a listening + * status is public, so the user opts in. + * + * - [shareInApp]: tracks and podcast episodes played in Amethyst. + * - [shareOtherApps]: whatever another app on this device reports through the OS media controls + * (needs a platform permission on Android), except the apps in [blockedApps]. + * - [knownApps]: the other apps seen playing so far (id to label), listed on the settings screen + * so the user can block one. + */ +@Immutable +data class NowPlayingSettings( + val shareInApp: Boolean = false, + val shareOtherApps: Boolean = false, + val blockedApps: Set = emptySet(), + val knownApps: Map = emptyMap(), +) { + fun isEnabled() = shareInApp || shareOtherApps + + fun isAllowed(source: NowPlayingSource): Boolean = + when (source) { + NowPlayingSource.InApp -> shareInApp + is NowPlayingSource.OtherApp -> shareOtherApps && source.id !in blockedApps + } +} + +/** + * Per-account holder for [NowPlayingSettings] (`Account.nowPlayingSettings`). The platform + * restores and persists it (Android: `NowPlayingSettingsStore`). + */ +@Stable +class NowPlayingSettingsState { + private val state = MutableStateFlow(NowPlayingSettings()) + + val flow: StateFlow = state + + fun restore(settings: NowPlayingSettings) = state.update { settings } + + fun setShareInApp(enabled: Boolean) = state.update { it.copy(shareInApp = enabled) } + + fun setShareOtherApps(enabled: Boolean) = state.update { it.copy(shareOtherApps = enabled) } + + fun setAppBlocked( + appId: String, + blocked: Boolean, + ) = state.update { it.copy(blockedApps = if (blocked) it.blockedApps + appId else it.blockedApps - appId) } + + /** Records an app seen playing, so the settings screen can offer to block it. */ + fun rememberApp( + appId: String, + label: String, + ) { + if (state.value.knownApps[appId] == label) return + state.update { it.copy(knownApps = it.knownApps + (appId to label)) } + } + + fun forgetApp(appId: String) = + state.update { + it.copy(knownApps = it.knownApps - appId, blockedApps = it.blockedApps - appId) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingStatusService.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingStatusService.kt new file mode 100644 index 0000000000..cc82f52b05 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingStatusService.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onEach + +/** + * Wires the platform's playback sources to a [NowPlayingPublisher] under one account's + * [settings]. [sources] are in priority order: the first one playing an allowed track wins (the + * in-app player goes first, since the user is looking at it). + * + * Every other app seen playing while [NowPlayingSettings.shareOtherApps] is on is reported to + * [onOtherAppSeen], so the settings screen can list it and let the user block it. + */ +class NowPlayingStatusService( + private val settings: StateFlow, + private val sources: List>, + private val onOtherAppSeen: (NowPlayingSource.OtherApp) -> Unit, + private val publisher: NowPlayingPublisher, +) { + suspend fun run() { + val observed = + sources.map { source -> + source.onEach { track -> + val app = track?.source as? NowPlayingSource.OtherApp + if (app != null && settings.value.shareOtherApps) onOtherAppSeen(app) + } + } + + val tracks = + combine(listOf(settings) + observed) { values -> + val current = values[0] as NowPlayingSettings + pickNowPlaying(current, *Array(values.size - 1) { values[it + 1] as NowPlaying? }) + } + + publisher.run(tracks) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NowPlayingSettingsStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NowPlayingSettingsStore.kt new file mode 100644 index 0000000000..74da2d9b31 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NowPlayingSettingsStore.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettings +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +/** + * Per-account persistence for [NowPlayingSettings]. Keys are namespaced by pubkey because the + * status is published by that account. Loads the saved settings into [state] on construction, then + * writes every later change back. Construct once per account, eagerly. + */ +@Stable +class NowPlayingSettingsStore( + private val store: DataStore, + private val scope: CoroutineScope, + pubKeyHex: HexKey, + private val state: NowPlayingSettingsState, +) { + private val shareInApp = booleanPreferencesKey("$KEY_PREFIX$pubKeyHex.shareInApp") + private val shareOtherApps = booleanPreferencesKey("$KEY_PREFIX$pubKeyHex.shareOtherApps") + private val blockedApps = stringSetPreferencesKey("$KEY_PREFIX$pubKeyHex.blockedApps") + private val knownApps = stringSetPreferencesKey("$KEY_PREFIX$pubKeyHex.knownApps") + + init { + scope.launch { + restoreFromDisk() + // drop(1) skips the value present at collection start, which restoreFromDisk already wrote. + state.flow.drop(1).collect { persist(it) } + } + } + + private suspend fun restoreFromDisk() { + try { + val prefs = store.data.first() + state.restore( + NowPlayingSettings( + shareInApp = prefs[shareInApp] ?: false, + shareOtherApps = prefs[shareOtherApps] ?: false, + blockedApps = prefs[blockedApps] ?: emptySet(), + knownApps = decodeApps(prefs[knownApps] ?: emptySet()), + ), + ) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("NowPlayingSettingsStore") { "Error reading now playing settings: ${e.message}" } + } + } + + private suspend fun persist(value: NowPlayingSettings) { + try { + store.edit { prefs -> + prefs[shareInApp] = value.shareInApp + prefs[shareOtherApps] = value.shareOtherApps + prefs[blockedApps] = value.blockedApps + prefs[knownApps] = encodeApps(value.knownApps) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("NowPlayingSettingsStore") { "Error writing now playing settings: ${e.message}" } + } + } + + companion object { + private const val KEY_PREFIX = "nowPlaying." + private const val SEPARATOR = '\t' + + fun encodeApps(apps: Map): Set = apps.mapTo(mutableSetOf()) { (id, label) -> "$id$SEPARATOR$label" } + + fun decodeApps(raw: Set): Map = + raw + .mapNotNull { entry -> + val split = entry.indexOf(SEPARATOR) + if (split <= 0) null else entry.substring(0, split) to entry.substring(split + 1) + }.toMap() + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCacheTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCacheTest.kt new file mode 100644 index 0000000000..d175a52fb8 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCacheTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.UserContext +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent +import kotlin.test.Test +import kotlin.test.assertEquals + +class UserStatusCacheTest { + private val pubKey = "b".repeat(64) + private val sig = "c".repeat(128) + private val author = User(pubKey, UserContext { addr -> AddressableNote(addr) }) + + private fun status( + id: Char, + content: String, + createdAt: Long, + ) = UserStatusEvent(id.toString().repeat(64), pubKey, createdAt, arrayOf(arrayOf("d", UserStatusEvent.MUSIC)), content, sig) + + @Test + fun aClearedStatusLeavesTheList() { + val cache = UserStatusCache() + val note = AddressableNote(Address(UserStatusEvent.KIND, pubKey, UserStatusEvent.MUSIC)) + + note.loadEvent(status('1', "Song - Band", 100), author, emptyList()) + cache.addStatus(note) + assertEquals(listOf(note), cache.statuses.value) + + // The same address now holds the newer, blank version: nothing to show any more. + note.loadEvent(status('2', "", 200), author, emptyList()) + cache.addStatus(note) + assertEquals(emptyList(), cache.statuses.value) + + // And a new track brings it back. + note.loadEvent(status('3', "Other Song - Band", 300), author, emptyList()) + cache.addStatus(note) + assertEquals(listOf(note), cache.statuses.value) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisherTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisherTest.kt new file mode 100644 index 0000000000..c4761cb6b1 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisherTest.kt @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class NowPlayingPublisherTest { + private val base = 1_700_000_000L + + private class Recorder { + val published = mutableListOf>() + var clears = 0 + } + + private fun TestScope.now() = base + testScheduler.currentTime / 1000 + + private fun TestScope.start(source: MutableStateFlow): Recorder { + val recorder = Recorder() + val publisher = + NowPlayingPublisher( + publish = { track, expiration -> recorder.published.add(track to expiration) }, + clear = { recorder.clears++ }, + nowSeconds = { now() }, + ) + backgroundScope.launch { publisher.run(source) } + runCurrent() + return recorder + } + + private fun TestScope.track( + title: String, + remainingSeconds: Long? = 180, + ) = NowPlaying( + title = title, + artist = "Artist", + source = NowPlayingSource.InApp, + endsAt = remainingSeconds?.let { now() + it }, + ) + + @Test + fun publishesAfterTheStartDelayWithTheTrackEndAsExpiration() = + runTest { + val source = MutableStateFlow(null) + val recorder = start(source) + + val song = track("Song") + source.value = song + advanceTimeBy(4_000) + assertEquals(0, recorder.published.size) + + advanceTimeBy(2_000) + assertEquals(1, recorder.published.size) + assertEquals(song, recorder.published[0].first) + assertEquals(song.endsAt, recorder.published[0].second) + } + + @Test + fun skippingThroughTracksOnlyPublishesTheOneThatKeepsPlaying() = + runTest { + val source = MutableStateFlow(null) + val recorder = start(source) + + source.value = track("One") + advanceTimeBy(2_000) + source.value = track("Two") + advanceTimeBy(2_000) + source.value = track("Three") + advanceTimeBy(10_000) + + assertEquals(listOf("Three"), recorder.published.map { it.first.title }) + } + + @Test + fun aShortPauseNeitherClearsNorRepublishes() = + runTest { + val source = MutableStateFlow(null) + val recorder = start(source) + + source.value = track("Song", remainingSeconds = 180) + advanceTimeBy(6_000) + assertEquals(1, recorder.published.size) + + source.value = null + advanceTimeBy(10_000) + // Resumed 10s later: the end moved by 10s, within the tolerance. + source.value = track("Song", remainingSeconds = 174) + advanceTimeBy(60_000) + + assertEquals(0, recorder.clears) + assertEquals(1, recorder.published.size) + } + + @Test + fun stoppingClearsTheStatusAfterTheClearDelay() = + runTest { + val source = MutableStateFlow(null) + val recorder = start(source) + + source.value = track("Song") + advanceTimeBy(6_000) + source.value = null + advanceTimeBy(29_000) + assertEquals(0, recorder.clears) + + advanceTimeBy(2_000) + assertEquals(1, recorder.clears) + } + + @Test + fun aStatusThatAlreadyExpiredIsNotCleared() = + runTest { + val source = MutableStateFlow(null) + val recorder = start(source) + + source.value = track("Short", remainingSeconds = 20) + advanceTimeBy(30_000) + source.value = null + advanceTimeBy(120_000) + + assertEquals(1, recorder.published.size) + assertEquals(0, recorder.clears) + } + + @Test + fun seekingRepublishesWithTheNewEnd() = + runTest { + val source = MutableStateFlow(null) + val recorder = start(source) + + source.value = track("Song", remainingSeconds = 180) + advanceTimeBy(6_000) + val seeked = track("Song", remainingSeconds = 60) + source.value = seeked + runCurrent() + + assertEquals(2, recorder.published.size) + assertEquals(seeked.endsAt, recorder.published[1].second) + } + + @Test + fun anUnknownLengthIsRefreshedBeforeItExpires() = + runTest { + val source = MutableStateFlow(null) + val recorder = start(source) + + source.value = track("Radio", remainingSeconds = null) + advanceTimeBy(6_000) + assertEquals(1, recorder.published.size) + assertEquals(now() - 1 + 600, recorder.published[0].second) + + // Refreshed a minute before the 10-minute expiration. + advanceTimeBy(541_000) + assertEquals(2, recorder.published.size) + } + + @Test + fun pickHonorsSettingsAndTheBlockList() { + val inApp = NowPlaying("In app", source = NowPlayingSource.InApp) + val spotify = NowPlaying("Other", source = NowPlayingSource.OtherApp("com.spotify.music", "Spotify")) + + assertNull(pickNowPlaying(NowPlayingSettings(), inApp, spotify)) + assertEquals(inApp, pickNowPlaying(NowPlayingSettings(shareInApp = true, shareOtherApps = true), inApp, spotify)) + assertEquals(spotify, pickNowPlaying(NowPlayingSettings(shareOtherApps = true), inApp, spotify)) + assertEquals(spotify, pickNowPlaying(NowPlayingSettings(shareInApp = true, shareOtherApps = true), null, spotify)) + assertNull( + pickNowPlaying( + NowPlayingSettings(shareOtherApps = true, blockedApps = setOf("com.spotify.music")), + null, + spotify, + ), + ) + } + + @Test + fun statusTextFollowsTheNipExample() { + assertEquals("Intergalactic - Beastie Boys", NowPlaying("Intergalactic", "Beastie Boys", NowPlayingSource.InApp).statusText()) + assertEquals("Intergalactic", NowPlaying("Intergalactic", null, NowPlayingSource.InApp).statusText()) + } + + @Test + fun aPositionTickIsTheSameMomentButASeekIsNot() { + val song = NowPlaying("Song", "Band", NowPlayingSource.InApp, endsAt = 1_000) + + assertTrue(song.isSameMoment(song.copy(endsAt = 1_004))) + assertFalse(song.isSameMoment(song.copy(endsAt = 1_060))) + assertFalse(song.isSameMoment(song.copy(title = "Other"))) + assertFalse(song.isSameMoment(song.copy(source = NowPlayingSource.OtherApp("x", "X")))) + assertFalse(song.isSameMoment(null)) + assertTrue(song.copy(endsAt = null).isSameMoment(song.copy(endsAt = null))) + } + + @Test + fun endsAtAccountsForPositionAndSpeed() { + assertEquals(1_000L + 60 + 1, NowPlaying.endsAt(1_000, durationMs = 180_000, positionMs = 120_000)) + assertEquals(1_000L + 30 + 1, NowPlaying.endsAt(1_000, durationMs = 180_000, positionMs = 120_000, speed = 2f)) + assertNull(NowPlaying.endsAt(1_000, durationMs = null, positionMs = 0)) + assertNull(NowPlaying.endsAt(1_000, durationMs = 1_000, positionMs = 2_000)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolverTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolverTest.kt new file mode 100644 index 0000000000..d09a83b28f --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolverTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying + +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class NowPlayingResolverTest { + private val id = "a".repeat(64) + private val pubKey = "b".repeat(64) + private val sig = "c".repeat(128) + + @Test + fun musicTrackPointsAtItsAddress() { + val event = + MusicTrackEvent( + id, + pubKey, + 1L, + arrayOf(arrayOf("d", "song"), arrayOf("title", "Song"), arrayOf("artist", "Band")), + "", + sig, + ) + + val track = NowPlayingResolver.fromEvent(event, fallbackArtist = "Uploader", endsAt = 10L)!! + + assertEquals("Song - Band", track.statusText()) + assertEquals("${MusicTrackEvent.KIND}:$pubKey:song", track.addressId) + assertNull(track.eventId) + assertEquals(10L, track.endsAt) + assertEquals(NowPlayingSource.InApp, track.source) + } + + @Test + fun podcastEpisodeUsesTheShowAsArtistAndPointsAtTheEvent() { + val event = PodcastEpisodeEvent(id, pubKey, 1L, arrayOf(arrayOf("title", "Episode 1")), "", sig) + + val track = NowPlayingResolver.fromEvent(event, fallbackArtist = "The Show")!! + + assertEquals("Episode 1 - The Show", track.statusText()) + assertEquals(id, track.eventId) + assertNull(track.addressId) + } + + @Test + fun otherEventsAreNotShared() { + val event = TextNoteEvent(id, pubKey, 1L, emptyArray(), "a video", sig) + + assertNull(NowPlayingResolver.fromEvent(event, fallbackTitle = "video", fallbackArtist = "someone")) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NowPlayingSettingsStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NowPlayingSettingsStoreTest.kt new file mode 100644 index 0000000000..e133edfa5a --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NowPlayingSettingsStoreTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettings +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class NowPlayingSettingsStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private fun raw(): DataStore { + val file = File(folder.root, "shared.preferences_pb") + return PreferenceDataStoreFactory.createWithPath( + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + produceFile = { file.toOkioPath() }, + ) + } + + @Test + fun appLabelsSurviveTheEncoding() { + val apps = mapOf("com.spotify.music" to "Spotify", "org.mpris.MediaPlayer2.vlc" to "VLC media player") + + assertEquals(apps, NowPlayingSettingsStore.decodeApps(NowPlayingSettingsStore.encodeApps(apps))) + assertEquals(emptyMap(), NowPlayingSettingsStore.decodeApps(setOf("no-separator", "\tno-id"))) + } + + @Test + fun settingsAreWrittenAndRestoredPerAccount() = + runBlocking { + val store = raw() + val expected = + NowPlayingSettings( + shareInApp = true, + shareOtherApps = true, + blockedApps = setOf("com.google.android.youtube"), + knownApps = mapOf("com.google.android.youtube" to "YouTube", "com.spotify.music" to "Spotify"), + ) + + val writerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val written = NowPlayingSettingsState() + NowPlayingSettingsStore(store, writerScope, "alice", written) + // Let the store finish its initial restore before changing anything. + withTimeout(5_000) { store.data.first() } + Thread.sleep(200) + written.setShareInApp(true) + written.setShareOtherApps(true) + written.rememberApp("com.google.android.youtube", "YouTube") + written.rememberApp("com.spotify.music", "Spotify") + written.setAppBlocked("com.google.android.youtube", true) + + withTimeout(5_000) { + // The block is the last change made, so once it is on disk everything is. + while (store.data.first()[stringSetPreferencesKey("nowPlaying.alice.blockedApps")].isNullOrEmpty()) Thread.sleep(20) + } + writerScope.cancel() + + val readerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val restored = NowPlayingSettingsState() + NowPlayingSettingsStore(store, readerScope, "alice", restored) + withTimeout(5_000) { restored.flow.first { it.isEnabled() } } + assertEquals(expected, restored.flow.value) + + // Another account on the same file starts from the defaults. + val other = NowPlayingSettingsState() + NowPlayingSettingsStore(store, readerScope, "bob", other) + Thread.sleep(200) + assertEquals(NowPlayingSettings(), other.flow.value) + readerScope.cancel() + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 0bcd36e69f..0ce1c6a5de 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -6362,6 +6362,27 @@ This file type can run code. Check that the name matches what you meant to get. Save From %1$s + Now Playing + music listening status song podcast spotify nip38 share playing + Your status is public. It appears under your name and disappears when the track ends or you stop playing. + How others see it + Song title - Artist + Live + Example + Off + You + Share from + Amethyst + Music tracks and podcast episodes you play here + Other apps + Spotify, YouTube Music, podcast players and any app with media controls + Notification access needed + Android only shows other apps’ media controls to apps with notification access. Amethyst reads what is playing, never the content of your notifications. + Grant access + Manage + Notification access is on + Apps + Apps show up here after they play something. Turn one off to stop sharing it. Prediction market Open Closed diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsScreen.kt new file mode 100644 index 0000000000..8dc824086f --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsScreen.kt @@ -0,0 +1,559 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect +import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons +import com.vitorpamplona.amethyst.commons.hashtags.Tunestr +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingAccess +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState +import com.vitorpamplona.amethyst.commons.relayClient.event.observeNote +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserName +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserStatuses +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.now_playing_access_granted +import com.vitorpamplona.amethyst.commons.resources.now_playing_access_needed +import com.vitorpamplona.amethyst.commons.resources.now_playing_access_needed_title +import com.vitorpamplona.amethyst.commons.resources.now_playing_apps +import com.vitorpamplona.amethyst.commons.resources.now_playing_apps_empty +import com.vitorpamplona.amethyst.commons.resources.now_playing_description +import com.vitorpamplona.amethyst.commons.resources.now_playing_grant_access +import com.vitorpamplona.amethyst.commons.resources.now_playing_manage_access +import com.vitorpamplona.amethyst.commons.resources.now_playing_preview_example +import com.vitorpamplona.amethyst.commons.resources.now_playing_preview_live +import com.vitorpamplona.amethyst.commons.resources.now_playing_preview_off +import com.vitorpamplona.amethyst.commons.resources.now_playing_preview_sample +import com.vitorpamplona.amethyst.commons.resources.now_playing_preview_title +import com.vitorpamplona.amethyst.commons.resources.now_playing_preview_you +import com.vitorpamplona.amethyst.commons.resources.now_playing_section_sources +import com.vitorpamplona.amethyst.commons.resources.now_playing_settings +import com.vitorpamplona.amethyst.commons.resources.now_playing_share_in_app +import com.vitorpamplona.amethyst.commons.resources.now_playing_share_in_app_description +import com.vitorpamplona.amethyst.commons.resources.now_playing_share_other_apps +import com.vitorpamplona.amethyst.commons.resources.now_playing_share_other_apps_description +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.commons.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * Settings for sharing what the user listens to as their NIP-38 music status. + * + * [access] is the platform permission other apps' playback needs (Android: notification access); + * null where none is needed. [appIcon] draws a real app icon where the platform can (Android); + * without it each app gets a colored monogram. + */ +@Composable +fun NowPlayingSettingsScreen( + accountViewModel: AccountViewModel, + nav: INav, + access: NowPlayingAccess? = null, + appIcon: (@Composable (appId: String, label: String) -> Unit)? = null, +) { + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(Res.string.now_playing_settings), nav) + }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + NowPlayingSettingsContent( + state = accountViewModel.account.nowPlayingSettings, + access = access, + appIcon = appIcon, + preview = { isOn -> AccountStatusPreview(accountViewModel, nav, isOn) }, + ) + } + } +} + +/** + * The body of the settings screen: a preview of the status as others see it, the two sources, + * notification access, and the per-app list. It does not scroll, so front ends with their own + * settings layout (desktop) can embed it. [showInApp] hides the in-app source where the app has + * no music or podcast player of its own; [preview] replaces the anonymous preview card with one + * showing the account (it receives whether sharing is on at all). + */ +@Composable +fun NowPlayingSettingsContent( + state: NowPlayingSettingsState, + access: NowPlayingAccess?, + modifier: Modifier = Modifier, + showInApp: Boolean = true, + appIcon: (@Composable (appId: String, label: String) -> Unit)? = null, + preview: @Composable (isOn: Boolean) -> Unit = { isOn -> + NowPlayingPreviewCard( + avatar = { MonogramBadge(stringRes(Res.string.now_playing_preview_you), size = 44, shape = CircleShape) }, + name = stringRes(Res.string.now_playing_preview_you), + liveStatus = null, + isOn = isOn, + ) + }, +) { + val settings by state.flow.collectAsState() + val isOn = (showInApp && settings.shareInApp) || settings.shareOtherApps + // One check per resume for both the confirmation row and the alert card. + val accessGranted = access == null || rememberAccessGranted(access) + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionTitle(stringRes(Res.string.now_playing_preview_title)) + preview(isOn) + Text( + text = stringRes(Res.string.now_playing_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } + + SettingsSection(Res.string.now_playing_section_sources) { + if (showInApp) { + SettingsSwitchTile( + icon = MaterialSymbols.Headphones, + title = Res.string.now_playing_share_in_app, + description = Res.string.now_playing_share_in_app_description, + checked = settings.shareInApp, + onCheckedChange = state::setShareInApp, + ) + SettingsDivider() + } + SettingsSwitchTile( + icon = MaterialSymbols.Apps, + title = Res.string.now_playing_share_other_apps, + description = Res.string.now_playing_share_other_apps_description, + checked = settings.shareOtherApps, + onCheckedChange = state::setShareOtherApps, + ) + if (settings.shareOtherApps && access != null && accessGranted) { + AccessGrantedRow(access) + } + } + + if (settings.shareOtherApps && access != null && !accessGranted) { + AccessNeededCard(access) + } + + if (settings.shareOtherApps) { + SettingsSection(Res.string.now_playing_apps) { + if (settings.knownApps.isEmpty()) { + Text( + text = stringRes(Res.string.now_playing_apps_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp), + ) + } else { + settings.knownApps.entries + .sortedBy { it.value.lowercase() } + .forEachIndexed { index, (id, label) -> + if (index > 0) SettingsDivider() + AppToggleRow( + id = id, + label = label, + checked = id !in settings.blockedApps, + appIcon = appIcon, + onCheckedChange = { allowed -> state.setAppBlocked(id, !allowed) }, + ) + } + } + } + } + } +} + +/** The account's own avatar, name and live music status, exactly as other users see them. */ +@Composable +private fun AccountStatusPreview( + accountViewModel: AccountViewModel, + nav: INav, + isOn: Boolean, +) { + val user = remember(accountViewModel) { accountViewModel.account.userProfile() } + val name by observeUserName(user, accountViewModel) + val statuses by observeUserStatuses(user, accountViewModel) + val music = statuses.firstOrNull { it.address.dTag == UserStatusEvent.MUSIC } + + val avatar = @Composable { ClickableUserPicture(user, 44.dp, accountViewModel) } + + if (music == null) { + NowPlayingPreviewCard(avatar, name, liveStatus = null, isOn = isOn) + } else { + LiveMusicStatus(music, accountViewModel) { text -> + NowPlayingPreviewCard(avatar, name, liveStatus = text, isOn = isOn) + } + } +} + +@Composable +private fun LiveMusicStatus( + note: AddressableNote, + accountViewModel: AccountViewModel, + content: @Composable (String?) -> Unit, +) { + val noteState by observeNote(note, accountViewModel) + content( + noteState.note.event + ?.content + ?.ifBlank { null }, + ) +} + +/** + * A miniature of how the status renders under the user's name in feeds: the avatar, the name and + * the music line with the same icon and color the real status uses. Shows [liveStatus] when one + * is published, otherwise an example, dimmed while sharing is off. + */ +@Composable +fun NowPlayingPreviewCard( + avatar: @Composable () -> Unit, + name: String, + liveStatus: String?, + isOn: Boolean, +) { + val contentAlpha = if (liveStatus != null || isOn) 1f else 0.55f + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Sized, not clipped: the avatar rounds itself, and the follow mark and user cards it + // draws sit in the corners of its square box, which a circular clip would cut off. + Box(Modifier.size(44.dp)) { avatar() } + + Column( + modifier = Modifier.weight(1f).padding(start = 14.dp, end = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = CustomHashTagIcons.Tunestr, + contentDescription = null, + modifier = Modifier.size(15.dp).padding(end = 4.dp), + tint = MaterialTheme.colorScheme.placeholderText.copy(alpha = contentAlpha), + ) + Text( + text = liveStatus ?: stringRes(Res.string.now_playing_preview_example), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.placeholderText.copy(alpha = contentAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + StatusChip( + when { + liveStatus != null -> PreviewChip.LIVE + isOn -> PreviewChip.SAMPLE + else -> PreviewChip.OFF + }, + ) + } + } +} + +private enum class PreviewChip { LIVE, SAMPLE, OFF } + +@Composable +private fun StatusChip(chip: PreviewChip) { + val (container, content) = + when (chip) { + PreviewChip.LIVE -> MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.surfaceContainerHighest to MaterialTheme.colorScheme.onSurfaceVariant + } + val label = + when (chip) { + PreviewChip.LIVE -> Res.string.now_playing_preview_live + PreviewChip.SAMPLE -> Res.string.now_playing_preview_sample + PreviewChip.OFF -> Res.string.now_playing_preview_off + } + + Row( + modifier = + Modifier + .clip(RoundedCornerShape(50)) + .background(container) + .padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (chip == PreviewChip.LIVE) { + Box(Modifier.size(6.dp).clip(CircleShape).background(content)) + Spacer(Modifier.width(6.dp)) + } + Text( + text = stringRes(label), + style = MaterialTheme.typography.labelMedium, + color = content, + ) + } +} + +/** Sub-row under the "Other apps" tile once access is granted: a quiet confirmation + Manage. */ +@Composable +private fun AccessGrantedRow(access: NowPlayingAccess) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 68.dp, end = 8.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SymbolIcon( + symbol = MaterialSymbols.CheckCircle, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(Res.string.now_playing_access_granted), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f).padding(start = 6.dp), + ) + TextButton(onClick = access::requestAccess) { + Text(stringRes(Res.string.now_playing_manage_access)) + } + } +} + +/** The one thing blocking other-app sharing, as a tonal card with a single clear action. */ +@Composable +private fun AccessNeededCard(access: NowPlayingAccess) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + // A light wash of the error color rather than the full errorContainer: it flags the one + // step left without shouting louder than the rest of the screen, in either theme. + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.error.copy(alpha = 0.10f), + contentColor = MaterialTheme.colorScheme.onSurface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column(Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = + Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.error.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center, + ) { + SymbolIcon( + symbol = MaterialSymbols.NotificationsOff, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + Text( + text = stringRes(Res.string.now_playing_access_needed_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 16.dp), + ) + } + Text( + text = stringRes(Res.string.now_playing_access_needed), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 52.dp, top = 4.dp), + ) + Spacer(Modifier.height(12.dp)) + Button( + onClick = access::requestAccess, + modifier = Modifier.align(Alignment.End), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringRes(Res.string.now_playing_grant_access)) + } + } + } +} + +/** The user grants access in system settings and comes back, so this re-checks on every resume. */ +@Composable +private fun rememberAccessGranted(access: NowPlayingAccess): Boolean { + var granted by remember(access) { mutableStateOf(access.hasAccess()) } + LifecycleResumeEffect(access) { + granted = access.hasAccess() + onPauseOrDispose {} + } + return granted +} + +@Composable +private fun AppToggleRow( + id: String, + label: String, + checked: Boolean, + appIcon: (@Composable (appId: String, label: String) -> Unit)?, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)), contentAlignment = Alignment.Center) { + if (appIcon != null) appIcon(id, label) else MonogramBadge(label, size = 36, shape = RoundedCornerShape(10.dp)) + } + Column( + modifier = Modifier.weight(1f).padding(start = 16.dp, end = 12.dp), + ) { + Text(text = label, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + // The package name tells apart two apps with the same name; skip it when it only + // repeats the name (desktop player ids such as `spotify` or `vlc`). + if (!label.lowercase().filterNot { it.isWhitespace() }.contains(id.lowercase())) { + Text( + text = id, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +// The accent palette of the Messages settings, so app badges read as the same family. +private val MONOGRAM_COLORS = + listOf( + Color(0xFF2EBD85), + Color(0xFFF6A609), + Color(0xFF2E90FA), + Color(0xFF9E77ED), + Color(0xFF5B6AD0), + Color(0xFFEC4899), + Color(0xFF14B8A6), + Color(0xFFEF4444), + Color(0xFF06B6D4), + ) + +/** A tinted badge with the first letter of [label], colored by the label so it stays stable. */ +@Composable +fun MonogramBadge( + label: String, + size: Int, + shape: Shape, +) { + val accent = MONOGRAM_COLORS[(label.hashCode() and Int.MAX_VALUE) % MONOGRAM_COLORS.size] + Box( + modifier = Modifier.size(size.dp).clip(shape).background(accent.copy(alpha = 0.18f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = label.trim().take(1).uppercase(), + style = if (size >= 40) MaterialTheme.typography.titleMedium else MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = accent, + ) + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 4.dp), + ) +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index 0bf5bee248..35f29cf0cc 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -67,6 +67,8 @@ import com.vitorpamplona.amethyst.commons.resources.nests_servers_search_keyword import com.vitorpamplona.amethyst.commons.resources.nests_servers_title import com.vitorpamplona.amethyst.commons.resources.notification_settings import com.vitorpamplona.amethyst.commons.resources.notification_settings_search_keywords +import com.vitorpamplona.amethyst.commons.resources.now_playing_search_keywords +import com.vitorpamplona.amethyst.commons.resources.now_playing_settings import com.vitorpamplona.amethyst.commons.resources.ots_explorer_search_keywords import com.vitorpamplona.amethyst.commons.resources.ots_explorer_settings import com.vitorpamplona.amethyst.commons.resources.payment_targets @@ -160,6 +162,7 @@ fun buildSettingsCatalog( symEntry(Res.string.drawer_settings, MaterialSymbols.AutoMirrored.ViewList, Res.string.drawer_search_keywords, Route.DrawerSettings), symEntry(Res.string.video_player_settings, MaterialSymbols.VideoSettings, Res.string.video_player_search_keywords, Route.VideoPlayerSettings), symEntry(Res.string.audio_visualizer_settings, MaterialSymbols.MusicNote, Res.string.audio_visualizer_search_keywords, Route.AudioVisualizerSettings), + symEntry(Res.string.now_playing_settings, MaterialSymbols.MusicNote, Res.string.now_playing_search_keywords, Route.NowPlayingSettings), symEntry(Res.string.favorite_dvms_title, MaterialSymbols.AutoAwesome, Res.string.favorite_dvms_search_keywords, Route.EditFavoriteAlgoFeeds), symEntry(Res.string.profile_badges_title, MaterialSymbols.MilitaryTech, Res.string.profile_badges_search_keywords, Route.ProfileBadges), symEntry(Res.string.payment_targets, MaterialSymbols.Payment, Res.string.payment_targets_search_keywords, Route.EditPaymentTargets), diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsRenderTest.kt new file mode 100644 index 0000000000..9c5b1c853a --- /dev/null +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsRenderTest.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingAccess +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettings +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState +import com.vitorpamplona.amethyst.commons.ui.theme.AmethystPreviewTheme +import org.jetbrains.skia.EncodedImageFormat +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.io.File +import javax.imageio.ImageIO +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Renders the Now Playing settings headlessly in both themes. Like the other render tests it + * checks structure, not exact pixels: every string resolves (a missing one throws here) and the + * screen follows the theme. Set NOW_PLAYING_RENDER_DIR to also write the PNGs for a human to look at. + */ +class NowPlayingSettingsRenderTest { + private val width = 820 + private val height = 1900 + + private class FakeAccess( + val granted: Boolean, + ) : NowPlayingAccess { + override fun hasAccess() = granted + + override fun requestAccess() {} + } + + private fun state(settings: NowPlayingSettings) = NowPlayingSettingsState().apply { restore(settings) } + + private val apps = + mapOf( + "com.spotify.music" to "Spotify", + "com.google.android.apps.youtube.music" to "YouTube Music", + "de.danoeh.antennapod" to "AntennaPod", + ) + + private val scenarios: Map Unit> = + mapOf( + "off" to { NowPlayingSettingsContent(state(NowPlayingSettings()), FakeAccess(false)) }, + "needs-access" to { + NowPlayingSettingsContent(state(NowPlayingSettings(shareInApp = true, shareOtherApps = true)), FakeAccess(false)) + }, + "on" to { + NowPlayingSettingsContent( + state( + NowPlayingSettings( + shareInApp = true, + shareOtherApps = true, + knownApps = apps, + blockedApps = setOf("com.google.android.apps.youtube.music"), + ), + ), + FakeAccess(true), + preview = { isOn -> + NowPlayingPreviewCard( + avatar = { MonogramBadge("Vitor", size = 44, shape = CircleShape) }, + name = "Vitor", + liveStatus = "One More Time - Daft Punk", + isOn = isOn, + ) + }, + ) + }, + "desktop" to { + NowPlayingSettingsContent( + state(NowPlayingSettings(shareOtherApps = true, knownApps = mapOf("spotify" to "Spotify", "vlc" to "VLC media player"))), + access = null, + showInApp = false, + ) + }, + ) + + private fun render( + dark: Boolean, + content: @Composable () -> Unit, + ): BufferedImage { + val scene = + ImageComposeScene(width = width, height = height, density = Density(2f)) { + AmethystPreviewTheme(dark = dark) { + Column( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + content() + } + } + } + return try { + val png = scene.render().encodeToData(EncodedImageFormat.PNG)!!.bytes + System.getenv("NOW_PLAYING_RENDER_DIR")?.let { dir -> + val name = scenarios.entries.firstOrNull { it.value === content }?.key ?: return@let + File(dir, "now-playing-$name-${if (dark) "dark" else "light"}.png").writeBytes(png) + } + ImageIO.read(ByteArrayInputStream(png)) + } finally { + scene.close() + } + } + + private fun BufferedImage.distinctColours(): Int { + val seen = mutableSetOf() + for (x in 0 until width step 3) { + for (y in 0 until height step 3) seen += getRGB(x, y) + } + return seen.size + } + + @Test + fun theAvatarCornersAreNotClipped() { + // ClickableUserPicture draws the follow mark in the top-right corner of its square box, + // outside the round picture. A circular clip around the slot used to cut it off. + val marker = Color(0xFFFF00FF) + val image = + render(false) { + NowPlayingPreviewCard( + avatar = { + Box(Modifier.fillMaxSize()) { + Box(Modifier.size(12.dp).align(Alignment.TopEnd).background(marker)) + } + }, + name = "Vitor", + liveStatus = null, + isOn = true, + ) + } + + var markerPixels = 0 + for (x in 0 until width) { + for (y in 0 until height) { + if (image.getRGB(x, y) == marker.toArgb()) markerPixels++ + } + } + // 12dp at density 2 is a 24x24 square: all of it must reach the screen. + assertTrue(markerPixels >= 24 * 24 * 95 / 100, "only $markerPixels of ${24 * 24} corner pixels were drawn") + } + + @Test + fun everyStateRendersInBothThemes() { + scenarios.values.forEach { content -> + val light = render(false, content) + val dark = render(true, content) + + assertTrue(light.distinctColours() > 20, "the light screen drew ${light.distinctColours()} colours") + assertTrue(dark.distinctColours() > 20, "the dark screen drew ${dark.distinctColours()} colours") + assertTrue(light.getRGB(2, 2) != dark.getRGB(2, 2), "the screen ignored the theme") + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 88859f79c6..42ceaf2b33 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -97,6 +97,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinderAccoun import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.NowPlayingSettingsContent import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady import com.vitorpamplona.amethyst.commons.wot.LocalWoTService import com.vitorpamplona.amethyst.desktop.account.AccountManager @@ -109,6 +110,9 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher +import com.vitorpamplona.amethyst.desktop.nowPlaying.DesktopNowPlayingCoordinator +import com.vitorpamplona.amethyst.desktop.nowPlaying.DesktopNowPlayingSettings +import com.vitorpamplona.amethyst.desktop.nowPlaying.OsNowPlayingReader import com.vitorpamplona.amethyst.desktop.platform.PlatformInfo import com.vitorpamplona.amethyst.desktop.platform.applyNativeWindowChrome import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore @@ -1459,6 +1463,18 @@ private fun AppInner( onDispose { iAccount.wotService.close() } } + // Shares what other apps on this computer play as the account's NIP-38 + // music status, once the user turns it on in Settings > Now Playing. + if (!account.isReadOnly) { + LaunchedEffect(iAccount) { + DesktopNowPlayingCoordinator( + settings = DesktopNowPlayingSettings.forAccount(account.pubKeyHex), + signer = iAccount.signer, + publishEvent = { relayManager.publish(it, iAccount.outboxHomeRelays()) }, + ).run() + } + } + // Lazy-load Namecoin services. The Core RPC HTTP // client is sourced from the Tor-aware DesktopHttpClient // singleton so .onion RPC URLs route through the @@ -2724,6 +2740,35 @@ private fun settingsEntries( }, ) + if (OsNowPlayingReader.forThisOs() != null) { + add( + SettingsEntry( + SettingsMeta( + id = "nowPlaying", + icon = MaterialSymbols.MusicNote, + title = "Now Playing", + subtitle = "Share what you listen to as your status", + keywords = + persistentListOf( + "music", + "listening", + "status", + "song", + "podcast", + "spotify", + "nip-38", + ), + ), + ) { + NowPlayingSettingsContent( + state = DesktopNowPlayingSettings.forAccount(account.pubKeyHex), + access = null, + showInApp = false, + ) + }, + ) + } + add( SettingsEntry( SettingsMeta( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingCoordinator.kt new file mode 100644 index 0000000000..16da363837 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingCoordinator.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.nowPlaying + +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingPublisher +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingStatusService +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +/** + * Desktop: shares what another app on this computer is playing as the account's NIP-38 music + * status. The OS is polled every [pollMs] while something plays ([idlePollMs] while nothing + * does), and only while the account has + * [com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettings.shareOtherApps] + * on, so nothing is spawned (and macOS never asks for Automation access) until the user opts in. + * + * Desktop has no music or podcast player of its own, so there is no in-app source here. + */ +class DesktopNowPlayingCoordinator( + private val settings: NowPlayingSettingsState, + private val signer: NostrSigner, + private val publishEvent: (Event) -> Unit, + private val reader: OsNowPlayingReader? = OsNowPlayingReader.forThisOs(), + private val pollMs: Long = 15_000, + private val idlePollMs: Long = 30_000, +) { + /** Runs until the calling coroutine (the logged-in account's composition) is cancelled. */ + suspend fun run() { + val reader = reader ?: return + + coroutineScope { + val otherApps = MutableStateFlow(null) + + launch { + settings.flow + .map { it.shareOtherApps } + .distinctUntilChanged() + .collectLatest { enabled -> + if (!enabled) { + otherApps.value = null + return@collectLatest + } + while (true) { + val track = reader.read() + // Keep the last value when only the position moved, so nothing downstream re-runs. + if (track == null || !track.isSameMoment(otherApps.value)) otherApps.value = track + // Each poll spawns processes (PowerShell on Windows is the heavy one), so + // look less often while nothing plays; a new song shows up within 30s. + delay(if (track == null) idlePollMs else pollMs) + } + } + } + + val publisher = + NowPlayingPublisher( + publish = { track, expiration -> publishEvent(UserStatusAction.createMusic(track, expiration, signer)) }, + clear = { publishEvent(UserStatusAction.clearMusic(signer)) }, + ) + + NowPlayingStatusService( + settings = settings.flow, + sources = listOf(otherApps), + onOtherAppSeen = { settings.rememberApp(it.id, it.label) }, + publisher = publisher, + ).run() + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingSettings.kt new file mode 100644 index 0000000000..a5c7af3dc1 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingSettings.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.nowPlaying + +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettings +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState +import com.vitorpamplona.amethyst.commons.model.preferences.NowPlayingSettingsStore +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import java.util.prefs.Preferences + +/** + * Desktop's per-account [NowPlayingSettingsState]s, persisted with `java.util.prefs` like the + * rest of the desktop preferences (the DataStore-backed `NowPlayingSettingsStore` is Android's). + * One state per pubkey for the life of the process, so the settings card and the publisher share it. + */ +object DesktopNowPlayingSettings { + private val prefs: Preferences = Preferences.userNodeForPackage(DesktopNowPlayingSettings::class.java) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val states = mutableMapOf() + + @Synchronized + fun forAccount(pubKeyHex: HexKey): NowPlayingSettingsState = + states.getOrPut(pubKeyHex) { + NowPlayingSettingsState().also { state -> + state.restore(load(pubKeyHex)) + scope.launch { state.flow.drop(1).collect { save(pubKeyHex, it) } } + } + } + + private fun key( + pubKeyHex: HexKey, + name: String, + ) = "nowPlaying.${pubKeyHex.take(16)}.$name" + + private fun load(pubKeyHex: HexKey) = + NowPlayingSettings( + shareInApp = false, + shareOtherApps = prefs.getBoolean(key(pubKeyHex, "shareOtherApps"), false), + blockedApps = split(prefs.get(key(pubKeyHex, "blockedApps"), "")).toSet(), + knownApps = NowPlayingSettingsStore.decodeApps(split(prefs.get(key(pubKeyHex, "knownApps"), "")).toSet()), + ) + + private fun save( + pubKeyHex: HexKey, + settings: NowPlayingSettings, + ) { + prefs.putBoolean(key(pubKeyHex, "shareOtherApps"), settings.shareOtherApps) + prefs.put(key(pubKeyHex, "blockedApps"), settings.blockedApps.joinToString(LINE)) + prefs.put(key(pubKeyHex, "knownApps"), NowPlayingSettingsStore.encodeApps(settings.knownApps).joinToString(LINE)) + } + + private fun split(value: String) = value.split(LINE).filter { it.isNotEmpty() } + + private const val LINE = "\n" +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MacNowPlayingReader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MacNowPlayingReader.kt new file mode 100644 index 0000000000..aca2f75602 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MacNowPlayingReader.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.nowPlaying + +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSource +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * macOS: the system-wide "Now Playing" is a private framework apps can no longer read (macOS + * 15.4+), so this asks the two players that matter most, Spotify and Apple Music, over + * AppleScript. Each is only asked while it is running: addressing an app that is not running + * would launch it. The first time, macOS asks the user to allow Amethyst to control the player. + */ +class MacNowPlayingReader( + private val run: suspend (List) -> String? = { runCommand(it) }, + private val nowSeconds: () -> Long = TimeUtils::now, +) : OsNowPlayingReader { + private class Player( + val bundleId: String, + val label: String, + val executable: String, + // Spotify reports the track duration in milliseconds, Music in seconds. + val durationInMs: Boolean, + ) + + override suspend fun read(): NowPlaying? { + val running = run(listOf("ps", "-A", "-o", "comm=")) ?: return null + + for (player in PLAYERS) { + if (!running.contains(player.executable)) continue + val output = run(listOf("osascript", "-e", script(player))) ?: continue + return parse(player.bundleId, player.label, output, player.durationInMs, nowSeconds()) ?: continue + } + + return null + } + + companion object { + private val PLAYERS = + listOf( + Player("com.spotify.client", "Spotify", "Spotify.app/Contents/MacOS/Spotify", durationInMs = true), + Player("com.apple.Music", "Music", "Music.app/Contents/MacOS/Music", durationInMs = false), + ) + + private fun script(player: Player) = + """ + tell application id "${player.bundleId}" + if player state is playing then + return (name of current track) & tab & (artist of current track) & tab & (duration of current track) & tab & (player position) + end if + end tell + return "" + """.trimIndent() + + /** Parses `titleartistdurationposition seconds`; numbers may use a decimal comma. */ + fun parse( + id: String, + label: String, + output: String, + durationInMs: Boolean, + nowSeconds: Long, + ): NowPlaying? { + val fields = output.trimEnd('\n', '\r').split('\t') + if (fields.size < 4) return null + + val title = fields[0].ifBlank { null } ?: return null + val duration = fields[2].replace(',', '.').toDoubleOrNull() + val durationMs = duration?.let { if (durationInMs) it.toLong() else (it * 1000).toLong() } + val positionMs = fields[3].replace(',', '.').toDoubleOrNull()?.let { (it * 1000).toLong() } ?: 0L + + return NowPlaying( + title = title, + artist = fields[1].ifBlank { null }, + source = NowPlayingSource.OtherApp(id, label), + endsAt = NowPlaying.endsAt(nowSeconds, durationMs, positionMs), + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MprisNowPlayingReader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MprisNowPlayingReader.kt new file mode 100644 index 0000000000..9290de3642 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MprisNowPlayingReader.kt @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.nowPlaying + +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSource +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Linux/BSD: asks every MPRIS player on the session bus (Spotify, VLC, Rhythmbox, browsers, ...) + * what it is playing, through the `dbus-send` tool that ships with D-Bus itself, so no D-Bus + * library is linked in. + */ +class MprisNowPlayingReader( + private val run: suspend (List) -> String? = { runCommand(it) }, + private val nowSeconds: () -> Long = TimeUtils::now, +) : OsNowPlayingReader { + // A player's name never changes while its bus name lives; asking once saves a process per poll. + private val identities = mutableMapOf() + + override suspend fun read(): NowPlaying? { + val names = run(LIST_NAMES)?.let(MprisParser::playerNames).orEmpty() + identities.keys.retainAll(names.toSet()) + + for (busName in names) { + val properties = run(getAll(busName))?.let(MprisParser::properties) ?: continue + if (properties["PlaybackStatus"]?.firstOrNull() != "Playing") continue + + val identity = + identities.getOrPut(busName) { + run(getIdentity(busName))?.let(MprisParser::properties)?.get("Identity")?.firstOrNull() + } + return MprisParser.toNowPlaying(busName, identity, properties, nowSeconds()) + } + + return null + } + + companion object { + private val LIST_NAMES = + listOf( + "dbus-send", + "--session", + "--print-reply", + "--dest=org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus.ListNames", + ) + + private fun getAll(busName: String) = + listOf( + "dbus-send", + "--session", + "--print-reply", + "--dest=$busName", + "/org/mpris/MediaPlayer2", + "org.freedesktop.DBus.Properties.GetAll", + "string:org.mpris.MediaPlayer2.Player", + ) + + private fun getIdentity(busName: String) = + listOf( + "dbus-send", + "--session", + "--print-reply", + "--dest=$busName", + "/org/mpris/MediaPlayer2", + "org.freedesktop.DBus.Properties.GetAll", + "string:org.mpris.MediaPlayer2", + ) + } +} + +/** Parses the text `dbus-send --print-reply` prints for the MPRIS calls above. */ +object MprisParser { + private const val PREFIX = "org.mpris.MediaPlayer2." + private val INSTANCE_SUFFIX = Regex("""\.instance_?\d+(_\d+)?$""") + + fun playerNames(output: String): List = + output + .lineSequence() + .map { it.trim() } + .filter { it.startsWith("string \"$PREFIX") } + .map { unquote(it.removePrefix("string ")) } + .toList() + + /** The player id the block list keys on: `org.mpris.MediaPlayer2.vlc.instance123` is `vlc`. */ + fun playerId(busName: String): String = busName.removePrefix(PREFIX).replace(INSTANCE_SUFFIX, "") + + /** + * Flattens every `dict entry` of a GetAll reply, nested ones included (the track metadata is + * a dictionary inside the `Metadata` entry), into key -> values. Scalars have one value; + * string arrays such as `xesam:artist` keep all of them. + */ + fun properties(output: String): Map> { + val result = mutableMapOf>() + var expectKey = false + var key: String? = null + var arrayKey: String? = null + + output.lineSequence().map { it.trim() }.forEach { line -> + when { + line.startsWith("dict entry(") -> { + expectKey = true + } + + expectKey && line.startsWith("string ") -> { + key = unquote(line.removePrefix("string ")) + expectKey = false + } + + line.startsWith("variant") -> { + val currentKey = key ?: return@forEach + val value = line.removePrefix("variant").trim() + if (value.startsWith("array [")) { + // Either a string list (artists) or a nested dictionary (Metadata); + // a nested dictionary's entries are picked up by the branches above. + arrayKey = currentKey + } else { + scalar(value)?.let { result.getOrPut(currentKey) { mutableListOf() }.add(it) } + } + key = null + } + + line == "]" -> { + arrayKey = null + } + + arrayKey != null && line.startsWith("string ") -> { + result.getOrPut(arrayKey!!) { mutableListOf() }.add(unquote(line.removePrefix("string "))) + } + } + } + + return result + } + + fun toNowPlaying( + busName: String, + identity: String?, + properties: Map>, + nowSeconds: Long, + ): NowPlaying? { + val title = properties["xesam:title"]?.firstOrNull()?.ifBlank { null } ?: return null + val artist = + properties["xesam:artist"]?.filter { it.isNotBlank() }?.joinToString(", ")?.ifBlank { null } + ?: properties["xesam:albumArtist"]?.firstOrNull()?.ifBlank { null } + + // MPRIS times are microseconds. + val durationMs = properties["mpris:length"]?.firstOrNull()?.toLongOrNull()?.div(1000) + val positionMs = properties["Position"]?.firstOrNull()?.toLongOrNull()?.div(1000) ?: 0L + val rate = properties["Rate"]?.firstOrNull()?.toFloatOrNull() ?: 1f + + val id = playerId(busName) + val label = identity?.ifBlank { null } ?: id.replaceFirstChar { it.uppercase() } + + return NowPlaying( + title = title, + artist = artist, + source = NowPlayingSource.OtherApp(id, label), + endsAt = NowPlaying.endsAt(nowSeconds, durationMs, positionMs, rate), + url = properties["xesam:url"]?.firstOrNull()?.let(::shareableUrl), + ) + } + + /** + * Only links that name a track are shared. Browsers fill `xesam:url` with the page of whatever + * tab is playing, and a local player with a file path: neither belongs in a public status. + */ + fun shareableUrl(url: String): String? = url.takeIf { SHAREABLE_URL.matches(it) } + + private val SHAREABLE_URL = Regex("""^https://open\.spotify\.com/(intl-[a-z-]+/)?(track|episode)/[A-Za-z0-9]+(\?.*)?$""") + + /** `string "x"`, `object path "/x"`, `int64 5`, `double 1`, `boolean true` -> the value. */ + private fun scalar(value: String): String? = + when { + value.startsWith("string ") -> unquote(value.removePrefix("string ")) + value.startsWith("object path ") -> unquote(value.removePrefix("object path ")) + else -> value.substringAfter(' ', "").ifBlank { null } + } + + private fun unquote(value: String): String { + val start = value.indexOf('"') + val end = value.lastIndexOf('"') + return if (start >= 0 && end > start) value.substring(start + 1, end) else value + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReader.kt new file mode 100644 index 0000000000..f8aad95b00 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReader.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.nowPlaying + +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +/** Reads what the OS says another app is playing right now. */ +interface OsNowPlayingReader { + /** The track a desktop app is playing, or null when nothing is (or the OS cannot say). */ + suspend fun read(): NowPlaying? + + companion object { + /** The reader for this OS, or null where there is none. */ + fun forThisOs(osName: String = System.getProperty("os.name").orEmpty()): OsNowPlayingReader? { + val os = osName.lowercase() + return when { + os.contains("linux") || os.contains("bsd") -> MprisNowPlayingReader() + os.contains("mac") -> MacNowPlayingReader() + os.contains("windows") -> WindowsNowPlayingReader() + else -> null + } + } + } +} + +/** + * Runs a short-lived command and returns its stdout, or null if it failed or took too long. + * Interruptible, so turning sharing off or closing the app does not wait out a slow command, and + * the process never outlives the call. + */ +internal suspend fun runCommand( + command: List, + timeoutSeconds: Long = 5, +): String? = + runInterruptible(Dispatchers.IO) { + var process: Process? = null + try { + val started = + ProcessBuilder(command) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + process = started + started.outputStream.close() + // Read while the command runs: `ps -A -o comm=` prints ~100 KB on a busy Mac, past the + // pipe buffer, so waiting for the exit before reading deadlocked until the timeout and + // the reader never saw a player. The watchdog still kills a command that hangs, which + // also ends the read. + val watchdog = + thread(isDaemon = true, name = "now-playing-command-timeout") { + try { + if (!started.waitFor(timeoutSeconds, TimeUnit.SECONDS)) started.destroyForcibly() + } catch (_: InterruptedException) { + // The command finished first. + } + } + val output = started.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + val exited = started.waitFor(timeoutSeconds, TimeUnit.SECONDS) + watchdog.interrupt() + if (!exited || started.exitValue() != 0) null else output + } catch (e: InterruptedException) { + throw e + } catch (e: Exception) { + // Missing binary (no dbus-send, no osascript) or a denied permission: nothing to report. + Log.d("OsNowPlayingReader") { "${command.first()} failed: ${e.message}" } + null + } finally { + process?.takeIf { it.isAlive }?.destroyForcibly() + } + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/WindowsNowPlayingReader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/WindowsNowPlayingReader.kt new file mode 100644 index 0000000000..a48ee1e5b9 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/WindowsNowPlayingReader.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.nowPlaying + +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSource +import com.vitorpamplona.quartz.utils.TimeUtils +import java.util.Base64 + +/** + * Windows: reads the system media transport controls (the media flyout every player feeds: + * Spotify, the Media Player app, browsers, ...) through the WinRT + * `GlobalSystemMediaTransportControlsSessionManager`, called from the built-in Windows PowerShell + * since the JVM has no WinRT bridge. + */ +class WindowsNowPlayingReader( + private val run: suspend (List) -> String? = { runCommand(it, timeoutSeconds = 10) }, + private val nowSeconds: () -> Long = TimeUtils::now, +) : OsNowPlayingReader { + override suspend fun read(): NowPlaying? { + val output = run(listOf("powershell.exe", "-NoProfile", "-NonInteractive", "-EncodedCommand", ENCODED_SCRIPT)) ?: return null + return parse(output, nowSeconds()) + } + + companion object { + private val SCRIPT = + """ + ${'$'}ErrorActionPreference = 'Stop' + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + Add-Type -AssemblyName System.Runtime.WindowsRuntime + ${'$'}asTask = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { ${'$'}_.Name -eq 'AsTask' -and ${'$'}_.GetParameters().Count -eq 1 -and ${'$'}_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' })[0] + function Await(${'$'}op, [Type]${'$'}type) { ${'$'}task = ${'$'}asTask.MakeGenericMethod(${'$'}type).Invoke(${'$'}null, @(${'$'}op)); ${'$'}task.Wait(-1) | Out-Null; ${'$'}task.Result } + [void][Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager, Windows.Media.Control, ContentType = WindowsRuntime] + ${'$'}manager = Await ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager]::RequestAsync()) ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager]) + foreach (${'$'}session in ${'$'}manager.GetSessions()) { + if (${'$'}session.GetPlaybackInfo().PlaybackStatus -ne 'Playing') { continue } + ${'$'}props = Await (${'$'}session.TryGetMediaPropertiesAsync()) ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionMediaProperties]) + ${'$'}timeline = ${'$'}session.GetTimelineProperties() + ${'$'}length = [long](${'$'}timeline.EndTime - ${'$'}timeline.StartTime).TotalMilliseconds + ${'$'}position = [long]${'$'}timeline.Position.TotalMilliseconds + [Console]::Out.WriteLine((${'$'}session.SourceAppUserModelId, ${'$'}props.Title, ${'$'}props.Artist, ${'$'}length, ${'$'}position) -join "`t") + break + } + """.trimIndent() + + // -EncodedCommand takes UTF-16LE in Base64, which sidesteps every quoting rule of the command line. + private val ENCODED_SCRIPT: String = Base64.getEncoder().encodeToString(SCRIPT.toByteArray(Charsets.UTF_16LE)) + + /** Parses `appIdtitleartistlengthMspositionMs`. */ + fun parse( + output: String, + nowSeconds: Long, + ): NowPlaying? { + val fields = + output + .lineSequence() + .map { it.trimEnd('\r') } + .firstOrNull { it.isNotBlank() } + ?.split('\t') ?: return null + if (fields.size < 5) return null + + val appId = fields[0].ifBlank { null } ?: return null + val title = fields[1].ifBlank { null } ?: return null + val durationMs = fields[3].toLongOrNull()?.takeIf { it > 0 } + val positionMs = fields[4].toLongOrNull() ?: 0L + + return NowPlaying( + title = title, + artist = fields[2].ifBlank { null }, + source = NowPlayingSource.OtherApp(appId, appLabel(appId)), + endsAt = NowPlaying.endsAt(nowSeconds, durationMs, positionMs), + ) + } + + /** + * A readable name for an app user model id: `Spotify.exe` is `Spotify`, + * `Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic` is `ZuneMusic`. + */ + fun appLabel(appId: String): String { + val app = appId.substringAfterLast('!').substringBefore('_') + val name = + app + .removeSuffix(".exe") + .removeSuffix(".EXE") + .substringAfterLast('\\') + .substringAfterLast('.') + return name.ifBlank { appId } + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt new file mode 100644 index 0000000000..36b29a93cc --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.nowPlaying + +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSource +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OsNowPlayingReaderTest { + private val listNames = + """ + method return time=1700000000.1 sender=org.freedesktop.DBus -> destination=:1.99 serial=3 reply_serial=2 + array [ + string "org.freedesktop.DBus" + string ":1.7" + string "org.mpris.MediaPlayer2.vlc.instance4242" + string "org.mpris.MediaPlayer2.spotify" + ] + """.trimIndent() + + private val vlcPaused = + """ + method return time=1700000000.2 sender=:1.50 -> destination=:1.99 serial=10 reply_serial=2 + array [ + dict entry( + string "PlaybackStatus" + variant string "Paused" + ) + ] + """.trimIndent() + + private val spotifyPlaying = + """ + method return time=1700000000.3 sender=:1.51 -> destination=:1.99 serial=11 reply_serial=2 + array [ + dict entry( + string "PlaybackStatus" + variant string "Playing" + ) + dict entry( + string "Metadata" + variant array [ + dict entry( + string "mpris:trackid" + variant object path "/com/spotify/track/0DiWol3AO6WpXZgp0goxAV" + ) + dict entry( + string "mpris:length" + variant uint64 320000000 + ) + dict entry( + string "xesam:artist" + variant array [ + string "Daft Punk" + string "Romanthony" + ] + ) + dict entry( + string "xesam:title" + variant string "One More Time "Live" – Café" + ) + dict entry( + string "xesam:url" + variant string "https://open.spotify.com/track/0DiWol3AO6WpXZgp0goxAV" + ) + ] + ) + dict entry( + string "Position" + variant int64 20000000 + ) + dict entry( + string "Rate" + variant double 1 + ) + ] + """.trimIndent() + + private val spotifyIdentity = + """ + method return time=1700000000.4 sender=:1.51 -> destination=:1.99 serial=12 reply_serial=2 + array [ + dict entry( + string "Identity" + variant string "Spotify" + ) + dict entry( + string "SupportedMimeTypes" + variant array [ + ] + ) + ] + """.trimIndent() + + @Test + fun mprisFindsThePlayingPlayer() = + runTest { + val reader = + MprisNowPlayingReader( + run = { command -> + val dest = command.firstOrNull { it.startsWith("--dest=") }?.removePrefix("--dest=") + when { + dest == "org.freedesktop.DBus" -> listNames + dest == "org.mpris.MediaPlayer2.vlc.instance4242" -> vlcPaused + command.last() == "string:org.mpris.MediaPlayer2.Player" -> spotifyPlaying + else -> spotifyIdentity + } + }, + nowSeconds = { 1_000L }, + ) + + val track = reader.read()!! + + assertEquals("One More Time \"Live\" – Café", track.title) + assertEquals("Daft Punk, Romanthony", track.artist) + assertEquals(NowPlayingSource.OtherApp("spotify", "Spotify"), track.source) + // 320s long, 20s in. + assertEquals(1_000L + 300 + 1, track.endsAt) + assertEquals("https://open.spotify.com/track/0DiWol3AO6WpXZgp0goxAV", track.url) + } + + /** + * Runs the real reader, `dbus-send` included, against a live session bus. Opt-in: start an + * MPRIS player (or a fake one) under `dbus-run-session` and set MPRIS_LIVE_TITLE to its title. + */ + @Test + fun mprisReadsALiveSessionBus() = + runTest { + val expectedTitle = System.getenv("MPRIS_LIVE_TITLE") ?: return@runTest + assertEquals(expectedTitle, MprisNowPlayingReader().read()?.title) + } + + @Test + fun mprisReportsNothingWhenNoneIsPlaying() = + runTest { + val reader = + MprisNowPlayingReader( + run = { command -> if (command.any { it == "--dest=org.freedesktop.DBus" }) listNames else vlcPaused }, + ) + + assertNull(reader.read()) + } + + @Test + fun mprisSharesTrackLinksButNeverPagesOrFiles() { + assertEquals( + "https://open.spotify.com/track/0DiWol3AO6WpXZgp0goxAV", + MprisParser.shareableUrl("https://open.spotify.com/track/0DiWol3AO6WpXZgp0goxAV"), + ) + assertEquals( + "https://open.spotify.com/intl-pt/episode/4rOoJ6Egrf8K2IrywzwOMk?si=abc", + MprisParser.shareableUrl("https://open.spotify.com/intl-pt/episode/4rOoJ6Egrf8K2IrywzwOMk?si=abc"), + ) + // Chromium reports the playing tab's page; VLC the file it opened. + assertNull(MprisParser.shareableUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")) + assertNull(MprisParser.shareableUrl("https://mail.example.com/inbox/secret-thread")) + assertNull(MprisParser.shareableUrl("file:///home/me/Music/song.mp3")) + assertNull(MprisParser.shareableUrl("https://open.spotify.com.evil.example/track/abc")) + } + + @Test + fun mprisPlayerIdDropsTheInstanceSuffix() { + assertEquals("vlc", MprisParser.playerId("org.mpris.MediaPlayer2.vlc.instance4242")) + assertEquals("chromium", MprisParser.playerId("org.mpris.MediaPlayer2.chromium.instance_1_23")) + assertEquals("spotify", MprisParser.playerId("org.mpris.MediaPlayer2.spotify")) + } + + @Test + fun macParsesSpotifyAndMusicUnits() { + val spotify = MacNowPlayingReader.parse("com.spotify.client", "Spotify", "Song\tBand\t200000\t50,5\n", durationInMs = true, nowSeconds = 0)!! + assertEquals("Song", spotify.title) + assertEquals("Band", spotify.artist) + assertEquals(149L + 1, spotify.endsAt) + + val music = MacNowPlayingReader.parse("com.apple.Music", "Music", "Song\tBand\t200.0\t50.0\n", durationInMs = false, nowSeconds = 0)!! + assertEquals(150L + 1, music.endsAt) + + assertNull(MacNowPlayingReader.parse("com.apple.Music", "Music", "\n", durationInMs = false, nowSeconds = 0)) + } + + @Test + fun windowsParsesTheScriptOutput() { + val track = WindowsNowPlayingReader.parse("Spotify.exe\tSong\tBand\t200000\t50000\r\n", nowSeconds = 0)!! + + assertEquals("Song - Band", track.statusText()) + assertEquals(NowPlayingSource.OtherApp("Spotify.exe", "Spotify"), track.source) + assertEquals(150L + 1, track.endsAt) + + assertNull(WindowsNowPlayingReader.parse("", nowSeconds = 0)) + } + + @Test + fun windowsAppLabels() { + assertEquals("Spotify", WindowsNowPlayingReader.appLabel("Spotify.exe")) + assertEquals("ZuneMusic", WindowsNowPlayingReader.appLabel("Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic")) + assertEquals("308046B0AF4A39CB", WindowsNowPlayingReader.appLabel("308046B0AF4A39CB")) + } + + private val hasShell = File("/bin/sh").canExecute() + + /** `ps -A -o comm=` prints ~100 KB on a busy Mac; reading only after the exit deadlocked on the pipe. */ + @Test + fun runCommandReturnsOutputLargerThanThePipeBuffer() = + runTest { + if (!hasShell) return@runTest + val output = runCommand(listOf("/bin/sh", "-c", "i=0; while [ \$i -lt 4000 ]; do echo 0123456789012345678901234567890123456789012345678; i=\$((i+1)); done")) + assertEquals(4000 * 50, output?.length) + } + + @Test + fun runCommandGivesUpOnAHungCommand() = + runTest { + if (!hasShell) return@runTest + val started = System.currentTimeMillis() + assertNull(runCommand(listOf("/bin/sh", "-c", "sleep 30"), timeoutSeconds = 1)) + assertTrue(System.currentTimeMillis() - started < 10_000, "the timeout must end the call") + } +}