Merge remote-tracking branch 'origin/main' into claude/relay-message-pagination-LMqSQ

This commit is contained in:
Claude
2026-06-05 12:33:34 +00:00
12 changed files with 928 additions and 79 deletions
@@ -1336,54 +1336,28 @@ object LocalCache : ILocalCache, ICacheProvider {
else -> null
}
@Suppress("DEPRECATION")
/**
* NIP-09 delete of a single targeted event.
*
* Removal has two halves: unlinking the note from everything that points AT it
* (its parents, channels, and the per-user report/card/status/poll indexes —
* all handled by [unlinkAndRemove]); and dealing with the note's OWN children
* (the notes that point at IT). The delete path and the prune path share the
* first half and differ only on the second:
* - delete (here): the children are independent events and stay in the cache;
* [Note.detachFromChildren] only severs their back-reference so the removed
* shell can neither leak (held alive by a child's `replyTo`) nor be later
* resurrected by `computeReplyTo` as a second Note for the same id.
* - prune (see [unlinkAndRemove] callers): the whole child subtree is removed.
*
* Gift-wrapped events additionally drop their decrypted inner host.
*/
private fun deleteNote(deleteNote: Note) {
val deletedEvent = deleteNote.event
(deleteNote.event as? WrappedEvent)?.let { deleteWraps(it) }
if (deletedEvent is ReportEvent) {
deletedEvent.reportedAuthor().forEach {
getUserIfExists(it.pubkey)?.reportsOrNull()?.removeReport(deleteNote)
}
}
deleteNote.detachFromChildren()
if (deleteNote is AddressableNote && deletedEvent is ContactCardEvent) {
getUserIfExists(deletedEvent.aboutUser())?.cardsOrNull()?.removeCard(deleteNote)
}
if (deleteNote is AddressableNote && deletedEvent is StatusEvent) {
deleteNote.author?.statusStateOrNull()?.removeStatus(deleteNote)
}
if (deletedEvent is PollResponseEvent) {
deletedEvent.poll()?.eventId?.let {
getNoteIfExists(it)?.pollStateOrNull()?.removeResponse(deleteNote)
}
}
if (deletedEvent is TorrentCommentEvent) {
deletedEvent.torrentIds()?.let {
getNoteIfExists(it)?.removeReply(deleteNote)
}
}
if (deletedEvent is WrappedEvent) {
deleteWraps(deletedEvent)
}
// Counts the replies
deleteNote.replyTo?.forEach { masterNote ->
masterNote.removeNote(deleteNote)
}
deleteNote.inGatherers?.forEach { it.removeNote(deleteNote) }
getAnyChannel(deleteNote)?.removeNote(deleteNote)
notes.remove(deleteNote.idHex)
deleteNote.clearFlow()
refreshDeletedNoteObservers(deleteNote)
unlinkAndRemove(deleteNote)
}
fun deleteWraps(event: WrappedEvent) {
@@ -2592,12 +2566,12 @@ object LocalCache : ILocalCache, ICacheProvider {
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
removeFromCache(it)
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
childrenToBeRemoved.addAll(it.clearChildLinks())
}
removeFromCache(childrenToBeRemoved)
unlinkAndRemove(childrenToBeRemoved)
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
println(
@@ -2631,12 +2605,12 @@ object LocalCache : ILocalCache, ICacheProvider {
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
removeFromCache(it)
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
childrenToBeRemoved.addAll(it.clearChildLinks())
}
removeFromCache(childrenToBeRemoved)
unlinkAndRemove(childrenToBeRemoved)
// Audio-room presence is keyed separately from `notes` and
// never gets reaped by the top-N rule. Drop entries older
@@ -2676,12 +2650,12 @@ object LocalCache : ILocalCache, ICacheProvider {
toBeRemoved.forEach {
childrenToBeRemoved.addAll(removeIfWrap(it))
removeFromCache(it)
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
childrenToBeRemoved.addAll(it.clearChildLinks())
}
removeFromCache(childrenToBeRemoved)
unlinkAndRemove(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println(
@@ -2699,8 +2673,8 @@ object LocalCache : ILocalCache, ICacheProvider {
if (noteEvent is WrappedEvent) {
noteEvent.host?.id?.let {
getNoteIfExists(it)?.let { it2 ->
removeFromCache(it2)
it2.removeAllChildNotes()
unlinkAndRemove(it2)
it2.clearChildLinks()
}
}
} else {
@@ -2730,11 +2704,11 @@ object LocalCache : ILocalCache, ICacheProvider {
it.moveAllReferencesTo(newerVersion)
}
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
removeFromCache(childrenToBeRemoved)
unlinkAndRemove(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} old version of addressables removed.")
@@ -2767,24 +2741,48 @@ object LocalCache : ILocalCache, ICacheProvider {
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
removeFromCache(childrenToBeRemoved)
unlinkAndRemove(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} thread replies removed.")
}
}
private fun removeFromCache(note: Note) {
/**
* Unlinks [note] from everything in the cache that references it, then drops it
* from the [notes] map and notifies observers. This is the shared "unlink from
* above" half of removal, used by both the prune callers and [deleteNote].
*
* It detaches the note from:
* - its parent notes (their replies/reactions/zaps/boosts/reports/labels maps);
* because event-level reports and torrent comments both carry the target in
* `replyTo`, [Note.removeNote] cleans those up here too;
* - its channels/gatherers (`inGatherers` is authoritative — `Channel.addNote`
* always registers the gatherer — and `getAnyChannel` is a belt-and-suspenders
* resolve so a note can never linger in a channel after leaving the cache);
* - the per-target indexes `replyTo` does NOT reach: user-level reports and
* reported addresses, contact cards, statuses, and poll responses.
*
* It deliberately does NOT touch the note's own children: prune callers collect
* them via [Note.clearChildLinks] and remove the subtree, while [deleteNote]
* keeps them and severs only their back-reference. Every per-target removal is
* idempotent, so the overlap between `replyTo` and the explicit indexes (e.g. an
* event-level report reachable both ways) is harmless. Addressable notes are
* dropped from the [addressables] map by the caller; this only removes from [notes].
*/
private fun unlinkAndRemove(note: Note) {
note.replyTo?.forEach { masterNote ->
masterNote.removeNote(note)
}
note.inGatherers?.forEach { it.removeNote(note) }
getAnyChannel(note)?.removeNote(note)
val noteEvent = note.event
if (noteEvent is ReportEvent) {
@@ -2822,8 +2820,8 @@ object LocalCache : ILocalCache, ICacheProvider {
refreshDeletedNoteObservers(note)
}
fun removeFromCache(nextToBeRemoved: List<Note>) {
nextToBeRemoved.forEach { note -> removeFromCache(note) }
fun unlinkAndRemove(nextToBeRemoved: List<Note>) {
nextToBeRemoved.forEach { note -> unlinkAndRemove(note) }
}
fun pruneExpiredEvents() {
@@ -2836,16 +2834,16 @@ object LocalCache : ILocalCache, ICacheProvider {
val childrenToBeRemoved = mutableListOf<Note>()
versionsToBeRemoved.forEach {
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
addressesToBeRemoved.forEach {
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
removeFromCache(childrenToBeRemoved)
unlinkAndRemove(childrenToBeRemoved)
if (versionsToBeRemoved.size > 1 || addressesToBeRemoved.size > 1) {
println("PRUNE: ${versionsToBeRemoved.size} events and ${addressesToBeRemoved.size} expired.")
@@ -2863,11 +2861,11 @@ object LocalCache : ILocalCache, ICacheProvider {
}
toBeRemoved.forEach {
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
removeFromCache(childrenToBeRemoved)
unlinkAndRemove(childrenToBeRemoved)
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
}
@@ -20,11 +20,14 @@
*/
package com.vitorpamplona.amethyst.service.playback.composable
import android.content.Context
import android.media.AudioManager
import androidx.annotation.OptIn
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -34,18 +37,23 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.ContentFrame
import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW
import com.vitorpamplona.amethyst.service.playback.composable.controls.BottomGradientOverlay
import com.vitorpamplona.amethyst.service.playback.composable.controls.FullscreenSwipeControlsState
import com.vitorpamplona.amethyst.service.playback.composable.controls.FullscreenSwipeLevelIndicator
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderAnimatedBottomInfo
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCenterButtons
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderTopButtons
import com.vitorpamplona.amethyst.service.playback.composable.controls.TopGradientOverlay
import com.vitorpamplona.amethyst.service.playback.composable.controls.fullscreenSwipeControls
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.AudioPlayingAnimation
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.ui.components.getDialogWindow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
internal const val SKIP_SECONDS = 10
@@ -87,6 +95,7 @@ fun RenderVideoPlayer(
onDialog: (() -> Unit)? = null,
controllerVisible: MutableState<Boolean> = remember { mutableStateOf(false) },
hasBlurhash: Boolean = false,
isFullscreen: Boolean = false,
accountViewModel: AccountViewModel,
) {
// Hold the container size in a non-state holder so layout passes don't trigger an
@@ -95,6 +104,29 @@ fun RenderVideoPlayer(
val containerWidth = remember { intArrayOf(0) }
val isLive = remember(mediaItem.src.videoUri) { isLiveStreaming(mediaItem.src.videoUri) }
val swipeState = remember { FullscreenSwipeControlsState() }
val context = LocalContext.current
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager }
// Brightness is applied to the fullscreen dialog window so it auto-reverts on dismiss.
// Returns null for the inline feed player (not inside a dialog) — fine, gated by isFullscreen below.
val dialogWindow = getDialogWindow()
// Sync the per-video mute (Media3 player volume) with the volume swipe. Mirrors the mute
// button's handler exactly, so its listener-driven icon flips when the swipe mutes/unmutes,
// and the global default carries to the next video.
val isVideoMuted = { controllerState.controller.volume < 0.001f }
val setVideoMuted = { mute: Boolean ->
DEFAULT_MUTED_SETTING.value = mute
controllerState.controller.volume = if (mute) 0f else 1f
}
// Belt-and-suspenders: clear any brightness override when this player leaves composition, so
// exiting fullscreen never leaves the screen dimmed. releaseBrightness no-ops when dialogWindow
// is null (the inline feed path) or when no override was applied, so this is safe unconditionally.
DisposableEffect(Unit) {
onDispose { swipeState.releaseBrightness(dialogWindow) }
}
WatchPlaybackErrors(controllerState)
Box(
@@ -115,7 +147,20 @@ fun RenderVideoPlayer(
}
},
)
},
}.then(
if (isFullscreen) {
Modifier.fullscreenSwipeControls(
state = swipeState,
audioManager = audioManager,
window = dialogWindow,
resolver = context.contentResolver,
isMuted = isVideoMuted,
setMuted = setVideoMuted,
)
} else {
Modifier
},
),
) {
ContentFrame(
player = controllerState.controller,
@@ -172,5 +217,9 @@ fun RenderVideoPlayer(
isLiveStream = isLive,
)
}
if (isFullscreen) {
FullscreenSwipeLevelIndicator(swipeState, isMuted = isVideoMuted)
}
}
}
@@ -107,6 +107,7 @@ fun VideoViewInner(
controllerVisible = controllerVisible,
onDialog = onZoom,
hasBlurhash = hasBlurhash,
isFullscreen = isFullscreen,
accountViewModel = accountViewModel,
)
}
@@ -0,0 +1,332 @@
/*
* 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.playback.composable.controls
import android.content.ContentResolver
import android.media.AudioManager
import android.provider.Settings
import android.view.Window
import android.view.WindowManager
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
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.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlin.math.roundToInt
enum class SwipeAxis { Brightness, Volume }
private const val BRIGHTNESS_FLOOR = 0.01f
private const val AUTO_HIDE_MILLIS = 800L
/**
* Holds the live state for the fullscreen brightness/volume swipe. A single instance is remembered
* by RenderVideoPlayer and shared between the drag [Modifier] and the [FullscreenSwipeLevelIndicator]
* overlay. All device side-effects (AudioManager / window brightness) are applied here.
*/
class FullscreenSwipeControlsState {
var axis by mutableStateOf<SwipeAxis?>(null)
private set
var level by mutableFloatStateOf(0f)
private set
var visible by mutableStateOf(false)
private set
// Bumped on every drag event and on drag end; the overlay keys its auto-hide timer on this so
// the timer restarts while dragging and fires AUTO_HIDE_MILLIS after the last event.
var interactionId by mutableIntStateOf(0)
private set
private var dragStartLevel = 0f
private var accumulatedDragPx = 0f
// Max volume is device-invariant; cache it at drag start so the per-frame drag path doesn't
// re-query AudioManager on every pointer event.
private var maxVolume = 0
// Last discrete value actually pushed to the device this drag. Continuous finger movement maps
// to the same volume index / brightness step across several frames; skipping unchanged writes
// avoids redundant AudioManager calls and window-attribute relayouts with no visible effect.
private var lastVolumeIndex = -1
private var lastBrightnessStep = -1
fun startDrag(
axis: SwipeAxis,
audioManager: AudioManager?,
window: Window?,
resolver: ContentResolver,
) {
this.axis = axis
accumulatedDragPx = 0f
when (axis) {
SwipeAxis.Volume -> {
maxVolume = audioManager?.getStreamMaxVolume(AudioManager.STREAM_MUSIC) ?: 0
lastVolumeIndex = -1
dragStartLevel = currentVolumeFraction(audioManager, maxVolume)
}
SwipeAxis.Brightness -> {
lastBrightnessStep = -1
dragStartLevel = currentBrightnessFraction(window, resolver)
}
}
level = dragStartLevel
visible = true
interactionId++
}
fun onDrag(
dragAmountPx: Float,
heightPx: Float,
audioManager: AudioManager?,
window: Window?,
isMuted: () -> Boolean,
setMuted: (Boolean) -> Unit,
) {
accumulatedDragPx += dragAmountPx
level = computeLevel(dragStartLevel, accumulatedDragPx, heightPx)
when (axis) {
SwipeAxis.Volume -> {
audioManager?.let { applyVolumeIfChanged(it) }
applyMuteSync(isMuted, setMuted)
}
SwipeAxis.Brightness -> window?.let { applyBrightnessIfChanged(it) }
null -> Unit
}
interactionId++
}
// Directional mute sync: dragging up unmutes a muted video; reaching zero mutes it. Kept outside
// the AudioManager branch so per-video mute still tracks the gesture even if device volume is
// unavailable.
private fun applyMuteSync(
isMuted: () -> Boolean,
setMuted: (Boolean) -> Unit,
) {
when (muteActionFor(level, movedUp = accumulatedDragPx < 0f, isMuted = isMuted())) {
MuteAction.Mute -> setMuted(true)
MuteAction.Unmute -> setMuted(false)
MuteAction.None -> Unit
}
}
private fun applyVolumeIfChanged(audioManager: AudioManager) {
if (maxVolume <= 0) return
val index = levelToVolumeIndex(level, maxVolume)
if (index == lastVolumeIndex) return
lastVolumeIndex = index
// Flag 0 = no system volume UI; we draw our own ring.
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, index, 0)
}
private fun applyBrightnessIfChanged(window: Window) {
val target = level.coerceIn(BRIGHTNESS_FLOOR, 1f)
// Quantize to the panel's 0..255 range; sub-step movement is imperceptible and would
// otherwise reassign window.attributes (a relayout) every frame for no visible change.
val step = (target * 255f).roundToInt()
if (step == lastBrightnessStep) return
lastBrightnessStep = step
applyBrightness(window, target)
}
fun endDrag() {
interactionId++
}
fun hide() {
visible = false
}
/**
* Clears any brightness override this controller applied, restoring the window to the system
* brightness. Call from the fullscreen player's onDispose so leaving fullscreen never leaves
* the screen dimmed.
*/
fun releaseBrightness(window: Window?) {
window?.let { releaseBrightnessOverride(it) }
}
}
private fun currentVolumeFraction(
audioManager: AudioManager?,
max: Int,
): Float {
audioManager ?: return 0f
if (max <= 0) return 0f
return audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / max
}
private fun currentBrightnessFraction(
window: Window?,
resolver: ContentResolver,
): Float {
val override = window?.attributes?.screenBrightness ?: -1f
if (override in 0f..1f) return override
val system =
try {
Settings.System.getInt(resolver, Settings.System.SCREEN_BRIGHTNESS)
} catch (e: Settings.SettingNotFoundException) {
128
}
return (system / 255f).coerceIn(0f, 1f)
}
private fun applyBrightness(
window: Window,
brightness: Float,
) {
val params = window.attributes
params.screenBrightness = brightness
window.attributes = params
}
private fun releaseBrightnessOverride(window: Window) {
val params = window.attributes
params.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
window.attributes = params
}
/**
* Vertical-drag handler for the fullscreen video surface. Left half of the surface controls
* brightness, right half controls volume. Must be a separate [pointerInput] from the existing
* tap/double-tap handler so taps still work.
*/
fun Modifier.fullscreenSwipeControls(
state: FullscreenSwipeControlsState,
audioManager: AudioManager?,
window: Window?,
resolver: ContentResolver,
isMuted: () -> Boolean,
setMuted: (Boolean) -> Unit,
): Modifier =
// isMuted/setMuted are intentionally not pointerInput keys: they change identity every
// recomposition but read live state, so adding them would restart the gesture for no reason.
pointerInput(state, audioManager, window, resolver) {
detectVerticalDragGestures(
onDragStart = { offset ->
val axis = if (offset.x < size.width / 2f) SwipeAxis.Brightness else SwipeAxis.Volume
state.startDrag(axis, audioManager, window, resolver)
},
onVerticalDrag = { _, dragAmount ->
state.onDrag(dragAmount, size.height.toFloat(), audioManager, window, isMuted, setMuted)
},
onDragEnd = { state.endDrag() },
onDragCancel = { state.endDrag() },
)
}
/** Centered ring + glyph that appears while swiping and fades out shortly after the drag ends. */
@Composable
fun BoxScope.FullscreenSwipeLevelIndicator(
state: FullscreenSwipeControlsState,
isMuted: () -> Boolean,
) {
// Launch once on the stable state and watch interactionId via a snapshotFlow instead of keying
// the effect on it: collectLatest restarts the auto-hide delay on each drag event, and reading
// interactionId here (not as a composition key) avoids re-keying the effect every frame. The
// indicator still recomposes per frame to redraw the arc as level changes — fine for a transient
// drag overlay.
LaunchedEffect(state) {
snapshotFlow { state.interactionId }
.collectLatest {
if (state.visible) {
delay(AUTO_HIDE_MILLIS)
state.hide()
}
}
}
val alpha by animateFloatAsState(if (state.visible) 1f else 0f, label = "swipeIndicatorAlpha")
if (alpha <= 0f) return
val axis = state.axis ?: return
val level = state.level
val ringColor = MaterialTheme.colorScheme.onBackground
val trackColor = ringColor.copy(alpha = 0.25f)
val backdrop = MaterialTheme.colorScheme.background.copy(alpha = 0.5f)
Box(
modifier =
Modifier
.align(Alignment.Center)
.size(110.dp)
.alpha(alpha)
.clip(CircleShape)
.background(backdrop),
contentAlignment = Alignment.Center,
) {
Canvas(modifier = Modifier.fillMaxSize().padding(16.dp)) {
val stroke = Stroke(width = 6.dp.toPx(), cap = StrokeCap.Round)
drawArc(
color = trackColor,
startAngle = -90f,
sweepAngle = 360f,
useCenter = false,
style = stroke,
)
drawArc(
color = ringColor,
startAngle = -90f,
sweepAngle = 360f * level.coerceIn(0f, 1f),
useCenter = false,
style = stroke,
)
}
val symbol =
when (axis) {
SwipeAxis.Brightness -> MaterialSymbols.BrightnessMedium
SwipeAxis.Volume ->
if (isMuted() || level <= 0f) MaterialSymbols.AutoMirrored.VolumeOff else MaterialSymbols.AutoMirrored.VolumeUp
}
Icon(
symbol = symbol,
contentDescription = null,
tint = ringColor,
modifier = Modifier.size(36.dp),
)
}
}
@@ -0,0 +1,71 @@
/*
* 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.playback.composable.controls
import kotlin.math.roundToInt
/**
* Maps a vertical drag to a 0..1 level. Dragging up (negative accumulated pixels) increases the
* level; dragging down decreases it. A drag spanning the full element height covers the entire
* 0..1 range. The result is clamped to 0..1.
*/
fun computeLevel(
startLevel: Float,
accumulatedDragPx: Float,
heightPx: Float,
): Float {
if (heightPx <= 0f) return startLevel.coerceIn(0f, 1f)
return (startLevel - accumulatedDragPx / heightPx).coerceIn(0f, 1f)
}
/** Clamps [level] to 0..1, then rounds it to a discrete stream-volume index in 0..max. Returns 0 when max <= 0. */
fun levelToVolumeIndex(
level: Float,
max: Int,
): Int {
if (max <= 0) return 0
return (level.coerceIn(0f, 1f) * max).roundToInt()
}
/** The mute change a volume swipe should trigger on the per-video player. */
enum class MuteAction { Mute, Unmute, None }
/**
* Directional mute sync for the volume swipe.
*
* - Reaching zero mutes the video (no-op if already muted).
* - Dragging the finger up ([movedUp], i.e. net upward from where the drag started) unmutes a muted
* video — so a muted video pinned at max device volume still unmutes even though [level] can't rise.
* - A downward swipe that stays above zero leaves the mute state untouched.
*
* [movedUp] is the drag direction, not a level comparison, so the clamp at level 1.0 doesn't swallow
* the unmute intent.
*/
fun muteActionFor(
level: Float,
movedUp: Boolean,
isMuted: Boolean,
): MuteAction =
when {
level <= 0f -> if (isMuted) MuteAction.None else MuteAction.Mute
isMuted && movedUp -> MuteAction.Unmute
else -> MuteAction.None
}
@@ -191,12 +191,18 @@ fun ZoomableImageDialog(
val dialogWindow = getDialogWindow()
if (activityWindow != null && dialogWindow != null) {
// Preserve any brightness override already applied to the dialog window (e.g. by the
// fullscreen swipe controls). This block re-runs on recomposition (orientation change
// re-reads `orientation` above), and copying the activity attributes would otherwise
// reset screenBrightness and snap the user's brightness back mid-session.
val currentBrightness = dialogWindow.attributes.screenBrightness
val attributes = WindowManager.LayoutParams()
attributes.copyFrom(activityWindow.attributes)
attributes.type = dialogWindow.attributes.type
// Disable the system dim so the thumbnail stays visible behind the growing dialog.
attributes.dimAmount = 0f
attributes.flags = attributes.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND.inv()
attributes.screenBrightness = currentBrightness
dialogWindow.attributes = attributes
}
@@ -0,0 +1,109 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable.controls
import org.junit.Assert.assertEquals
import org.junit.Test
class FullscreenSwipeMathTest {
@Test
fun dragUpIncreasesLevel() {
// Drag up 500px (negative) on a 1000px screen from 0.2 -> +0.5 = 0.7
assertEquals(0.7f, computeLevel(0.2f, -500f, 1000f), 0.0001f)
}
@Test
fun dragDownDecreasesLevel() {
assertEquals(0.3f, computeLevel(0.8f, 500f, 1000f), 0.0001f)
}
@Test
fun clampsToOne() {
assertEquals(1f, computeLevel(0.9f, -500f, 1000f), 0.0001f)
}
@Test
fun clampsToZero() {
assertEquals(0f, computeLevel(0.1f, 500f, 1000f), 0.0001f)
}
@Test
fun zeroHeightReturnsStartClamped() {
assertEquals(0.5f, computeLevel(0.5f, -100f, 0f), 0.0001f)
}
@Test
fun zeroHeightClampsOutOfRangeStartLevel() {
assertEquals(1f, computeLevel(1.5f, -100f, 0f), 0.0001f)
assertEquals(0f, computeLevel(-0.3f, 100f, 0f), 0.0001f)
}
@Test
fun volumeIndexExactMidpoint() {
assertEquals(5, levelToVolumeIndex(0.5f, 10))
}
@Test
fun volumeIndexFull() {
assertEquals(15, levelToVolumeIndex(1f, 15))
}
@Test
fun volumeIndexZeroLevel() {
assertEquals(0, levelToVolumeIndex(0f, 15))
}
@Test
fun volumeIndexZeroMaxGuard() {
assertEquals(0, levelToVolumeIndex(0.5f, 0))
}
@Test
fun reachingZeroWhileUnmutedMutes() {
assertEquals(MuteAction.Mute, muteActionFor(level = 0f, movedUp = false, isMuted = false))
}
@Test
fun reachingZeroWhileMutedIsNoop() {
assertEquals(MuteAction.None, muteActionFor(level = 0f, movedUp = false, isMuted = true))
}
@Test
fun movingUpWhileMutedUnmutes() {
assertEquals(MuteAction.Unmute, muteActionFor(level = 0.5f, movedUp = true, isMuted = true))
}
@Test
fun movingUpAtMaxWhileMutedStillUnmutes() {
// Device volume pinned at 1.0 can't rise, but the upward drag still expresses intent.
assertEquals(MuteAction.Unmute, muteActionFor(level = 1f, movedUp = true, isMuted = true))
}
@Test
fun movingUpWhileUnmutedIsNoop() {
assertEquals(MuteAction.None, muteActionFor(level = 0.5f, movedUp = true, isMuted = false))
}
@Test
fun movingDownAboveZeroWhileMutedIsNoop() {
assertEquals(MuteAction.None, muteActionFor(level = 0.5f, movedUp = false, isMuted = true))
}
}
@@ -46,6 +46,7 @@ object MaterialSymbols {
val BookmarkAdd = MaterialSymbol("\uE598")
val BookmarkBorder = MaterialSymbol("\uE8E7")
val BookmarkRemove = MaterialSymbol("\uE59A")
val BrightnessMedium = MaterialSymbol("\uE1AE")
val Forward10 = MaterialSymbol("\uE056")
val Replay10 = MaterialSymbol("\uE059")
val CalendarMonth = MaterialSymbol("\uEBCC")
@@ -146,6 +146,8 @@ open class Note(
removeZapPayment(note)
removeReport(note)
removeLabel(note)
removeNutzap(note)
removeOnchainZapBySource(note)
}
var poll: PollResponsesCache? = null
@@ -371,7 +373,7 @@ open class Note(
}
}
fun removeAllChildNotes(): List<Note> {
fun clearChildLinks(): List<Note> {
val repliesChanged = replies.isNotEmpty()
val reactionsChanged = reactions.isNotEmpty()
val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty() || onchainZaps.isNotEmpty() || nutzaps.isNotEmpty()
@@ -389,7 +391,8 @@ open class Note(
zaps.values.filterNotNull() +
zapPayments.keys +
zapPayments.values.filterNotNull() +
nutzaps.values.map { it.source }
nutzaps.values.map { it.source } +
onchainZaps.values.map { it.source }
replies = listOf()
reactions = mapOf()
@@ -414,6 +417,31 @@ open class Note(
return toBeRemoved
}
/**
* Fully detach this note from the notes below it in the graph so it can be
* removed from the cache without leaving a partial deletion behind. It both
* clears this note's own child collections (via [clearChildLinks]) and
* drops this note from every child's [replyTo], so once this note leaves the
* cache map nothing keeps the dead shell alive.
*
* This matters for the NIP-09 delete path: without severing the child →
* parent `replyTo` links, the removed note leaks (held by each child) and a
* later reply resolved through `computeReplyTo` would `getOrCreateNote` a
* *second* Note for the same id — breaking the one-Note-per-id invariant.
*
* Returns the now-orphaned children (their other parents, if any, are kept).
*/
fun detachFromChildren(): List<Note> {
val children = clearChildLinks()
children.forEach { child ->
val parents = child.replyTo
if (parents != null && this in parents) {
child.replyTo = parents - this
}
}
return children
}
fun removeReaction(note: Note) {
val tags = note.event?.tags ?: emptyArray()
val reaction = note.event?.content?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
@@ -587,6 +615,29 @@ open class Note(
}
}
private fun innerRemoveOnchainZapBySource(source: Note): Boolean =
syncLock.withLock {
val newMap = onchainZaps.filterValues { it.source != source }
if (newMap.size == onchainZaps.size) return@withLock false
onchainZaps = newMap
return@withLock true
}
/**
* Detach every onchain-zap entry whose source is [source] — used when the
* source OnchainZapEvent note is being pruned from `LocalCache`. Unlike
* [removeOnchainZapForSource] (a verification verdict that respects the
* anti-spoof / no-CONFIRMED-downgrade guards), this is an unconditional
* cache-removal that must drop the strong reference no matter the status,
* otherwise the pruned source Note leaks through this map.
*/
fun removeOnchainZapBySource(source: Note) {
if (innerRemoveOnchainZapBySource(source)) {
updateZapTotal()
flowSet?.zaps?.invalidateData()
}
}
private fun innerAddNutzap(
eventId: HexKey,
entry: NutzapEntry,
@@ -1165,6 +1216,21 @@ open class Note(
note.addNutzap(it.source, it.claimedSats)
it.source.replyTo = it.source.replyTo?.replace(this, note)
}
onchainZaps.forEach { (txid, entry) ->
note.addOnchainZap(entry.source, txid, entry.claimedSats, entry.verifiedSats, entry.status)
entry.source.replyTo = entry.source.replyTo?.replace(this, note)
}
zapPayments.forEach {
note.addZapPayment(it.key, it.value)
it.key.replyTo = it.key.replyTo?.replace(this, note)
it.value?.replyTo = it.value?.replyTo?.replace(this, note)
}
labels.forEach { (hashtag, labelNotes) ->
labelNotes.forEach {
note.addLabel(hashtag, it)
it.replyTo = it.replyTo?.replace(this, note)
}
}
replyTo = null
replies = emptyList()
@@ -1173,6 +1239,9 @@ open class Note(
reports = emptyMap()
zaps = emptyMap()
nutzaps = emptyMap()
onchainZaps = emptyMap()
zapPayments = emptyMap()
labels = emptyMap()
zapsAmount = BigDecimal(0)
}
@@ -349,15 +349,15 @@ class NoteOnchainZapTest {
}
@Test
fun removeAllChildNotesClearsOnchainZapResolvedFlag() {
// `removeAllChildNotes()` runs on delete-event handling and during cache
fun clearChildLinksResetsOnchainZapResolvedFlag() {
// `clearChildLinks()` runs on delete-event handling and during cache
// pressure; the resolved flag must travel with the cleared state so a
// re-arrival of the same event gets re-verified instead of being silently
// skipped against stale state.
val src = sourceNote("ee".repeat(32))
src.onchainZapResolved = true
src.removeAllChildNotes()
src.clearChildLinks()
assertFalse(src.onchainZapResolved)
}
@@ -0,0 +1,213 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertSame
import kotlin.test.assertTrue
/**
* Guards the cache-pruning invariant: when a Note is removed from `LocalCache`,
* every other Note that referenced it must drop that strong reference. Onchain
* zaps (NIP-BC) and nutzaps (NIP-61) were added to [Note] after the original
* removal/migration routines were written, so these tests pin that
* [Note.removeNote], [Note.clearChildLinks], and [Note.moveAllReferencesTo]
* all account for them — otherwise a pruned source Note leaks through the
* target's `onchainZaps` / `nutzaps` map and a duplicate Note with the same id
* gets minted on the next relay echo.
*/
class NotePruningReferenceTest {
private fun note(idHex: String) = Note(idHex)
private fun userFor(pubKey: HexKey) = User(pubKey) { addr -> Note(addr.toValue()) }
// A source-event Note with a wired author, matching the production shape where
// `source.author` is the zap sender.
private fun sourceNote(pubKey: HexKey): Note = note(pubKey).apply { author = userFor(pubKey) }
private fun eventWith(idHex: HexKey): Event =
Event(
id = idHex,
pubKey = "ab".repeat(32),
createdAt = 1L,
kind = 9321,
tags = emptyArray(),
content = "",
sig = "sig",
)
// ── Fix 1: removeNote drops onchain-zap and nutzap sources ──────────────
@Test
fun removeNoteDetachesOnchainZapSource() {
val target = note("a".repeat(64))
val src = sourceNote("11".repeat(32))
target.addOnchainZap(src, "tx1", claimedSats = 1000L, verifiedSats = 1000L, status = OnchainZapStatus.CONFIRMED)
assertTrue(target.onchainZaps.containsKey("tx1"))
target.removeNote(src)
assertTrue(target.onchainZaps.isEmpty(), "onchain zap source must be detached on removeNote")
}
@Test
fun removeNoteDetachesNutzapSource() {
val target = note("b".repeat(64))
val src = sourceNote("22".repeat(32)).apply { event = eventWith("cc".repeat(32)) }
target.addNutzap(src, claimedSats = 500L)
assertTrue(target.nutzaps.isNotEmpty())
target.removeNote(src)
assertTrue(target.nutzaps.isEmpty(), "nutzap source must be detached on removeNote")
}
@Test
fun removeOnchainZapBySourceIgnoresUnrelatedSource() {
val target = note("d".repeat(64))
val src = sourceNote("33".repeat(32))
val other = sourceNote("44".repeat(32))
target.addOnchainZap(src, "tx1", claimedSats = 1000L, verifiedSats = 1000L, status = OnchainZapStatus.CONFIRMED)
target.removeNote(other)
assertTrue(target.onchainZaps.containsKey("tx1"), "removing an unrelated note must not drop the entry")
}
// ── Fix 2: clearChildLinks returns onchain-zap sources ──────────────
@Test
fun clearChildLinksReturnsAndClearsOnchainZapSources() {
val target = note("e".repeat(64))
val src = sourceNote("55".repeat(32))
target.addOnchainZap(src, "tx1", claimedSats = 1000L, verifiedSats = 1000L, status = OnchainZapStatus.CONFIRMED)
val removed = target.clearChildLinks()
assertTrue(src in removed, "onchain zap source must be returned for removal from the cache map")
assertTrue(target.onchainZaps.isEmpty())
}
// ── Fix 3: moveAllReferencesTo migrates onchain zaps, zap payments, labels ──
@Test
fun moveAllReferencesToMigratesOnchainZaps() {
val old = note("f0".repeat(32))
val newer = AddressableNote(Address(30023, "ab".repeat(32), "slug"))
val src = sourceNote("66".repeat(32)).apply { replyTo = listOf(old) }
old.addOnchainZap(src, "tx1", claimedSats = 1000L, verifiedSats = 1000L, status = OnchainZapStatus.CONFIRMED)
old.moveAllReferencesTo(newer)
assertTrue(old.onchainZaps.isEmpty(), "old version must release its onchain zaps")
assertEquals(1, newer.onchainZaps.size, "onchain zap must move to the newer version")
assertSame(src, newer.onchainZaps["tx1"]?.source)
assertSame(newer, src.replyTo?.single(), "source replyTo must repoint to the newer version")
}
@Test
fun moveAllReferencesToMigratesZapPayments() {
val old = note("f1".repeat(32))
val newer = AddressableNote(Address(30023, "ab".repeat(32), "slug2"))
val request = note("77".repeat(32)).apply { replyTo = listOf(old) }
val response = note("88".repeat(32))
old.addZapPayment(request, response)
old.moveAllReferencesTo(newer)
assertTrue(old.zapPayments.isEmpty(), "old version must release its zap payments")
assertTrue(newer.zapPayments.containsKey(request), "zap payment must move to the newer version")
assertSame(newer, request.replyTo?.single())
}
@Test
fun moveAllReferencesToMigratesLabels() {
val old = note("f2".repeat(32))
val newer = AddressableNote(Address(30023, "ab".repeat(32), "slug3"))
val labelNote = note("99".repeat(32)).apply { replyTo = listOf(old) }
old.addLabel("nostr", labelNote)
old.moveAllReferencesTo(newer)
assertTrue(old.labels.isEmpty(), "old version must release its labels")
assertTrue(newer.labels["nostr"]?.contains(labelNote) == true, "label must move to the newer version")
assertSame(newer, labelNote.replyTo?.single())
}
// ── Fix 4: deleteNote severs child back-references (no partial deletion) ──
@Test
fun detachFromChildrenSeversReplyToAndClearsCollections() {
val parent = note("a1".repeat(32))
val reply = note("b1".repeat(32)).apply { replyTo = listOf(parent) }
parent.addReply(reply)
val detached = parent.detachFromChildren()
assertTrue(reply in detached, "the child must be returned as detached")
assertTrue(parent.replies.isEmpty(), "parent must release its forward child references")
assertTrue(
reply.replyTo?.contains(parent) != true,
"child must no longer point back at the removed parent",
)
}
@Test
fun detachFromChildrenKeepsOtherParents() {
val deleted = note("a2".repeat(32))
val survivor = note("c2".repeat(32))
val reply = note("b2".repeat(32)).apply { replyTo = listOf(deleted, survivor) }
deleted.addReply(reply)
survivor.addReply(reply)
deleted.detachFromChildren()
assertEquals(listOf(survivor), reply.replyTo, "only the removed parent must be dropped from replyTo")
}
@Test
fun detachFromChildrenSeversReactionAndZapSources() {
val parent = note("a3".repeat(32))
val reaction = sourceNote("31".repeat(32)).apply { replyTo = listOf(parent) }
val zapSource = sourceNote("32".repeat(32)).apply { replyTo = listOf(parent) }
parent.addOnchainZap(zapSource, "tx1", claimedSats = 1L, verifiedSats = 1L, status = OnchainZapStatus.CONFIRMED)
parent.addBoost(reaction)
parent.detachFromChildren()
assertTrue(parent.boosts.isEmpty())
assertTrue(parent.onchainZaps.isEmpty())
assertTrue(reaction.replyTo?.contains(parent) != true)
assertTrue(zapSource.replyTo?.contains(parent) != true)
}
}