fix: lazily create inline video players to survive media-heavy notes

InlineVideoPlayer and InlineVideoPlayerWithFullscreen created a fully
prepared ExoPlayer per video URL the moment it entered composition,
with no cap or visibility gating. A note carrying many video URLs
(such as a 155-video note seen in the wild) instantly spawned a
player per URL -- 156 players / 487 player threads measured upstream,
exhausting hardware codec instances (~16-32 device-wide) and memory
until input dispatch timed out (ANR).

The player now only exists while its video is near the viewport:
created when >50% visible with autoplay on, or on tap (which starts
playback immediately); released with its position remembered once the
video scrolls fully off-screen. Until then the slot renders the
uploader-provided NIP-92 imeta "image" preview frame, falling back
to the existing thumbhash/blurhash painter. parseImetaTags now parses
the "image" entry, and MediaCarousel video tiles use it too instead
of a blank box when no thumbhash/blurhash is present.

Port of barrydeen/dark-wisp-android#31.
This commit is contained in:
Letdown2491
2026-06-12 12:14:40 -06:00
parent 04253bbcb1
commit 9fafa1f20c
2 changed files with 196 additions and 50 deletions
@@ -125,7 +125,17 @@ private fun CarouselTile(
)
}
is CarouselItem.Video -> {
if (placeholder != null) {
if (meta.image != null) {
// Uploader-provided preview frame (NIP-92 imeta "image")
AsyncImage(
model = meta.image,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = placeholder,
error = placeholder,
modifier = Modifier.fillMaxSize()
)
} else if (placeholder != null) {
Image(
painter = placeholder,
contentDescription = null,
@@ -188,7 +188,8 @@ data class MediaMeta(
val mime: String? = null,
val dimension: String? = null,
val thumbhash: String? = null,
val blurhash: String? = null
val blurhash: String? = null,
val image: String? = null
)
internal sealed interface ContentSegment {
@@ -224,7 +225,7 @@ private val blossomPathRegex = Regex("""^/[0-9a-f]{64}$""", RegexOption.IGNORE_C
/**
* Parse NIP-92 imeta tags from a list of tags to build a URL→metadata map.
* Tag format: ["imeta", "url https://...", "m image/png", "dim 1024x768", "thumbhash ...", "blurhash ...", ...]
* Tag format: ["imeta", "url https://...", "m image/png", "dim 1024x768", "thumbhash ...", "blurhash ...", "image https://...", ...]
*/
fun parseImetaTags(tags: List<List<String>>): Map<String, MediaMeta> {
val map = mutableMapOf<String, MediaMeta>()
@@ -235,6 +236,7 @@ fun parseImetaTags(tags: List<List<String>>): Map<String, MediaMeta> {
var dim: String? = null
var thumb: String? = null
var blur: String? = null
var image: String? = null
for (i in 1 until tag.size) {
val entry = tag[i]
when {
@@ -243,10 +245,11 @@ fun parseImetaTags(tags: List<List<String>>): Map<String, MediaMeta> {
entry.startsWith("dim ") -> dim = entry.removePrefix("dim ")
entry.startsWith("thumbhash ") -> thumb = entry.removePrefix("thumbhash ")
entry.startsWith("blurhash ") -> blur = entry.removePrefix("blurhash ")
entry.startsWith("image ") -> image = entry.removePrefix("image ")
}
}
if (url != null) {
map[url] = MediaMeta(url = url, mime = mime, dimension = dim, thumbhash = thumb, blurhash = blur)
map[url] = MediaMeta(url = url, mime = mime, dimension = dim, thumbhash = thumb, blurhash = blur, image = image)
}
}
return map
@@ -2302,8 +2305,25 @@ internal fun InlineVideoPlayerWithFullscreen(meta: MediaMeta, onFullScreen: (pos
var isPlaying by remember { mutableStateOf(false) }
var isBuffering by remember { mutableStateOf(false) }
val exoPlayer = remember(url) {
(PipController.reclaimPlayer(url)
// A note can carry an arbitrary number of video URLs, and a prepared
// player per composed URL exhausts hardware codec instances (~16-32
// device-wide), threads (~3 per player), and memory all at once. The
// player therefore only exists while its video is near the viewport
// (or after an explicit tap); scrolling fully off-screen releases it
// and remembers the position.
var playerWanted by remember(url) { mutableStateOf(false) }
var playOnCreate by remember(url) { mutableStateOf(false) }
var resumePositionMs by remember(url) { mutableLongStateOf(0L) }
var exoPlayer by remember(url) { mutableStateOf<ExoPlayer?>(null) }
// Sync volume with global mute state
LaunchedEffect(isMuted) {
exoPlayer?.volume = if (isMuted) 0f else 1f
}
DisposableEffect(url, playerWanted) {
if (!playerWanted) return@DisposableEffect onDispose {}
val player = (PipController.reclaimPlayer(url)
?: HttpClientFactory.createExoPlayer(context).apply {
setMediaItem(MediaItem.fromUri(Uri.parse(url)))
prepare()
@@ -2312,14 +2332,7 @@ internal fun InlineVideoPlayerWithFullscreen(meta: MediaMeta, onFullScreen: (pos
}).apply {
repeatMode = Player.REPEAT_MODE_ONE
}
}
// Sync volume with global mute state
LaunchedEffect(isMuted) {
exoPlayer.volume = if (isMuted) 0f else 1f
}
DisposableEffect(url) {
if (resumePositionMs > 0) player.seekTo(resumePositionMs)
val listener = object : Player.Listener {
override fun onVideoSizeChanged(videoSize: VideoSize) {
if (videoSize.width > 0 && videoSize.height > 0) {
@@ -2328,7 +2341,7 @@ internal fun InlineVideoPlayerWithFullscreen(meta: MediaMeta, onFullScreen: (pos
}
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
if (!playing && exoPlayer.playbackState == Player.STATE_READY) {
if (!playing && player.playbackState == Player.STATE_READY) {
userPaused = true
}
}
@@ -2336,13 +2349,23 @@ internal fun InlineVideoPlayerWithFullscreen(meta: MediaMeta, onFullScreen: (pos
isBuffering = state == Player.STATE_BUFFERING
}
}
exoPlayer.addListener(listener)
player.addListener(listener)
if (playOnCreate) {
playOnCreate = false
activeVideoUrl.value = url
player.play()
}
exoPlayer = player
onDispose {
exoPlayer.removeListener(listener)
player.removeListener(listener)
activeVideoUrl.compareAndSet(url, null)
resumePositionMs = player.currentPosition
isPlaying = false
isBuffering = false
exoPlayer = null
// Only release if not handed off to PiP
if (PipController.pipState.value?.url != url) {
exoPlayer.release()
player.release()
}
}
}
@@ -2364,25 +2387,80 @@ internal fun InlineVideoPlayerWithFullscreen(meta: MediaMeta, onFullScreen: (pos
val visibleHeight = (visibleBottom - visibleTop).coerceAtLeast(0f)
val totalHeight = bounds.height
val visibleFraction = if (totalHeight > 0) visibleHeight / totalHeight else 0f
val player = exoPlayer
if (visibleFraction > 0.5f) {
if (mediaSettings.videoAutoPlay && !exoPlayer.isPlaying && !userPaused) {
if (mediaSettings.videoAutoPlay && !userPaused) {
val current = activeVideoUrl.value
if (current == null || current == url) {
activeVideoUrl.value = url
exoPlayer.play()
if (player == null) {
playerWanted = true
} else if (!player.isPlaying) {
activeVideoUrl.value = url
player.play()
}
}
}
} else if (visibleFraction <= 0f) {
// Fully off-screen: release the player, keep the position
if (playerWanted) playerWanted = false
userPaused = false
} else {
if (exoPlayer.isPlaying) exoPlayer.pause()
if (player?.isPlaying == true) player.pause()
activeVideoUrl.compareAndSet(url, null)
userPaused = false
}
}
) {
val player = exoPlayer
if (player == null) {
// Stand-in until the player is needed; tap plays immediately
val blurPainter = rememberMediaPlaceholderPainter(meta.thumbhash, meta.blurhash, meta.dimension)
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable {
userPaused = false
playOnCreate = true
playerWanted = true
},
contentAlignment = Alignment.Center
) {
if (meta.image != null) {
// Uploader-provided preview frame (NIP-92 imeta "image")
AsyncImage(
model = meta.image,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = blurPainter,
error = blurPainter,
modifier = Modifier.fillMaxSize()
)
} else if (blurPainter != null) {
Image(
painter = blurPainter,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
}
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play video",
modifier = Modifier
.size(64.dp)
.background(
color = Color.Black.copy(alpha = 0.5f),
shape = CircleShape
)
.padding(8.dp),
tint = Color.White
)
}
} else {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
player = exoPlayer
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
useController = true
controllerAutoShow = false
@@ -2395,6 +2473,7 @@ internal fun InlineVideoPlayerWithFullscreen(meta: MediaMeta, onFullScreen: (pos
hideController()
}
},
update = { it.player = player },
modifier = Modifier.fillMaxSize()
)
@@ -2444,8 +2523,8 @@ internal fun InlineVideoPlayerWithFullscreen(meta: MediaMeta, onFullScreen: (pos
Spacer(Modifier.width(4.dp))
IconButton(
onClick = {
val position = exoPlayer.currentPosition
exoPlayer.pause()
val position = player.currentPosition
player.pause()
onFullScreen(position)
},
colors = buttonColors,
@@ -2459,7 +2538,7 @@ internal fun InlineVideoPlayerWithFullscreen(meta: MediaMeta, onFullScreen: (pos
Spacer(Modifier.width(4.dp))
IconButton(
onClick = {
PipController.enterPip(url, exoPlayer, videoAspectRatio)
PipController.enterPip(url, player, videoAspectRatio)
},
colors = buttonColors,
modifier = Modifier.size(36.dp)
@@ -2479,10 +2558,10 @@ internal fun InlineVideoPlayerWithFullscreen(meta: MediaMeta, onFullScreen: (pos
.clickable {
activeVideoUrl.value = url
userPaused = false
if (exoPlayer.playbackState == Player.STATE_ENDED) {
exoPlayer.seekTo(0)
if (player.playbackState == Player.STATE_ENDED) {
player.seekTo(0)
}
exoPlayer.play()
player.play()
},
contentAlignment = Alignment.Center
) {
@@ -2500,6 +2579,7 @@ internal fun InlineVideoPlayerWithFullscreen(meta: MediaMeta, onFullScreen: (pos
)
}
}
} // player != null
}
} // centering wrapper
}
@@ -2581,8 +2661,22 @@ private fun InlineVideoPlayer(url: String, modifier: Modifier = Modifier) {
var userPaused by remember { mutableStateOf(false) }
var isPlaying by remember { mutableStateOf(false) }
var isBuffering by remember { mutableStateOf(false) }
val exoPlayer = remember(url) {
(PipController.reclaimPlayer(url)
// Same lazy lifecycle as InlineVideoPlayerWithFullscreen: the player
// only exists while near the viewport (or after a tap), so a note
// full of video URLs can't exhaust codecs/threads/memory.
var playerWanted by remember(url) { mutableStateOf(false) }
var playOnCreate by remember(url) { mutableStateOf(false) }
var resumePositionMs by remember(url) { mutableLongStateOf(0L) }
var exoPlayer by remember(url) { mutableStateOf<ExoPlayer?>(null) }
LaunchedEffect(isMuted) {
exoPlayer?.volume = if (isMuted) 0f else 1f
}
DisposableEffect(url, playerWanted) {
if (!playerWanted) return@DisposableEffect onDispose {}
val player = (PipController.reclaimPlayer(url)
?: HttpClientFactory.createExoPlayer(context).apply {
setMediaItem(MediaItem.fromUri(Uri.parse(url)))
prepare()
@@ -2591,13 +2685,7 @@ private fun InlineVideoPlayer(url: String, modifier: Modifier = Modifier) {
}).apply {
repeatMode = Player.REPEAT_MODE_ONE
}
}
LaunchedEffect(isMuted) {
exoPlayer.volume = if (isMuted) 0f else 1f
}
DisposableEffect(url) {
if (resumePositionMs > 0) player.seekTo(resumePositionMs)
val listener = object : Player.Listener {
override fun onVideoSizeChanged(videoSize: VideoSize) {
if (videoSize.width > 0 && videoSize.height > 0) {
@@ -2606,7 +2694,7 @@ private fun InlineVideoPlayer(url: String, modifier: Modifier = Modifier) {
}
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
if (!playing && exoPlayer.playbackState == Player.STATE_READY) {
if (!playing && player.playbackState == Player.STATE_READY) {
userPaused = true
}
}
@@ -2614,12 +2702,22 @@ private fun InlineVideoPlayer(url: String, modifier: Modifier = Modifier) {
isBuffering = state == Player.STATE_BUFFERING
}
}
exoPlayer.addListener(listener)
player.addListener(listener)
if (playOnCreate) {
playOnCreate = false
activeVideoUrl.value = url
player.play()
}
exoPlayer = player
onDispose {
exoPlayer.removeListener(listener)
player.removeListener(listener)
activeVideoUrl.compareAndSet(url, null)
resumePositionMs = player.currentPosition
isPlaying = false
isBuffering = false
exoPlayer = null
if (PipController.pipState.value?.url != url) {
exoPlayer.release()
player.release()
}
}
}
@@ -2636,25 +2734,61 @@ private fun InlineVideoPlayer(url: String, modifier: Modifier = Modifier) {
val visibleHeight = (visibleBottom - visibleTop).coerceAtLeast(0f)
val totalHeight = bounds.height
val visibleFraction = if (totalHeight > 0) visibleHeight / totalHeight else 0f
val player = exoPlayer
if (visibleFraction > 0.5f) {
if (mediaSettings.videoAutoPlay && !exoPlayer.isPlaying && !userPaused) {
if (mediaSettings.videoAutoPlay && !userPaused) {
val current = activeVideoUrl.value
if (current == null || current == url) {
activeVideoUrl.value = url
exoPlayer.play()
if (player == null) {
playerWanted = true
} else if (!player.isPlaying) {
activeVideoUrl.value = url
player.play()
}
}
}
} else if (visibleFraction <= 0f) {
// Fully off-screen: release the player, keep the position
if (playerWanted) playerWanted = false
userPaused = false
} else {
if (exoPlayer.isPlaying) exoPlayer.pause()
if (player?.isPlaying == true) player.pause()
activeVideoUrl.compareAndSet(url, null)
userPaused = false
}
}
) {
val player = exoPlayer
if (player == null) {
// Stand-in until the player is needed; tap plays immediately
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable {
userPaused = false
playOnCreate = true
playerWanted = true
},
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play video",
modifier = Modifier
.size(64.dp)
.background(
color = Color.Black.copy(alpha = 0.5f),
shape = CircleShape
)
.padding(8.dp),
tint = Color.White
)
}
} else {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
player = exoPlayer
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
useController = true
controllerAutoShow = false
@@ -2667,6 +2801,7 @@ private fun InlineVideoPlayer(url: String, modifier: Modifier = Modifier) {
hideController()
}
},
update = { it.player = player },
modifier = Modifier.fillMaxSize()
)
@@ -2707,10 +2842,10 @@ private fun InlineVideoPlayer(url: String, modifier: Modifier = Modifier) {
.clickable {
activeVideoUrl.value = url
userPaused = false
if (exoPlayer.playbackState == Player.STATE_ENDED) {
exoPlayer.seekTo(0)
if (player.playbackState == Player.STATE_ENDED) {
player.seekTo(0)
}
exoPlayer.play()
player.play()
},
contentAlignment = Alignment.Center
) {
@@ -2728,6 +2863,7 @@ private fun InlineVideoPlayer(url: String, modifier: Modifier = Modifier) {
)
}
}
} // player != null
}
}