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