From bb7b528eea98e37ecf748c35260aea66b0e76622 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 02:24:21 +0000 Subject: [PATCH 1/8] feat: share what you listen to as a NIP-38 music status Publishes the song or podcast episode the user is playing as the account's NIP-38 `music` status (podcasts included, so every client renders them), with a NIP-40 expiration at the track's end, and clears it when playback stops. Everything is opt-in per account under Settings > Now Playing. Sources: - Android, in app: a Player.Listener on every ExoPlayer resolves the item's nostr: URI to a music track, audio track or podcast episode (feed videos, voice notes and muted autoplay never count) and tags the status with its a/e reference. - Android, other apps: a NotificationListenerService reads other apps' media sessions (Spotify, YouTube Music, podcast players, ...) once the user grants notification access; apps can be blocked one by one. - Desktop: Linux MPRIS via dbus-send, macOS Spotify/Music via osascript, Windows media controls via PowerShell/WinRT. No new dependencies. Shared core in commons (NowPlayingPublisher, NowPlayingStatusService, NowPlayingResolver, settings + DataStore store) and a shared settings screen in commonsUI. The publisher waits 5s before posting a new track, re-posts on seeks, refreshes unknown-length streams, and waits 30s before clearing so a pause or stall doesn't churn events. Also fixes UserStatusCache keeping a status listed after a newer blank (cleared) or expired version arrived, and keeps the music status out of the drawer's status editor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N2naGACCZy1GvqmZNCwpBu --- amethyst/src/main/AndroidManifest.xml | 19 ++ .../com/vitorpamplona/amethyst/AppModules.kt | 13 + .../nowPlaying/AndroidNowPlayingAccess.kt | 33 +++ .../service/nowPlaying/InAppPlayback.kt | 124 +++++++++ .../nowPlaying/NowPlayingStatusCoordinator.kt | 118 +++++++++ .../nowPlaying/OtherAppsNowPlayingService.kt | 219 ++++++++++++++++ .../playback/playerPool/ExoPlayerBuilder.kt | 2 + .../playback/playerPool/ExoPlayerPool.kt | 6 + .../amethyst/ui/navigation/AppNavigation.kt | 7 + .../ui/navigation/drawer/DrawerContent.kt | 7 +- .../amethyst/commons/model/Account.kt | 14 ++ .../commons/model/navigation/Routes.kt | 2 + .../nip38UserStatuses/UserStatusAction.kt | 20 ++ .../nip38UserStatuses/UserStatusCache.kt | 13 +- .../nowPlaying/NowPlaying.kt | 93 +++++++ .../nowPlaying/NowPlayingAccess.kt | 33 +++ .../nowPlaying/NowPlayingPublisher.kt | 156 ++++++++++++ .../nowPlaying/NowPlayingResolver.kt | 109 ++++++++ .../nowPlaying/NowPlayingSettings.kt | 89 +++++++ .../nowPlaying/NowPlayingStatusService.kt | 59 +++++ .../preferences/NowPlayingSettingsStore.kt | 108 ++++++++ .../nip38UserStatuses/UserStatusCacheTest.kt | 61 +++++ .../nowPlaying/NowPlayingPublisherTest.kt | 215 ++++++++++++++++ .../nowPlaying/NowPlayingResolverTest.kt | 73 ++++++ .../NowPlayingSettingsStoreTest.kt | 106 ++++++++ .../composeResources/values/strings.xml | 13 + .../settings/NowPlayingSettingsScreen.kt | 238 ++++++++++++++++++ .../settings/SettingsCatalogBuilder.kt | 3 + .../vitorpamplona/amethyst/desktop/Main.kt | 45 ++++ .../DesktopNowPlayingCoordinator.kt | 90 +++++++ .../nowPlaying/DesktopNowPlayingSettings.kt | 78 ++++++ .../desktop/nowPlaying/MacNowPlayingReader.kt | 98 ++++++++ .../nowPlaying/MprisNowPlayingReader.kt | 190 ++++++++++++++ .../desktop/nowPlaying/OsNowPlayingReader.kt | 75 ++++++ .../nowPlaying/WindowsNowPlayingReader.kt | 108 ++++++++ .../nowPlaying/OsNowPlayingReaderTest.kt | 201 +++++++++++++++ 36 files changed, 2833 insertions(+), 5 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/AndroidNowPlayingAccess.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/InAppPlayback.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/NowPlayingStatusCoordinator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/OtherAppsNowPlayingService.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlaying.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingAccess.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisher.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolver.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingSettings.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingStatusService.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NowPlayingSettingsStore.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCacheTest.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisherTest.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolverTest.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NowPlayingSettingsStoreTest.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingCoordinator.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingSettings.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MacNowPlayingReader.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MprisNowPlayingReader.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReader.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/WindowsNowPlayingReader.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 91acb98039..d5d91e424e 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -90,6 +90,11 @@ + + + + @@ -556,6 +561,20 @@ + + + + + + + >(emptyMap()) + + val flow: StateFlow> = playing + + /** The most recently started playback, if any. */ + fun current(all: Map): InAppPlayback? = all.values.lastOrNull() + + fun update( + player: Player, + playback: InAppPlayback?, + ) { + val key = System.identityHashCode(player) + playing.update { current -> + when { + playback == null -> if (key in current) current - key else current + current[key] == playback -> current + // Remove first so a new track moves to the end and becomes the current one. + 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..4998f945a2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/NowPlayingStatusCoordinator.kt @@ -0,0 +1,118 @@ +/* + * 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) { + val inAppTracks = + inApp + .map { InAppPlaybackRegistry.current(it) } + .distinctUntilChanged() + .map { playback -> playback?.let(::resolve) } + .flowOn(Dispatchers.IO) + + val publisher = + NowPlayingPublisher( + publish = { track, expiration -> + account.publishNowPlaying(track, expiration) + holdRelays() + }, + clear = { + account.clearNowPlaying() + holdRelays() + }, + ) + + NowPlayingStatusService( + settings = account.nowPlayingSettings.flow, + sources = listOf(inAppTracks, otherApps), + onOtherAppSeen = { account.nowPlayingSettings.rememberApp(it.id, it.label) }, + publisher = publisher, + ).run() + } + + private fun resolve(playback: InAppPlayback): NowPlaying? = + NowPlayingResolver.fromNostrUri( + uri = playback.callbackUri, + findEvent = { key -> LocalCache.getNoteIfExists(key)?.event }, + fallbackTitle = playback.title, + fallbackArtist = playback.artist, + 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 + } +} 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..6a8a69718a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/OtherAppsNowPlayingService.kt @@ -0,0 +1,219 @@ +/* + * 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.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.Amethyst +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?) { + 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. + */ +class OtherAppsNowPlayingService : NotificationListenerService() { + private val handler = Handler(Looper.getMainLooper()) + private var sessionManager: MediaSessionManager? = null + private val watched = 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) + } + + // The system can start the process just to bind this listener; the publisher needs the account. + Amethyst.instance.sessionManager.loginWithDefaultAccountIfLoggedOff() + } + + 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.map { it.sessionToken.toString() }.toSet() + + watched.keys.filter { it !in keys }.forEach { key -> + watched.remove(key)?.let { (controller, callback) -> controller.unregisterCallback(callback) } + } + + others.forEach { controller -> + val key = controller.sessionToken.toString() + 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 = + 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..4bab06f572 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,7 @@ 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.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 +655,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) + } 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 dca2865779..9b77f366a1 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 @@ -532,6 +534,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. @@ -3345,6 +3352,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..ca57ed9045 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlaying.kt @@ -0,0 +1,93 @@ +/* + * 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 + +/** 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 + + companion object { + /** + * 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..494e2bc298 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingResolver.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.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 = + when (val entity = Nip19Parser.uriToRoute(uri)?.entity) { + is NEvent -> entity.hex + is NNote -> entity.hex + is NAddress -> entity.aTag() + else -> null + } ?: return null + + val event = findEvent(key) ?: return null + return fromEvent(event, fallbackTitle, fallbackArtist, endsAt) + } + + 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..0854204e8e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/nowPlaying/NowPlayingPublisherTest.kt @@ -0,0 +1,215 @@ +/* + * 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.assertNull + +@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 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 9b2257719e..5a6a7e8a10 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -6362,4 +6362,17 @@ 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 + Share the song or podcast you are listening to as your status. It shows under your name for anyone who sees your profile, and disappears when the track ends or you stop playing. + Share what I play in Amethyst + Music tracks and podcast episodes played in the app. + Share what I play in other apps + Spotify, YouTube Music, podcast players and any other app that shows media controls. + To see what other apps are playing, Amethyst needs notification access. It only reads media controls, never the content of your notifications. + Grant access + Manage access + Notification access is on. + Apps + Apps show up here after they play something. Turn one off to stop sharing it. 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..2ac357765c --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsScreen.kt @@ -0,0 +1,238 @@ +/* + * 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.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.LifecycleResumeEffect +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingAccess +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSettingsState +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_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_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.stringRes +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel + +/** + * 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. + */ +@Composable +fun NowPlayingSettingsScreen( + accountViewModel: AccountViewModel, + nav: INav, + access: NowPlayingAccess? = null, +) { + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(Res.string.now_playing_settings), nav) + }, + ) { padding -> + Column( + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()), + ) { + NowPlayingSettingsContent(accountViewModel.account.nowPlayingSettings, access) + } + } +} + +/** + * The body of the settings screen, also embedded by front ends with their own settings layout. + * [showInApp] hides the in-app toggle where the app has no music or podcast player of its own. + */ +@Composable +fun NowPlayingSettingsContent( + state: NowPlayingSettingsState, + access: NowPlayingAccess?, + showInApp: Boolean = true, +) { + val settings by state.flow.collectAsState() + + Text( + text = stringRes(Res.string.now_playing_description), + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), + ) + + if (showInApp) { + SwitchRow( + title = stringRes(Res.string.now_playing_share_in_app), + description = stringRes(Res.string.now_playing_share_in_app_description), + checked = settings.shareInApp, + onCheckedChange = state::setShareInApp, + ) + } + + SwitchRow( + title = stringRes(Res.string.now_playing_share_other_apps), + description = stringRes(Res.string.now_playing_share_other_apps_description), + checked = settings.shareOtherApps, + onCheckedChange = state::setShareOtherApps, + ) + + if (!settings.shareOtherApps) { + Spacer(modifier = Modifier.height(16.dp)) + return + } + + if (access != null) { + AccessRow(access) + } + + HorizontalDivider(thickness = 4.dp, modifier = Modifier.padding(vertical = 8.dp)) + + Text( + text = stringRes(Res.string.now_playing_apps), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp), + ) + + if (settings.knownApps.isEmpty()) { + Text( + text = stringRes(Res.string.now_playing_apps_empty), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) + } else { + settings.knownApps.entries + .sortedBy { it.value.lowercase() } + .forEach { (id, label) -> + SwitchRow( + title = label, + description = id.takeIf { it != label }, + checked = id !in settings.blockedApps, + onCheckedChange = { allowed -> state.setAppBlocked(id, !allowed) }, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) +} + +@Composable +private fun AccessRow(access: NowPlayingAccess) { + // The user grants access in system settings and comes back, so check again on every resume. + var granted by remember(access) { mutableStateOf(access.hasAccess()) } + LifecycleResumeEffect(access) { + granted = access.hasAccess() + onPauseOrDispose {} + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(if (granted) Res.string.now_playing_access_granted else Res.string.now_playing_access_needed), + fontSize = 13.sp, + color = if (granted) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(16.dp)) + OutlinedButton(onClick = access::requestAccess) { + Text(stringRes(if (granted) Res.string.now_playing_manage_access else Res.string.now_playing_grant_access)) + } + } +} + +@Composable +private fun SwitchRow( + title: String, + description: String?, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + ) + if (description != null) { + Text( + text = description, + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + Spacer(modifier = Modifier.width(16.dp)) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + ) + } +} 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/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..911f74aada --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingCoordinator.kt @@ -0,0 +1,90 @@ +/* + * 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] 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, +) { + /** 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) { + otherApps.value = reader.read() + delay(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..d9b4910150 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MprisNowPlayingReader.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.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 { + override suspend fun read(): NowPlaying? { + val names = run(LIST_NAMES)?.let(MprisParser::playerNames).orEmpty() + + for (busName in names) { + val properties = run(getAll(busName))?.let(MprisParser::properties) ?: continue + if (properties["PlaybackStatus"]?.firstOrNull() != "Playing") continue + + val identity = 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()?.takeIf { it.startsWith("https://") }, + ) + } + + /** `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..a3f131c7d5 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReader.kt @@ -0,0 +1,75 @@ +/* + * 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.withContext +import java.util.concurrent.TimeUnit + +/** 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. */ +internal suspend fun runCommand( + command: List, + timeoutSeconds: Long = 5, +): String? = + withContext(Dispatchers.IO) { + try { + val process = + ProcessBuilder(command) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + process.outputStream.close() + // Wait before reading so a hung command cannot block the read forever. The outputs + // here are a few KB, well under the pipe buffer, so the command never blocks on it. + if (!process.waitFor(timeoutSeconds, TimeUnit.SECONDS)) { + process.destroyForcibly() + null + } else if (process.exitValue() != 0) { + null + } else { + process.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + } + } 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 + } + } 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..4fc575d62a --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt @@ -0,0 +1,201 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +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 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")) + } +} From 0d4241d65591d0113ae2d94557990305227a79d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 03:27:24 +0000 Subject: [PATCH 2/8] feat: redesign the Now Playing settings in the shared settings style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilds the screen on the SettingsSection / SettingsSwitchTile kit used by Profile UI, Home Tabs and Relay Auth settings: - "How others see it": a preview card with the account's avatar, name and live music status (same icon and color as the real status), with a Live / Example / Off chip. - "Share from": icon-badge switch tiles for Amethyst and other apps, with a quiet "Notification access is on · Manage" line once granted. - A tinted alert card with a single Grant access action while notification access is missing. - "Apps": real launcher icons on Android, colored monogram badges (the Messages settings palette) elsewhere; package ids only when they add information. Adds a headless render test of every state in both themes, which also catches missing string resources; set NOW_PLAYING_RENDER_DIR to write the PNGs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N2naGACCZy1GvqmZNCwpBu --- .../service/nowPlaying/AndroidAppIcon.kt | 66 +++ .../amethyst/ui/navigation/AppNavigation.kt | 3 +- .../composeResources/values/strings.xml | 24 +- .../settings/NowPlayingSettingsScreen.kt | 543 ++++++++++++++---- .../settings/NowPlayingSettingsRenderTest.kt | 156 +++++ 5 files changed, 673 insertions(+), 119 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/AndroidAppIcon.kt create mode 100644 commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsRenderTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/AndroidAppIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/AndroidAppIcon.kt new file mode 100644 index 0000000000..7cea29a589 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/AndroidAppIcon.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.nowPlaying + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.MonogramBadge +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** The launcher icon of [packageName] for the now-playing app list, or a monogram if it is hidden. */ +@Composable +fun AndroidAppIcon( + packageName: String, + label: String, +) { + val context = LocalContext.current + val icon by produceState(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/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 4bab06f572..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 @@ -203,6 +203,7 @@ 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 @@ -658,7 +659,7 @@ fun BuildNavigation( composableFromEnd { val context = LocalContext.current val access = remember(context) { AndroidNowPlayingAccess(context.applicationContext) } - NowPlayingSettingsScreen(accountViewModel, nav, access) + NowPlayingSettingsScreen(accountViewModel, nav, access) { appId, label -> AndroidAppIcon(appId, label) } } composableFromEnd { NotificationSettingsScreen(accountViewModel, nav) } composableFromEnd { ResourceUsageScreen(accountViewModel, nav) } diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 5a6a7e8a10..db818a9ed9 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -6364,15 +6364,23 @@ From %1$s Now Playing music listening status song podcast spotify nip38 share playing - Share the song or podcast you are listening to as your status. It shows under your name for anyone who sees your profile, and disappears when the track ends or you stop playing. - Share what I play in Amethyst - Music tracks and podcast episodes played in the app. - Share what I play in other apps - Spotify, YouTube Music, podcast players and any other app that shows media controls. - To see what other apps are playing, Amethyst needs notification access. It only reads media controls, never the content of your notifications. + 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 access - Notification access is on. + Manage + Notification access is on Apps Apps show up here after they play something. Turn one off to stop sharing it. 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 index 2ac357765c..bd1c6890dc 100644 --- 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 @@ -20,21 +20,33 @@ */ 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.HorizontalDivider +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.OutlinedButton 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 @@ -43,20 +55,38 @@ 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.compose.ui.unit.sp 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 @@ -64,20 +94,26 @@ 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. + * 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 = { @@ -85,154 +121,441 @@ fun NowPlayingSettingsScreen( }, ) { padding -> Column( - Modifier - .padding(padding) - .verticalScroll(rememberScrollState()), + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), ) { - NowPlayingSettingsContent(accountViewModel.account.nowPlayingSettings, access) + NowPlayingSettingsContent( + state = accountViewModel.account.nowPlayingSettings, + access = access, + appIcon = appIcon, + preview = { isOn -> AccountStatusPreview(accountViewModel, nav, isOn) }, + ) } } } /** - * The body of the settings screen, also embedded by front ends with their own settings layout. - * [showInApp] hides the in-app toggle where the app has no music or podcast player of its own. + * 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 - Text( - text = stringRes(Res.string.now_playing_description), - fontSize = 14.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), - ) + 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), + ) + } - if (showInApp) { - SwitchRow( - title = stringRes(Res.string.now_playing_share_in_app), - description = stringRes(Res.string.now_playing_share_in_app_description), - checked = settings.shareInApp, - onCheckedChange = state::setShareInApp, - ) - } - - SwitchRow( - title = stringRes(Res.string.now_playing_share_other_apps), - description = stringRes(Res.string.now_playing_share_other_apps_description), - checked = settings.shareOtherApps, - onCheckedChange = state::setShareOtherApps, - ) - - if (!settings.shareOtherApps) { - Spacer(modifier = Modifier.height(16.dp)) - return - } - - if (access != null) { - AccessRow(access) - } - - HorizontalDivider(thickness = 4.dp, modifier = Modifier.padding(vertical = 8.dp)) - - Text( - text = stringRes(Res.string.now_playing_apps), - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp), - ) - - if (settings.knownApps.isEmpty()) { - Text( - text = stringRes(Res.string.now_playing_apps_empty), - fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), - ) - } else { - settings.knownApps.entries - .sortedBy { it.value.lowercase() } - .forEach { (id, label) -> - SwitchRow( - title = label, - description = id.takeIf { it != label }, - checked = id !in settings.blockedApps, - onCheckedChange = { allowed -> state.setAppBlocked(id, !allowed) }, + 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) { + AccessGrantedRow(access) + } + } - Spacer(modifier = Modifier.height(16.dp)) + if (settings.shareOtherApps && access != null) { + 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 AccessRow(access: NowPlayingAccess) { - // The user grants access in system settings and comes back, so check again on every resume. - var granted by remember(access) { mutableStateOf(access.hasAccess()) } - LifecycleResumeEffect(access) { - granted = access.hasAccess() - onPauseOrDispose {} - } +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 } - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringRes(if (granted) Res.string.now_playing_access_granted else Res.string.now_playing_access_needed), - fontSize = 13.sp, - color = if (granted) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error, - modifier = Modifier.weight(1f), - ) - Spacer(modifier = Modifier.width(16.dp)) - OutlinedButton(onClick = access::requestAccess) { - Text(stringRes(if (granted) Res.string.now_playing_manage_access else Res.string.now_playing_grant_access)) + 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 SwitchRow( - title: String, - description: String?, +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, + ) { + Box(Modifier.size(44.dp).clip(CircleShape)) { 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) { + val granted = rememberAccessGranted(access) + if (!granted) return + + 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) { + val granted = rememberAccessGranted(access) + if (granted) return + + 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() - .padding(horizontal = 24.dp, vertical = 12.dp), + .clickable { onCheckedChange(!checked) } + .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - ) - if (description != null) { + 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 = description, - fontSize = 13.sp, + text = id, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } - Spacer(modifier = Modifier.width(16.dp)) - Switch( - checked = checked, - onCheckedChange = onCheckedChange, + 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/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..69ee28bc70 --- /dev/null +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/NowPlayingSettingsRenderTest.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.ui.screen.loggedIn.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +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.first { it.value === content }.key + 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 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") + } + } +} From 96a0fe1d3a43ff367b69c72ca05f5a5aaed0a93e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 13:12:06 +0000 Subject: [PATCH 3/8] fix: now-playing audit fixes (privacy, ordering, churn, cancellation) Bugs: - Desktop MPRIS: browsers put the playing tab's page URL in xesam:url, which went out as the status' `r` tag. Only Spotify track/episode links are shared now; pages and file paths never are. - In-app: only the most recently started player was considered, so an unmuted feed video started after a song hid the song. All audible players are checked, newest first, and the first track wins. - In-app: a position update moved its player to the end of the registry, making it "current" over players started later. - Relays are woken before publishing, not after. - The listener service no longer logs the account in: AppModules already does on every process start, and a second call raced it. - Desktop commands are interruptible and their process is killed on the way out, so turning sharing off or quitting no longer waits on (or leaks) a running PowerShell. Performance: - Players and other apps that report their position every second no longer re-emit downstream (NowPlaying.isSameMoment, 5s tolerance). - In-app items are resolved (NIP-19 parse + cache lookup) once per item, not on every timeline tick of a live stream. - App labels are cached instead of a PackageManager IPC per callback. - The notification listener asks only for ongoing notifications. - MPRIS player names are cached; desktop polls every 30s while nothing plays (15s while playing). - The settings screen checks notification access once per resume. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N2naGACCZy1GvqmZNCwpBu --- amethyst/src/main/AndroidManifest.xml | 5 ++ .../service/nowPlaying/InAppPlayback.kt | 25 ++++++--- .../nowPlaying/NowPlayingStatusCoordinator.kt | 52 ++++++++++++++----- .../nowPlaying/OtherAppsNowPlayingService.kt | 30 ++++++----- .../nowPlaying/NowPlaying.kt | 18 +++++++ .../nowPlaying/NowPlayingResolver.kt | 18 ++++--- .../nowPlaying/NowPlayingPublisherTest.kt | 14 +++++ .../settings/NowPlayingSettingsScreen.kt | 12 ++--- .../DesktopNowPlayingCoordinator.kt | 12 +++-- .../nowPlaying/MprisNowPlayingReader.kt | 19 ++++++- .../desktop/nowPlaying/OsNowPlayingReader.kt | 27 ++++++---- .../nowPlaying/OsNowPlayingReaderTest.kt | 17 ++++++ 12 files changed, 186 insertions(+), 63 deletions(-) diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index d5d91e424e..467e9ac3d9 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -573,6 +573,11 @@ + + > = playing - /** The most recently started playback, if any. */ - fun current(all: Map): InAppPlayback? = all.values.lastOrNull() - fun update( player: Player, playback: InAppPlayback?, ) { val key = System.identityHashCode(player) playing.update { current -> + val previous = current[key] when { - playback == null -> if (key in current) current - key else current - current[key] == playback -> current - // Remove first so a new track moves to the end and becomes the current one. + 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) } } 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 index 4998f945a2..40485ae3df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/NowPlayingStatusCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/NowPlayingStatusCoordinator.kt @@ -68,22 +68,25 @@ class NowPlayingStatusCoordinator( } 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 { InAppPlaybackRegistry.current(it) } - .distinctUntilChanged() - .map { playback -> playback?.let(::resolve) } + .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 -> - account.publishNowPlaying(track, expiration) holdRelays() + account.publishNowPlaying(track, expiration) }, clear = { - account.clearNowPlaying() holdRelays() + account.clearNowPlaying() }, ) @@ -95,14 +98,36 @@ class NowPlayingStatusCoordinator( ).run() } - private fun resolve(playback: InAppPlayback): NowPlaying? = - NowPlayingResolver.fromNostrUri( - uri = playback.callbackUri, - findEvent = { key -> LocalCache.getNoteIfExists(key)?.event }, - fallbackTitle = playback.title, - fallbackArtist = playback.artist, - endsAt = playback.endsAt, - ) + /** + * 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() @@ -114,5 +139,6 @@ class NowPlayingStatusCoordinator( 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 index 6a8a69718a..a461d2b516 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/OtherAppsNowPlayingService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nowPlaying/OtherAppsNowPlayingService.kt @@ -27,6 +27,7 @@ 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 @@ -36,7 +37,6 @@ import android.os.SystemClock import android.provider.Settings import android.service.notification.NotificationListenerService import androidx.core.app.NotificationManagerCompat -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlaying import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSource import com.vitorpamplona.quartz.utils.Log @@ -51,6 +51,8 @@ object OtherAppsPlaybackRegistry { 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 } } @@ -64,12 +66,17 @@ object OtherAppsPlaybackRegistry { * "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. + * 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>() + 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 -> @@ -89,9 +96,6 @@ class OtherAppsNowPlayingService : NotificationListenerService() { // Access was revoked between the bind and this call. Log.w("OtherAppsNowPlaying", "Notification access is not granted", e) } - - // The system can start the process just to bind this listener; the publisher needs the account. - Amethyst.instance.sessionManager.loginWithDefaultAccountIfLoggedOff() } override fun onListenerDisconnected() { @@ -111,14 +115,14 @@ class OtherAppsNowPlayingService : NotificationListenerService() { private fun watch(controllers: List) { val others = controllers.filter { it.packageName != packageName } - val keys = others.map { it.sessionToken.toString() }.toSet() + 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.toString() + val key = controller.sessionToken if (key !in watched) { val callback = object : MediaController.Callback() { @@ -182,10 +186,12 @@ class OtherAppsNowPlayingService : NotificationListenerService() { } private fun appLabel(packageName: String): String = - try { - packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)).toString() - } catch (_: PackageManager.NameNotFoundException) { - packageName + labels.getOrPut(packageName) { + try { + packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)).toString() + } catch (_: PackageManager.NameNotFoundException) { + packageName + } } companion object { 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 index ca57ed9045..32287b1968 100644 --- 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 @@ -22,6 +22,7 @@ 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 @@ -72,7 +73,24 @@ data class NowPlaying( 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. 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 index 494e2bc298..8d203fc781 100644 --- 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 @@ -53,18 +53,20 @@ object NowPlayingResolver { fallbackArtist: String? = null, endsAt: Long? = null, ): NowPlaying? { - val key = - when (val entity = Nip19Parser.uriToRoute(uri)?.entity) { - is NEvent -> entity.hex - is NNote -> entity.hex - is NAddress -> entity.aTag() - else -> null - } ?: return null - + 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, 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 index 0854204e8e..c4761cb6b1 100644 --- 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 @@ -29,7 +29,9 @@ 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 { @@ -205,6 +207,18 @@ class NowPlayingPublisherTest { 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)) 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 index bd1c6890dc..973e5381aa 100644 --- 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 @@ -163,6 +163,8 @@ fun NowPlayingSettingsContent( ) { 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(), @@ -197,12 +199,12 @@ fun NowPlayingSettingsContent( checked = settings.shareOtherApps, onCheckedChange = state::setShareOtherApps, ) - if (settings.shareOtherApps && access != null) { + if (settings.shareOtherApps && access != null && accessGranted) { AccessGrantedRow(access) } } - if (settings.shareOtherApps && access != null) { + if (settings.shareOtherApps && access != null && !accessGranted) { AccessNeededCard(access) } @@ -375,9 +377,6 @@ private fun StatusChip(chip: PreviewChip) { /** Sub-row under the "Other apps" tile once access is granted: a quiet confirmation + Manage. */ @Composable private fun AccessGrantedRow(access: NowPlayingAccess) { - val granted = rememberAccessGranted(access) - if (!granted) return - Row( modifier = Modifier.fillMaxWidth().padding(start = 68.dp, end = 8.dp, bottom = 6.dp), verticalAlignment = Alignment.CenterVertically, @@ -403,9 +402,6 @@ private fun AccessGrantedRow(access: NowPlayingAccess) { /** The one thing blocking other-app sharing, as a tonal card with a single clear action. */ @Composable private fun AccessNeededCard(access: NowPlayingAccess) { - val granted = rememberAccessGranted(access) - if (granted) return - Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), 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 index 911f74aada..16da363837 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/DesktopNowPlayingCoordinator.kt @@ -37,7 +37,8 @@ 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] only while the account has + * 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. * @@ -49,6 +50,7 @@ class DesktopNowPlayingCoordinator( 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() { @@ -67,8 +69,12 @@ class DesktopNowPlayingCoordinator( return@collectLatest } while (true) { - otherApps.value = reader.read() - delay(pollMs) + 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) } } } 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 index d9b4910150..9290de3642 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MprisNowPlayingReader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/MprisNowPlayingReader.kt @@ -33,14 +33,21 @@ 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 = run(getIdentity(busName))?.let(MprisParser::properties)?.get("Identity")?.firstOrNull() + val identity = + identities.getOrPut(busName) { + run(getIdentity(busName))?.let(MprisParser::properties)?.get("Identity")?.firstOrNull() + } return MprisParser.toNowPlaying(busName, identity, properties, nowSeconds()) } @@ -170,10 +177,18 @@ object MprisParser { artist = artist, source = NowPlayingSource.OtherApp(id, label), endsAt = NowPlaying.endsAt(nowSeconds, durationMs, positionMs, rate), - url = properties["xesam:url"]?.firstOrNull()?.takeIf { it.startsWith("https://") }, + 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 { 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 index a3f131c7d5..7a8cb58c6d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReader.kt @@ -23,7 +23,7 @@ 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.withContext +import kotlinx.coroutines.runInterruptible import java.util.concurrent.TimeUnit /** Reads what the OS says another app is playing right now. */ @@ -45,31 +45,38 @@ interface OsNowPlayingReader { } } -/** Runs a short-lived command and returns its stdout, or null if it failed or took too long. */ +/** + * 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? = - withContext(Dispatchers.IO) { + runInterruptible(Dispatchers.IO) { + var process: Process? = null try { - val process = + val started = ProcessBuilder(command) .redirectError(ProcessBuilder.Redirect.DISCARD) .start() - process.outputStream.close() + process = started + started.outputStream.close() // Wait before reading so a hung command cannot block the read forever. The outputs // here are a few KB, well under the pipe buffer, so the command never blocks on it. - if (!process.waitFor(timeoutSeconds, TimeUnit.SECONDS)) { - process.destroyForcibly() - null - } else if (process.exitValue() != 0) { + if (!started.waitFor(timeoutSeconds, TimeUnit.SECONDS) || started.exitValue() != 0) { null } else { - process.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + started.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } } + } 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/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt index 4fc575d62a..02e0bc97c2 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt @@ -161,6 +161,23 @@ class OsNowPlayingReaderTest { 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")) From 322726ab517ee3722f2143f096d37c3815de44ae Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 13:44:27 +0000 Subject: [PATCH 4/8] feat: compact link preview from , meta description and favicon Pages without OpenGraph (e.g. most nsites) never got a preview card: the fetch was only kept when it found an image, so they fell back to a bare link even when they had a perfectly good title and description. - MetaTagsParser can now also yield the document <title> and icon <link>s (opt-in via includeTitleAndIcons, used by HtmlParser). - OpenGraphParser falls back to <title> when no og/twitter/meta title exists, and picks the best icon (scalable > largest declared size; apple-touch-icon defaults to 180px; mask-icon ignored). - UrlInfoItem keeps a page with a title or description, and resolves its icon (falling back to /favicon.ico at the origin). - UrlPreviewCard renders a short, wide card (icon left; title, description and host right) when there is no image, with a globe placeholder until the icon loads. The composer's thumbnail falls back to the icon too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KdPp7UiETpzv8jQ5qfAFqp --- .../ui/note/creators/previews/PreviewUrl.kt | 10 +- .../amethyst/commons/preview/HtmlParser.kt | 2 +- .../commons/preview/MetaTagsParser.kt | 123 +++++++++++- .../commons/preview/OpenGraphParser.kt | 67 ++++++- .../amethyst/commons/preview/UrlInfoItem.kt | 26 ++- .../preview/TitleAndIconFallbackTest.kt | 179 +++++++++++++++++ .../amethyst/commons/preview/UrlPreview.kt | 1 + .../preview/UrlInfoItemTextPreviewTest.kt | 87 ++++++++ .../commons/ui/components/UrlPreviewCard.kt | 187 +++++++++++++++--- 9 files changed, 637 insertions(+), 45 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/TitleAndIconFallbackTest.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItemTextPreviewTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt index 72aa4b9509..1c50e33e81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -39,6 +40,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.richtext.RichTextParser @@ -239,11 +241,13 @@ private fun MyLoadUrlPreviewDirect( ) } else { Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier.aspectRatio(1f)) { + // A page with no OpenGraph image still has its icon: shown whole, not cropped. + val hasImage = state.previewInfo.imageUrlFullPath.isNotBlank() AsyncImage( - model = state.previewInfo.imageUrlFullPath, + model = if (hasImage) state.previewInfo.imageUrlFullPath else state.previewInfo.iconUrlFullPath, contentDescription = state.previewInfo.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), + contentScale = if (hasImage) ContentScale.Crop else ContentScale.Fit, + modifier = if (hasImage) Modifier.fillMaxSize() else Modifier.fillMaxSize().padding(20.dp), ) Text( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt index 823862bf31..27571bebae 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt @@ -49,7 +49,7 @@ class HtmlParser { ?: bodyBytes.bomCharsetName() ?: HtmlCharsetParser.detectCharset(bodyBytes) val content = decodeBytes(bodyBytes, name) - MetaTagsParser.parse(content) + MetaTagsParser.parse(content, includeTitleAndIcons = true) } private fun ByteArray.bomCharsetName(): String? { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt index 62e46ced82..6fefdf4dc2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt @@ -22,8 +22,21 @@ package com.vitorpamplona.amethyst.commons.preview import com.vitorpamplona.amethyst.commons.util.codePointToChars +/** The `<head>` element a [MetaTag] was read from. */ +enum class HeadElement { + /** A `<meta>` tag, the only kind [MetaTagsParser.parse] yields unless asked for more. */ + META, + + /** The document's `<title>`, its text carried as the `content` attribute. */ + TITLE, + + /** A `<link>` whose `rel` names an icon (`icon`, `shortcut icon`, `apple-touch-icon`, …). */ + LINK, +} + data class MetaTag( private val attrs: Map<String, String>, + val element: HeadElement = HeadElement.META, ) { /** * Returns a value of an attribute specified by its name (case insensitive), or empty string if it doesn't exist. @@ -43,7 +56,16 @@ object MetaTagsParser { private const val NO_QUOTE = ' ' private const val META = "meta" + private const val LINK = "link" private const val HEAD = "head" + private const val ICON = "icon" + private const val REL = "rel" + private const val CONTENT = "content" + + // A `<title>` is only a fallback label, so one that runs on (an unclosed tag swallowing the + // page, a spam keyword dump) is cut rather than carried into the preview cache whole. + private const val MAX_TITLE_LENGTH = 300 + private val WHITESPACE_RUN = Regex("\\s+") // Elements whose content is text rather than markup: script and style hold raw text, title and // textarea hold character data. A `<` inside any of them is not a tag. @@ -66,26 +88,81 @@ object MetaTagsParser { /** The `</head>` that ends the interesting part of the document. */ HEAD_END, + /** A closed `<title>`: its text is [TagScanner.textStart]..<[TagScanner.textEnd]. */ + TITLE, + + /** A `<link …>` whose attribute span mentions `icon`; same span fields as [META]. */ + LINK, + /** Anything else: other elements, comments, declarations, unparseable markup. */ OTHER, } /** * Lazily parse a partial HTML document and extract meta tags. + * + * With [includeTitleAndIcons], the document's `<title>` and its icon `<link>`s are yielded + * too, tagged by [MetaTag.element]. They are what a page without OpenGraph still offers for a + * preview: browsers show exactly these two in a tab. */ - fun parse(input: String): Sequence<MetaTag> = + fun parse( + input: String, + includeTitleAndIcons: Boolean = false, + ): Sequence<MetaTag> = sequence { val s = TagScanner(input) while (!s.exhausted()) { - val kind = s.nextTag() - if (kind == TagKind.HEAD_END) break - if (kind == TagKind.META) { - val attrs = parseAttrs(input, s.attrsStart, s.attrsEnd) ?: continue - yield(MetaTag(attrs)) + when (s.nextTag()) { + TagKind.HEAD_END -> { + break + } + + TagKind.META -> { + val attrs = parseAttrs(input, s.attrsStart, s.attrsEnd) ?: continue + yield(MetaTag(attrs)) + } + + TagKind.TITLE -> { + if (includeTitleAndIcons) { + val text = titleText(input, s.textStart, s.textEnd) + if (text.isNotEmpty()) yield(MetaTag(mapOf(CONTENT to text), HeadElement.TITLE)) + } + } + + TagKind.LINK -> { + if (includeTitleAndIcons) { + val attrs = parseAttrs(input, s.attrsStart, s.attrsEnd) ?: continue + if (isIconRel(attrs[REL])) yield(MetaTag(attrs, HeadElement.LINK)) + } + } + + TagKind.OTHER -> {} } } } + /** + * Whether a `rel` names an icon: one of its space-separated tokens is `icon` or an + * `apple-touch-icon` variant. `mask-icon` is deliberately not one -- it is Safari's monochrome + * pinned-tab silhouette, which renders as a black blob anywhere else. + */ + private fun isIconRel(rel: String?): Boolean = + rel != null && + rel.split(' ', '\t', '\n', '\r', '\u000C').any { + it.equals(ICON, ignoreCase = true) || it.startsWith("apple-touch-icon", ignoreCase = true) + } + + /** The `<title>`'s character data, with references resolved and whitespace collapsed. */ + private fun titleText( + input: String, + from: Int, + to: Int, + ): String { + val raw = input.substring(from, to) + val decoded = if (raw.indexOf('&') < 0) raw else raw.replace(Attrs.RE_CHAR_REF, Attrs.Companion::replaceCharRefs) + return decoded.replace(WHITESPACE_RUN, " ").trim().take(MAX_TITLE_LENGTH) + } + private class TagScanner( private val input: String, ) { @@ -98,6 +175,12 @@ object MetaTagsParser { var attrsEnd = 0 private set + /** Character-data span of the `<title>` [nextTag] last reported as [TagKind.TITLE]. */ + var textStart = 0 + private set + var textEnd = 0 + private set + fun exhausted(): Boolean = p >= length /** @@ -128,6 +211,20 @@ object MetaTagsParser { p = if (end < 0) length else end + 1 } + /** True when `input[from..<to]` contains [lower], ASCII-case-insensitively. */ + private fun spanContains( + from: Int, + to: Int, + lower: String, + ): Boolean { + var i = from + while (i + lower.length <= to) { + if (input.regionMatches(i, lower, 0, lower.length, ignoreCase = true)) return true + i++ + } + return false + } + /** Leaves [p] on the `</name` that closes a raw-text element, or at the end of the input. */ private fun skipRawText(endTag: String) { var i = p @@ -215,14 +312,24 @@ object MetaTagsParser { // the same way an unbalanced quote inside a comment does. Switching on the name length // first keeps the common tag (a `<div>`, a `<link>`) down to one comparison. when (nameEnd - nameStart) { - META.length -> if (nameIs(nameStart, nameEnd, META)) return TagKind.META + META.length -> { + if (nameIs(nameStart, nameEnd, META)) return TagKind.META + // Most `<link>`s are stylesheets and preloads; checking the raw span for `icon` + // keeps their attributes from ever being parsed into a map. + if (nameIs(nameStart, nameEnd, LINK) && spanContains(attrsStart, attrsEnd, ICON)) return TagKind.LINK + } - STYLE.length -> + STYLE.length -> { if (nameIs(nameStart, nameEnd, STYLE)) { skipRawText(STYLE_END) } else if (nameIs(nameStart, nameEnd, TITLE)) { + textStart = p skipRawText(TITLE_END) + textEnd = p + // An unclosed title runs to the end of the input: that is not a title. + if (p < length) return TagKind.TITLE } + } SCRIPT.length -> if (nameIs(nameStart, nameEnd, SCRIPT)) skipRawText(SCRIPT_END) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt index 9e4714756b..3c0dfe3667 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.preview class OpenGraphParser { class Result( + /** The OpenGraph/Twitter/meta title, or the document's `<title>` when it declares none. */ val title: String, val description: String, val image: String, @@ -35,6 +36,8 @@ class OpenGraphParser { val videoType: String = "", /** `og:type` — what the page says it *is*, e.g. `music.song`, `video.other`, `article`. */ val type: String = "", + /** The best icon `<link>` the page declares, verbatim (may be relative). Empty when none. */ + val icon: String = "", ) companion object { @@ -112,6 +115,17 @@ class OpenGraphParser { ) private val CONTENT = "content" + private val HREF = "href" + private val REL = "rel" + private val SIZES = "sizes" + private val TYPE = "type" + + // How an icon with no `sizes` is ranked. An `apple-touch-icon` is 180px by Apple's + // convention; a plain `icon` without sizes is usually the 16/32px tab favicon. A scalable + // one (SVG, or `sizes="any"`) beats every bitmap: it is sharp at any card size. + private const val APPLE_TOUCH_ICON_DEFAULT_SIZE = 180 + private const val ICON_DEFAULT_SIZE = 32 + private const val SCALABLE_ICON_SIZE = Int.MAX_VALUE } /** Which field of [Result] a meta tag's key fills, or null when the key is not one we read. */ @@ -156,8 +170,33 @@ class OpenGraphParser { var video = "" var videoType = "" var type = "" + var documentTitle = "" + var icon = "" + var iconSize = -1 metaTags.forEach { + when (it.element) { + HeadElement.META -> {} + + HeadElement.TITLE -> { + if (documentTitle.isEmpty()) documentTitle = it.attr(CONTENT) + return@forEach + } + + HeadElement.LINK -> { + val href = it.attr(HREF) + if (href.isNotBlank()) { + val size = iconSize(it) + // Strictly greater, so among equals the first declared wins. + if (size > iconSize) { + icon = href + iconSize = size + } + } + return@forEach + } + } + // A meta tag names its key in exactly one of these three attributes, but which one // varies by site, so each is tried in turn until one is a key we read. val field = @@ -179,6 +218,32 @@ class OpenGraphParser { null -> Unit } } - return Result(title, description, image, audio, audioType, video, videoType, type) + return Result(title.ifEmpty { documentTitle }, description, image, audio, audioType, video, videoType, type, icon) + } + + /** The pixel size an icon `<link>` is ranked by: its largest declared size, or a default. */ + private fun iconSize(link: MetaTag): Int { + val sizes = link.attr(SIZES) + if (sizes.contains("any", ignoreCase = true)) return SCALABLE_ICON_SIZE + if (link.attr(TYPE).equals("image/svg+xml", ignoreCase = true)) return SCALABLE_ICON_SIZE + if (link + .attr(HREF) + .substringBefore('?') + .substringBefore('#') + .endsWith(".svg", ignoreCase = true) + ) { + return SCALABLE_ICON_SIZE + } + + // `sizes="16x16 32x32"`: rank by the largest, which is the one a decoder will pick. + val declared = + sizes + .split(' ') + .mapNotNull { it.lowercase().substringBefore('x', "").toIntOrNull() } + .maxOrNull() + if (declared != null) return declared + + val isAppleTouch = link.attr(REL).contains("apple-touch-icon", ignoreCase = true) + return if (isAppleTouch) APPLE_TOUCH_ICON_DEFAULT_SIZE else ICON_DEFAULT_SIZE } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt index 5cb955845d..00b24466cf 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt @@ -43,6 +43,8 @@ class UrlInfoItem( val videoType: String = "", /** `og:type` — what the page says it is, e.g. `music.song`, `video.other`, `article`. */ val type: String = "", + /** The page's best declared icon `<link>` (favicon / apple-touch-icon). Empty when none. */ + val icon: String = "", ) { /** The page's host, or null when [url] is not an absolute URL. */ val verifiedHost = absoluteUrlHost(url) @@ -66,6 +68,14 @@ class UrlInfoItem( // which is what it did before. Only the playable gate below treats unresolvable as refusal. val imageUrlFullPath = resolve(image) ?: image + /** + * The page's icon, absolute: the one it declared, else `/favicon.ico` at its origin -- the + * same guess every browser makes, since most sites that declare nothing still serve one. + * Null for a non-HTML URL (an image or a video is its own preview) or an unresolvable one. + */ + val iconUrlFullPath: String? = + if (mimeType.startsWith("text/html")) resolve(icon) ?: resolve(DEFAULT_FAVICON) else null + /** * The declared `og:video`, absolute, but only when the declaration holds up: a [videoType] the * renderer can actually play (a `video/` MIME, an `audio/` one, or an HLS playlist type), or @@ -119,13 +129,25 @@ class UrlInfoItem( else -> audioType.ifEmpty { null } } + /** + * Whether the page has text to show without an image: a title (OpenGraph's or the document's + * own `<title>`) or a description. Such a page gets the compact icon + text card rather than + * a bare link. + */ + val hasTextPreview: Boolean = title.isNotBlank() || description.isNotBlank() + /** * Whether the fetch produced something worth rendering. An image is the usual evidence, but a * page that declared playable media counts too — a track page that ships no cover art would * otherwise be thrown away as Empty and fall back to a bare link, which is exactly the player - * we went to the trouble of finding. + * we went to the trouble of finding. So does a page with no OpenGraph at all that still has a + * title or description: it renders as the compact card instead of a bare link. */ - fun fetchComplete(): Boolean = url.isNotEmpty() && (image.isNotEmpty() || playableMediaUrl != null) + fun fetchComplete(): Boolean = url.isNotEmpty() && (image.isNotEmpty() || playableMediaUrl != null || hasTextPreview) fun allFetchComplete(): Boolean = title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty() + + companion object { + private const val DEFAULT_FAVICON = "/favicon.ico" + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/TitleAndIconFallbackTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/TitleAndIconFallbackTest.kt new file mode 100644 index 0000000000..d0f9db83cc --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/TitleAndIconFallbackTest.kt @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.preview + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A page with no OpenGraph still has what a browser tab shows: its `<title>` and its icon + * `<link>`s. These are read only when asked for, and only ever as a fallback. + */ +class TitleAndIconFallbackTest { + private fun headTags(html: String) = MetaTagsParser.parse(html, includeTitleAndIcons = true).toList() + + private fun extract(html: String) = OpenGraphParser().extractUrlInfo(MetaTagsParser.parse(html, includeTitleAndIcons = true)) + + // Trimmed from the nsite that prompted this: no og:*, no twitter:*, only a title, a meta + // description and an SVG favicon. + private val noOpenGraphPage = + """ + |<!DOCTYPE html> + |<html lang="en"> + |<head> + |<meta charset="utf-8"> + |<meta name="viewport" content="width=device-width, initial-scale=1"> + |<title>Private Provider — Confidential AI + | + | + | + | + |

Hi

+ | + """.trimMargin() + + @Test + fun aPageWithoutOpenGraphStillYieldsTitleDescriptionAndIcon() { + val info = extract(noOpenGraphPage) + + assertEquals("Private Provider — Confidential AI", info.title) + assertEquals("A phone-first AI workspace for Android.", info.description) + assertEquals("favicon.svg", info.icon) + assertEquals("", info.image) + } + + @Test + fun theDefaultParseStillYieldsOnlyMetaTags() { + val tags = MetaTagsParser.parse(noOpenGraphPage).toList() + + assertEquals(3, tags.size) + assertTrue(tags.all { it.element == HeadElement.META }) + } + + @Test + fun onlyIconLinksAreYielded() { + val tags = headTags(noOpenGraphPage) + + val links = tags.filter { it.element == HeadElement.LINK } + assertEquals(1, links.size) + assertEquals("favicon.svg", links[0].attr("href")) + } + + @Test + fun openGraphTitleWinsOverTheDocumentTitle() { + val info = + extract( + """ + | + | Document Title + | + | + """.trimMargin(), + ) + + assertEquals("OG Title", info.title) + } + + @Test + fun titleWhitespaceIsCollapsed() { + val info = extract("\n Spread\n\t Out ") + + assertEquals("Spread Out", info.title) + } + + @Test + fun anUnclosedTitleIsNotATitle() { + val info = extract("runs to the end of the truncated body") + + assertEquals("", info.title) + } + + @Test + fun aTitleOutsideTheHeadIsIgnored() { + // An SVG in the body carries its own <title>; the scan stops at </head> before it. + val info = extract("<head></head><body><svg><title>Close") + + assertEquals("", info.title) + } + + @Test + fun theFirstTitleWins() { + val info = extract("FirstSecond") + + assertEquals("First", info.title) + } + + @Test + fun theLargestIconWins() { + val info = + extract( + """ + | + | + | + | + | + | + """.trimMargin(), + ) + + assertEquals("/apple-touch-icon.png", info.icon) + } + + @Test + fun aScalableIconBeatsBitmaps() { + val info = + extract( + """ + | + | + | + | + """.trimMargin(), + ) + + assertEquals("/icon.svg?v=2", info.icon) + } + + @Test + fun maskIconIsNotUsed() { + // Safari's monochrome pinned-tab silhouette renders as a black blob anywhere else. + val info = + extract( + """ + | + | + | + | + """.trimMargin(), + ) + + assertEquals("/favicon-32.png", info.icon) + } + + @Test + fun iconLinksAfterTheHeadAreIgnored() { + val info = extract("""""") + + assertEquals("", info.icon) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt index 78a918541f..3410eec81c 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt @@ -77,6 +77,7 @@ class UrlPreview { data.video, data.videoType, data.type, + data.icon, ) } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItemTextPreviewTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItemTextPreviewTest.kt new file mode 100644 index 0000000000..eaeeeb96a4 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItemTextPreviewTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.preview + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A page with no OpenGraph image is still worth a card when it has text: the compact icon + text + * card, built from its title, description and favicon. + */ +class UrlInfoItemTextPreviewTest { + private fun page( + url: String = "https://example.nsite.lol/", + title: String = "", + description: String = "", + image: String = "", + icon: String = "", + mimeType: String = "text/html; charset=utf-8", + ) = UrlInfoItem(url = url, title = title, description = description, image = image, mimeType = mimeType, icon = icon) + + @Test + fun aTitleAloneIsEnoughToKeepThePreview() { + assertTrue(page(title = "Private Provider").fetchComplete()) + } + + @Test + fun aDescriptionAloneIsEnoughToKeepThePreview() { + assertTrue(page(description = "A workspace").fetchComplete()) + } + + @Test + fun aPageWithNothingStaysEmpty() { + assertFalse(page().fetchComplete()) + assertFalse(page(title = " ").fetchComplete()) + } + + @Test + fun aRelativeIconIsResolvedAgainstThePage() { + assertEquals( + "https://example.nsite.lol/docs/favicon.svg", + page(url = "https://example.nsite.lol/docs/index.html", icon = "favicon.svg").iconUrlFullPath, + ) + } + + @Test + fun noDeclaredIconFallsBackToTheOriginFavicon() { + assertEquals( + "https://example.nsite.lol/favicon.ico", + page(url = "https://example.nsite.lol/a/b/c.html").iconUrlFullPath, + ) + } + + @Test + fun aNonHttpIconFallsBackToTheOriginFavicon() { + assertEquals( + "https://example.nsite.lol/favicon.ico", + page(icon = "file:///etc/passwd").iconUrlFullPath, + ) + } + + @Test + fun nonHtmlUrlsHaveNoIcon() { + assertNull(page(url = "https://example.com/a.png", image = "https://example.com/a.png", mimeType = "image/png").iconUrlFullPath) + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewCard.kt index a084cb0edf..59265eb13d 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewCard.kt @@ -21,24 +21,33 @@ package com.vitorpamplona.amethyst.commons.ui.components import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -50,9 +59,12 @@ import com.vitorpamplona.amethyst.commons.resources.link_actions_dialog_title import com.vitorpamplona.amethyst.commons.resources.url_preview_open_in_browser import com.vitorpamplona.amethyst.commons.ui.components.util.setText import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.MaxWidthWithHorzPadding import com.vitorpamplona.amethyst.commons.ui.theme.Size14Modifier +import com.vitorpamplona.amethyst.commons.ui.theme.Size24Modifier +import com.vitorpamplona.amethyst.commons.ui.theme.SmallBorder import com.vitorpamplona.amethyst.commons.ui.theme.innerPostModifier import com.vitorpamplona.amethyst.commons.ui.theme.previewCardImageModifier import kotlinx.coroutines.launch @@ -101,22 +113,42 @@ fun UrlPreviewCard( } } - Column( - modifier = - MaterialTheme.colorScheme.innerPostModifier - .combinedClickable( - onClick = { - if (onCardClick != null) { - onCardClick() - } else { - runCatching { uri.openUri(url) } - } - }, - onLongClick = { - popupExpanded.value = true - }, - ), - ) { + val cardModifier = + MaterialTheme.colorScheme.innerPostModifier + .combinedClickable( + onClick = { + if (onCardClick != null) { + onCardClick() + } else { + runCatching { uri.openUri(url) } + } + }, + onLongClick = { + popupExpanded.value = true + }, + ) + + // Only meaningful when the card's own tap does something else (e.g. opening the comment + // thread); otherwise it would duplicate the card's open-in-browser tap. + val onOpenInBrowser: (() -> Unit)? = onCardClick?.let { { runCatching { uri.openUri(url) } } } + + // A page with no picture to lead with -- most often one with no OpenGraph at all, previewed + // from its ``, meta description and favicon -- gets the short, wide card. Painting the + // big layout without its image would leave a text block that only looks broken. + if (previewInfo.imageUrlFullPath.isBlank() && previewInfo.hasTextPreview) { + CompactUrlPreviewCard(previewInfo, cardModifier, onOpenInBrowser) + } else { + LargeUrlPreviewCard(previewInfo, cardModifier, onOpenInBrowser) + } +} + +@Composable +private fun LargeUrlPreviewCard( + previewInfo: UrlInfoItem, + modifier: Modifier, + onOpenInBrowser: (() -> Unit)?, +) { + Column(modifier = modifier) { // A Loaded preview no longer implies an image: a player page that ships no cover art is // kept (it has media to play), and painting its empty string left a blank 180dp box. if (previewInfo.imageUrlFullPath.isNotBlank()) { @@ -141,20 +173,7 @@ fun UrlPreviewCard( overflow = TextOverflow.Ellipsis, ) - // Only meaningful when the card's own tap does something else (e.g. opening the - // comment thread); otherwise it would duplicate the card's open-in-browser tap. - if (onCardClick != null) { - IconButton( - onClick = { runCatching { uri.openUri(url) } }, - ) { - Icon( - symbol = MaterialSymbols.AutoMirrored.OpenInNew, - contentDescription = stringRes(Res.string.url_preview_open_in_browser), - modifier = Size14Modifier, - tint = Color.Gray, - ) - } - } + onOpenInBrowser?.let { OpenInBrowserButton(it) } } Text( @@ -177,3 +196,111 @@ fun UrlPreviewCard( Spacer(modifier = DoubleVertSpacer) } } + +/** + * The short, wide card: the site's icon on the left, its title, description and host on the + * right. Built from what every page has even without OpenGraph -- what a browser tab shows. + */ +@Composable +private fun CompactUrlPreviewCard( + previewInfo: UrlInfoItem, + modifier: Modifier, + onOpenInBrowser: (() -> Unit)?, +) { + val host = previewInfo.verifiedHost ?: previewInfo.url + + Row( + modifier = modifier.padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UrlPreviewIcon(previewInfo.iconUrlFullPath, host) + + Spacer(modifier = DoubleHorzSpacer) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = previewInfo.title.ifBlank { host }, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (previewInfo.description.isNotBlank()) { + Text( + text = previewInfo.description, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + // When there is no title the host already stands in for it on the first line. + if (previewInfo.title.isNotBlank()) { + Text( + text = host, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + onOpenInBrowser?.let { OpenInBrowserButton(it) } + } +} + +/** + * The site's icon in a fixed square. A generic globe sits there until the icon actually loads, + * so a site whose favicon is missing (the `/favicon.ico` guess is only a guess) or undecodable + * still gets a tidy card instead of an empty hole. + */ +@Composable +private fun UrlPreviewIcon( + iconUrl: String?, + contentDescription: String, +) { + Box( + modifier = + UrlPreviewIconModifier + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center, + ) { + var loaded by remember(iconUrl) { mutableStateOf(false) } + + if (!loaded) { + Icon( + symbol = MaterialSymbols.Language, + contentDescription = null, + modifier = Size24Modifier, + tint = Color.Gray, + ) + } + + if (iconUrl != null) { + AsyncImage( + model = iconUrl, + contentDescription = contentDescription, + contentScale = ContentScale.Fit, + modifier = UrlPreviewIconImageModifier, + onSuccess = { loaded = true }, + ) + } + } +} + +@Composable +private fun OpenInBrowserButton(onClick: () -> Unit) { + IconButton(onClick = onClick) { + Icon( + symbol = MaterialSymbols.AutoMirrored.OpenInNew, + contentDescription = stringRes(Res.string.url_preview_open_in_browser), + modifier = Size14Modifier, + tint = Color.Gray, + ) + } +} + +private val UrlPreviewIconModifier = Modifier.size(48.dp).clip(SmallBorder) +private val UrlPreviewIconImageModifier = Modifier.fillMaxSize().padding(6.dp) From bc1f970dd989df110f0a465eed8ef2795254dc53 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 1 Oct 2026 14:29:10 +0000 Subject: [PATCH 5/8] fix: stop clipping the follow mark off the Now Playing preview avatar The preview card wrapped the account's avatar in a circular clip. The avatar already rounds its own picture; the follow mark sits in the top-right corner of its square box (and user cards along the bottom), so the extra clip cut them off. The slot is now only sized. Adds a render test that draws a marker in the avatar's top-right corner: with the old clip only 207 of its 576 pixels reached the screen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N2naGACCZy1GvqmZNCwpBu --- .../settings/NowPlayingSettingsScreen.kt | 4 ++- .../settings/NowPlayingSettingsRenderTest.kt | 36 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) 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 index 973e5381aa..8dc824086f 100644 --- 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 @@ -297,7 +297,9 @@ fun NowPlayingPreviewCard( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { - Box(Modifier.size(44.dp).clip(CircleShape)) { avatar() } + // 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), 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 index 69ee28bc70..9c5b1c853a 100644 --- 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 @@ -21,14 +21,19 @@ 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 @@ -125,7 +130,7 @@ class NowPlayingSettingsRenderTest { return try { val png = scene.render().encodeToData(EncodedImageFormat.PNG)!!.bytes System.getenv("NOW_PLAYING_RENDER_DIR")?.let { dir -> - val name = scenarios.entries.first { it.value === content }.key + 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)) @@ -142,6 +147,35 @@ class NowPlayingSettingsRenderTest { 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 -> From ff15246c7b4a378b7af894039bb146ea5dedc676 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 1 Oct 2026 14:37:31 +0000 Subject: [PATCH 6/8] fix: harden the title/favicon link preview fallback Audit fixes for the compact link preview: - Stop reading <title> and icon <link>s once <body> starts. </head> is optional, and without it an inline SVG's <title> ("Close", "Menu") was taken as the page title. - Never cut a long <title> inside a surrogate pair. - Match the HTML mime type case-insensitively: UrlPreview stores the server's spelling, so "Text/HTML" pages lost their icon. - Honour data: icons. An inline data:image icon (up to 64 KB) is used as is; `href="data:,"` means "no favicon", so /favicon.ico is no longer requested for those sites. - Remember icon URLs the server refused (HTTP errors only, not network errors) so a missing /favicon.ico is not re-requested every time its card scrolls back into view. - Keep the open-in-browser callback stable across recompositions. - Benchmark rows for the title/icon parse path: no measurable cost on the heavy head, ~1.5 us on a small one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KdPp7UiETpzv8jQ5qfAFqp --- .../commons/preview/MetaTagsParser.kt | 26 ++++++++++++-- .../amethyst/commons/preview/UrlInfoItem.kt | 16 ++++++++- .../preview/TitleAndIconFallbackTest.kt | 34 +++++++++++++++++++ .../preview/UrlInfoItemTextPreviewTest.kt | 22 ++++++++++++ .../prodbench/MetaTagsParserBenchmark.kt | 13 +++++++ .../commons/ui/components/UrlPreviewCard.kt | 24 +++++++++++-- 6 files changed, 129 insertions(+), 6 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt index 6fefdf4dc2..5f4bd4cd4b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt @@ -58,6 +58,7 @@ object MetaTagsParser { private const val META = "meta" private const val LINK = "link" private const val HEAD = "head" + private const val BODY = "body" private const val ICON = "icon" private const val REL = "rel" private const val CONTENT = "content" @@ -88,6 +89,12 @@ object MetaTagsParser { /** The `</head>` that ends the interesting part of the document. */ HEAD_END, + /** + * A `<body>` start tag. `</head>` is optional, so on a page that omits it this is the only + * sign the head is over. + */ + BODY_START, + /** A closed `<title>`: its text is [TagScanner.textStart]..<[TagScanner.textEnd]. */ TITLE, @@ -111,26 +118,34 @@ object MetaTagsParser { ): Sequence<MetaTag> = sequence { val s = TagScanner(input) + // Meta tags past an implicit end of head are still read, as they always were. The + // title and icons are not: past `<body>`, a `<title>` is an inline SVG's tooltip + // ("Close", "Menu"), not the page's name. + var inBody = false while (!s.exhausted()) { when (s.nextTag()) { TagKind.HEAD_END -> { break } + TagKind.BODY_START -> { + inBody = true + } + TagKind.META -> { val attrs = parseAttrs(input, s.attrsStart, s.attrsEnd) ?: continue yield(MetaTag(attrs)) } TagKind.TITLE -> { - if (includeTitleAndIcons) { + if (includeTitleAndIcons && !inBody) { val text = titleText(input, s.textStart, s.textEnd) if (text.isNotEmpty()) yield(MetaTag(mapOf(CONTENT to text), HeadElement.TITLE)) } } TagKind.LINK -> { - if (includeTitleAndIcons) { + if (includeTitleAndIcons && !inBody) { val attrs = parseAttrs(input, s.attrsStart, s.attrsEnd) ?: continue if (isIconRel(attrs[REL])) yield(MetaTag(attrs, HeadElement.LINK)) } @@ -160,7 +175,11 @@ object MetaTagsParser { ): String { val raw = input.substring(from, to) val decoded = if (raw.indexOf('&') < 0) raw else raw.replace(Attrs.RE_CHAR_REF, Attrs.Companion::replaceCharRefs) - return decoded.replace(WHITESPACE_RUN, " ").trim().take(MAX_TITLE_LENGTH) + val title = decoded.replace(WHITESPACE_RUN, " ").trim() + if (title.length <= MAX_TITLE_LENGTH) return title + // Never end on half of a surrogate pair: a lone high surrogate renders as tofu. + val cut = if (title[MAX_TITLE_LENGTH - 1].isHighSurrogate()) MAX_TITLE_LENGTH - 1 else MAX_TITLE_LENGTH + return title.substring(0, cut) } private class TagScanner( @@ -314,6 +333,7 @@ object MetaTagsParser { when (nameEnd - nameStart) { META.length -> { if (nameIs(nameStart, nameEnd, META)) return TagKind.META + if (nameIs(nameStart, nameEnd, BODY)) return TagKind.BODY_START // Most `<link>`s are stylesheets and preloads; checking the raw span for `icon` // keeps their attributes from ever being parsed into a map. if (nameIs(nameStart, nameEnd, LINK) && spanContains(attrsStart, attrsEnd, ICON)) return TagKind.LINK diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt index 00b24466cf..4977a6cd67 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt @@ -71,10 +71,20 @@ class UrlInfoItem( /** * The page's icon, absolute: the one it declared, else `/favicon.ico` at its origin -- the * same guess every browser makes, since most sites that declare nothing still serve one. + * + * A `data:` icon is used as-is when it is an image (the image loader decodes those inline), + * and taken at its word when it is not: `href="data:,"` is how a site tells browsers it has no + * favicon, so guessing `/favicon.ico` there is a request that is known to miss. + * * Null for a non-HTML URL (an image or a video is its own preview) or an unresolvable one. */ val iconUrlFullPath: String? = - if (mimeType.startsWith("text/html")) resolve(icon) ?: resolve(DEFAULT_FAVICON) else null + when { + !mimeType.startsWith("text/html", ignoreCase = true) -> null + icon.startsWith("data:image/", ignoreCase = true) -> if (icon.length <= MAX_INLINE_ICON_LENGTH) icon else resolve(DEFAULT_FAVICON) + icon.startsWith("data:", ignoreCase = true) -> null + else -> resolve(icon) ?: resolve(DEFAULT_FAVICON) + } /** * The declared `og:video`, absolute, but only when the declaration holds up: a [videoType] the @@ -149,5 +159,9 @@ class UrlInfoItem( companion object { private const val DEFAULT_FAVICON = "/favicon.ico" + + // Previews are cached, so an inline icon is held for as long as its card is. Real inline + // favicons are a few KB; anything far past that is not worth keeping in memory. + private const val MAX_INLINE_ICON_LENGTH = 64 * 1024 } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/TitleAndIconFallbackTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/TitleAndIconFallbackTest.kt index d0f9db83cc..4822fceaa4 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/TitleAndIconFallbackTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/TitleAndIconFallbackTest.kt @@ -176,4 +176,38 @@ class TitleAndIconFallbackTest { assertEquals("", info.icon) } + + @Test + fun aBodyTitleIsIgnoredWhenTheHeadIsNeverClosed() { + // `</head>` is optional: `<body>` closes the head implicitly. An inline SVG's <title> + // ("Close", "Menu") is the classic thing to then mistake for the page title. + val info = extract("""<head><meta name="description" content="D"><body><svg><title>Close""") + + assertEquals("", info.title) + assertEquals("D", info.description) + } + + @Test + fun aBodyIconLinkIsIgnoredWhenTheHeadIsNeverClosed() { + val info = extract("""T""") + + assertEquals("T", info.title) + assertEquals("", info.icon) + } + + @Test + fun aTitleWithAttributesIsRead() { + val info = extract("""Helmet""") + + assertEquals("Helmet", info.title) + } + + @Test + fun aLongTitleIsNotCutInsideASurrogatePair() { + // 299 ASCII chars then an emoji: a plain take(300) keeps only its high surrogate. + val info = extract("" + "a".repeat(299) + "\uD83D\uDE00 tail") + + assertTrue(info.title.isNotEmpty()) + assertTrue(!info.title.last().isHighSurrogate(), "title ends in a dangling high surrogate") + } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItemTextPreviewTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItemTextPreviewTest.kt index eaeeeb96a4..5998927fbe 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItemTextPreviewTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItemTextPreviewTest.kt @@ -84,4 +84,26 @@ class UrlInfoItemTextPreviewTest { fun nonHtmlUrlsHaveNoIcon() { assertNull(page(url = "https://example.com/a.png", image = "https://example.com/a.png", mimeType = "image/png").iconUrlFullPath) } + + @Test + fun anUppercaseHtmlMimeStillGetsAnIcon() { + // UrlPreview stores MediaType.toString(), which keeps the server's spelling. + assertEquals( + "https://example.nsite.lol/favicon.ico", + page(mimeType = "Text/HTML; charset=UTF-8").iconUrlFullPath, + ) + } + + @Test + fun anEmptyDataIconMeansTheSiteHasNoIcon() { + // `` is the common way to tell browsers not to request + // /favicon.ico. Requesting it anyway is a guaranteed miss. + assertNull(page(icon = "data:,").iconUrlFullPath) + } + + @Test + fun anInlineImageIconIsUsedAsIs() { + val inline = "data:image/png;base64,iVBORw0KGgo=" + assertEquals(inline, page(icon = inline).iconUrlFullPath) + } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/MetaTagsParserBenchmark.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/MetaTagsParserBenchmark.kt index 228498fdf1..2a2d0f70db 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/MetaTagsParserBenchmark.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/MetaTagsParserBenchmark.kt @@ -160,6 +160,19 @@ class MetaTagsParserBenchmark { } bench("no , whole doc", open, 2_000) { MetaTagsParser.parse(it).count() } + // What production runs (HtmlParser asks for the and icon <link>s too). Should sit + // on top of the rows above: the 60 preload links never reach the attribute parser. + bench("spa head, +title/icons", spa, 50_000) { MetaTagsParser.parse(it, includeTitleAndIcons = true).count() } + bench("spa head, og: + fallbacks", spa, 50_000) { + OpenGraphParser().extractUrlInfo(MetaTagsParser.parse(it, includeTitleAndIcons = true)).title.length + } + bench("heavy head, +title/icons", heavy, 2_000) { MetaTagsParser.parse(it, includeTitleAndIcons = true).count() } + bench("no </head>, +title/icons", open, 2_000) { MetaTagsParser.parse(it, includeTitleAndIcons = true).count() } + + val spaFallbacks = OpenGraphParser().extractUrlInfo(MetaTagsParser.parse(spa, includeTitleAndIcons = true)) + assertEquals("Example — Your Network. Your Rules.", spaFallbacks.title) + assertEquals("/favicon.svg", spaFallbacks.icon) + assertTrue(MetaTagsParser.parse(open).count() >= 64) } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewCard.kt index 59265eb13d..66853c6bac 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewCard.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.ui.components +import androidx.collection.LruCache import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -49,6 +50,7 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import coil3.network.HttpException import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem @@ -130,7 +132,15 @@ fun UrlPreviewCard( // Only meaningful when the card's own tap does something else (e.g. opening the comment // thread); otherwise it would duplicate the card's open-in-browser tap. - val onOpenInBrowser: (() -> Unit)? = onCardClick?.let { { runCatching { uri.openUri(url) } } } + val hasCardClick = onCardClick != null + val onOpenInBrowser: (() -> Unit)? = + remember(hasCardClick, url, uri) { + if (hasCardClick) { + { runCatching { uri.openUri(url) } } + } else { + null + } + } // A page with no picture to lead with -- most often one with no OpenGraph at all, previewed // from its `<title>`, meta description and favicon -- gets the short, wide card. Painting the @@ -251,6 +261,13 @@ private fun CompactUrlPreviewCard( } } +/** + * Icon URLs the server refused (404 and the like) in this process. The image loader caches + * successes but not failures, so without this a site with no `/favicon.ico` would be asked for it + * again -- and 404 again -- every time its card scrolled back into view. + */ +private val failedIconUrls = LruCache<String, Unit>(200) + /** * The site's icon in a fixed square. A generic globe sits there until the icon actually loads, * so a site whose favicon is missing (the `/favicon.ico` guess is only a guess) or undecodable @@ -278,13 +295,16 @@ private fun UrlPreviewIcon( ) } - if (iconUrl != null) { + if (iconUrl != null && failedIconUrls[iconUrl] == null) { AsyncImage( model = iconUrl, contentDescription = contentDescription, contentScale = ContentScale.Fit, modifier = UrlPreviewIconImageModifier, onSuccess = { loaded = true }, + // Only a server's answer is remembered. A network error (offline, a timeout) says + // nothing about the icon and must be retried once the connection is back. + onError = { if (it.result.throwable is HttpException) failedIconUrls.put(iconUrl, Unit) }, ) } } From 5b7cffc72d0d4bc40696256612e5a2afd87f3438 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona <vitor@vitorpamplona.com> Date: Thu, 1 Oct 2026 10:38:28 -0400 Subject: [PATCH 7/8] fix(desktop): read now-playing command output while the command runs runCommand waited for the process to exit before reading its stdout. On a busy Mac `ps -A -o comm=` prints ~100 KB, past the pipe buffer, so ps blocked on the write, waitFor hit the 5 s timeout and the macOS reader never saw Spotify or Music (and every poll stalled 5 s). Read stdout to EOF while a watchdog thread kills a command that outlives the timeout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../desktop/nowPlaying/OsNowPlayingReader.kt | 24 +++++++++++++------ .../nowPlaying/OsNowPlayingReaderTest.kt | 22 +++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) 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 index 7a8cb58c6d..f8aad95b00 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReader.kt @@ -25,6 +25,7 @@ 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 { @@ -63,13 +64,22 @@ internal suspend fun runCommand( .start() process = started started.outputStream.close() - // Wait before reading so a hung command cannot block the read forever. The outputs - // here are a few KB, well under the pipe buffer, so the command never blocks on it. - if (!started.waitFor(timeoutSeconds, TimeUnit.SECONDS) || started.exitValue() != 0) { - null - } else { - started.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } - } + // 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) { 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 index 02e0bc97c2..36b29a93cc 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt @@ -22,9 +22,11 @@ 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 = @@ -215,4 +217,24 @@ class OsNowPlayingReaderTest { 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") + } } From 3d9c5bf0c95b049c9b66d2031868351ba8e25fa2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona <vitor@vitorpamplona.com> Date: Thu, 1 Oct 2026 11:08:42 -0400 Subject: [PATCH 8/8] fix: resolve relative URLs against a pathless base on Android Android's java.net.URI resolves a relative reference against a base with an empty path by gluing it onto the host: `y18.svg` against `https://news.ycombinator.com` became `https://news.ycombinator.comy18.svg`, so Hacker News' compact link preview never loaded its icon (seen on an SM-T220). The JDK follows RFC 3986 and treats the empty path as `/`, so normalize the base to that before resolving. Rebuilt from the raw parts so an encoded query is not encoded twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../util/HttpUrlResolution.jvmAndroid.kt | 22 +++++++- .../commons/util/HttpUrlResolutionTest.kt | 56 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolutionTest.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolution.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolution.jvmAndroid.kt index bda5b0c2a9..b27eb59efe 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolution.jvmAndroid.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolution.jvmAndroid.kt @@ -29,7 +29,7 @@ actual fun resolveHttpUrl( reference: String, ): String? = runCatching { - val baseUri = base?.let { runCatching { URI(it).toURL().toURI() }.getOrNull() } + val baseUri = base?.let { runCatching { URI(it).toURL().toURI().withRootPath() }.getOrNull() } val resolved = if (baseUri != null) baseUri.resolve(reference) else URI(reference) if (!resolved.scheme.equals("http", ignoreCase = true) && !resolved.scheme.equals("https", ignoreCase = true) @@ -39,3 +39,23 @@ actual fun resolveHttpUrl( resolved.toURL().toString() } }.getOrNull() + +/** + * `https://host` as `https://host/`. RFC 3986 resolves a relative reference against an empty base path + * as if the path were `/`, and the JDK does, but Android's `java.net.URI` does not: it glued + * `y18.svg` onto `https://news.ycombinator.com` as `https://news.ycombinator.comy18.svg`, so the + * page's icon (or a relative og:image) pointed at a host that does not exist. + */ +private fun URI.withRootPath(): URI = + if (isOpaque || !rawPath.isNullOrEmpty() || rawAuthority == null) { + this + } else { + // From the raw (still-encoded) parts: the multi-argument constructor would encode them again. + URI( + buildString { + append(scheme).append("://").append(rawAuthority).append('/') + rawQuery?.let { append('?').append(it) } + rawFragment?.let { append('#').append(it) } + }, + ) + } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolutionTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolutionTest.kt new file mode 100644 index 0000000000..353aacd5d1 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolutionTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * A relative reference against a base with no path resolves as if the path were `/` (RFC 3986). + * Android's `java.net.URI` got this wrong — `y18.svg` against `https://news.ycombinator.com` became + * `https://news.ycombinator.comy18.svg` — so the base is normalized before resolving. + */ +class HttpUrlResolutionTest { + @Test + fun relativeReferenceAgainstAPathlessBase() { + assertEquals("https://news.ycombinator.com/y18.svg", resolveHttpUrl("https://news.ycombinator.com", "y18.svg")) + assertEquals("https://news.ycombinator.com/favicon.ico", resolveHttpUrl("https://news.ycombinator.com", "/favicon.ico")) + } + + @Test + fun pathlessBaseKeepsItsQueryAndEncoding() { + assertEquals("https://a.example/icon.png", resolveHttpUrl("https://a.example?q=a%20b", "icon.png")) + // Encoded characters are carried over as they are, not encoded a second time. + assertEquals("https://a.example/icons/a%20b.png", resolveHttpUrl("https://a.example?q=a%20b", "icons/a%20b.png")) + } + + @Test + fun basesWithAPathAreUnchanged() { + assertEquals("https://a.example/dir/icon.png", resolveHttpUrl("https://a.example/dir/page.html", "icon.png")) + assertEquals("https://cdn.example/x.png", resolveHttpUrl("https://a.example/dir/", "//cdn.example/x.png")) + } + + @Test + fun nonHttpStaysRefused() { + assertNull(resolveHttpUrl("https://a.example", "file:///etc/passwd")) + } +}