Keep the reading playing when the poem is scrolled, and let it be scrubbed

The player was an item of the LazyColumn, so scrolling it off screen disposed
it, and the DisposableEffect that releases the MediaPlayer cut the reading off
mid-line. It is now pinned above the scrolling text: it stays in composition for
as long as the poem is open, and stays in reach while you read further down,
which is when a player is wanted.

A seek bar comes with it — position, a draggable handle, and the two times —
shown only once the length is known, because a bar that cannot be dragged
anywhere is furniture. The handle follows the audio on a 250ms poll while it
plays and stops fighting the finger while it is being dragged. MediaPlayer
already seeks; this needed no media library.

The bar and its times run left to right inside the otherwise right-to-left page,
because time does, whatever the script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
anas 2026-10-07 18:22:28 +02:00
parent c3b22fdf0e
commit 488f48ad39
2 changed files with 132 additions and 47 deletions

View File

@ -161,20 +161,24 @@ fun PoemScreen(
// couplet share a line, as ganjoor.net sets them on a desktop.
val side = if (wide) maxOf(20.dp, (maxWidth - 680.dp) / 2) else 20.dp
val sideBySide = wide && maxWidth - side * 2 >= 640.dp
SelectionContainer {
Column(Modifier.fillMaxSize().padding(top = insets.calculateTopPadding())) {
// Above the text, not in it: as an item of the list the player was disposed the
// moment it scrolled off, which released the MediaPlayer and cut the reading off
// mid-line. Here it keeps playing, and stays in reach while you read further down.
RecitationPlayer(poem.id, Modifier.padding(start = side, end = side, top = 8.dp))
SelectionContainer(Modifier.weight(1f)) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = side,
end = side,
top = insets.calculateTopPadding() + 8.dp,
top = 8.dp,
bottom = insets.calculateBottomPadding() + 32.dp,
),
) {
item {
Column(Modifier.padding(bottom = 12.dp)) {
Breadcrumbs(poem.fullTitle, poem.fullUrl.ifBlank { fullUrl }, onCategory)
RecitationPlayer(poem.id)
poem.metre?.rhythm?.let { rhythm ->
Text(
text = rhythm,
@ -226,6 +230,7 @@ fun PoemScreen(
}
}
}
}
if (wide) {
AnimatedVisibility(
visible = tapped != null,

View File

@ -3,6 +3,7 @@ package com.ganjoor.android.ui
import android.media.AudioAttributes
import android.media.MediaPlayer
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -15,11 +16,14 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.produceState
@ -27,24 +31,40 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.CompositionLocalProvider
import com.ganjoor.android.R
import com.ganjoor.android.data.Ganjoor
import com.ganjoor.android.data.Recitation
import kotlinx.coroutines.delay
/** mm:ss, the only shape a reading's length ever needs. */
private fun clock(millis: Int): String {
val total = (millis / 1000).coerceAtLeast(0)
return "%d:%02d".format(total / 60, total % 60)
}
/**
* Plays a reading of the poem, streamed from Ganjoor.
*
* ponytail: the platform's MediaPlayer rather than ExoPlayer — one URL, play and pause, no
* playlist or seeking to justify a media library. Nothing is cached, so this is the one part of
* the app that needs a connection; it simply doesn't appear when there is no reading or no
* network.
* Placed above the scrolling text rather than inside it, and deliberately so: as an item of the
* LazyColumn it was disposed the moment it scrolled off, which released the MediaPlayer and cut
* the reading off mid-line. Pinned here it stays in composition for as long as the poem is open,
* and stays in reach — which is what a player is for while you are reading further down.
*
* ponytail: still the platform's MediaPlayer rather than ExoPlayer. One URL, and seeking within
* it, is not a media library's worth of work. Nothing is cached, so this is the one part of the
* app that needs a connection; it simply doesn't appear when there is no reading or no network.
*/
@Composable
fun RecitationPlayer(poemId: Int) {
fun RecitationPlayer(poemId: Int, modifier: Modifier = Modifier) {
val recitations by produceState(emptyList<Recitation>(), poemId) {
value = Ganjoor.recitations(poemId)
}
@ -54,6 +74,10 @@ fun RecitationPlayer(poemId: Int) {
var playing by remember(poemId) { mutableStateOf(false) }
var loading by remember(poemId) { mutableStateOf(false) }
var picking by remember { mutableStateOf(false) }
var duration by remember(poemId) { mutableIntStateOf(0) }
var position by remember(poemId) { mutableIntStateOf(0) }
// While a finger is on the slider the poll must not fight it for the handle.
var scrubbing by remember(poemId) { mutableStateOf<Float?>(null) }
val player = remember {
MediaPlayer().apply {
@ -65,7 +89,7 @@ fun RecitationPlayer(poemId: Int) {
)
}
}
// A reading left playing when the screen goes would keep the whole poem in memory.
// A reading left playing when the poem closes would keep the whole thing in memory.
DisposableEffect(player) { onDispose { runCatching { player.release() } } }
val recitation = recitations.getOrNull(chosen) ?: return
@ -74,16 +98,26 @@ fun RecitationPlayer(poemId: Int) {
runCatching { player.reset() }
playing = false
loading = false
position = 0
duration = 0
onDispose { }
}
// The handle follows the audio only while it is actually moving.
LaunchedEffect(playing) {
while (playing) {
runCatching { position = player.currentPosition }
delay(250)
}
}
fun toggle() {
if (playing) {
runCatching { player.pause() }
playing = false
return
}
if (player.currentPosition > 0) {
if (duration > 0) {
runCatching { player.start() }.onSuccess { playing = true }
return
}
@ -91,15 +125,21 @@ fun RecitationPlayer(poemId: Int) {
runCatching {
player.reset()
player.setDataSource(recitation.mp3Url)
player.setOnPreparedListener { it.start(); playing = true; loading = false }
player.setOnCompletionListener { playing = false }
player.setOnPreparedListener {
duration = it.duration
it.start()
playing = true
loading = false
}
player.setOnCompletionListener { playing = false; position = duration }
player.setOnErrorListener { _, _, _ -> playing = false; loading = false; true }
player.prepareAsync()
}.onFailure { loading = false }
}
Column(modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
@ -138,4 +178,44 @@ fun RecitationPlayer(poemId: Int) {
}
}
}
// Only once the length is known: a bar that cannot be dragged anywhere is furniture.
if (duration > 0) {
// Time runs left to right whatever the script, so the bar and its two times are laid
// out that way inside an otherwise right-to-left page.
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = clock(scrubbing?.toInt() ?: position),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Slider(
value = scrubbing ?: position.toFloat().coerceIn(0f, duration.toFloat()),
onValueChange = { scrubbing = it },
onValueChangeFinished = {
scrubbing?.let { target ->
runCatching { player.seekTo(target.toInt()) }
position = target.toInt()
}
scrubbing = null
},
valueRange = 0f..duration.toFloat(),
modifier = Modifier
.weight(1f)
.semantics { contentDescription = "seek" },
)
Text(
text = clock(duration),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}