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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user