From 488f48ad3902d36d623f4ce762065b12cd21e048 Mon Sep 17 00:00:00 2001 From: anas Date: Wed, 7 Oct 2026 18:22:28 +0200 Subject: [PATCH] Keep the reading playing when the poem is scrolled, and let it be scrubbed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../java/com/ganjoor/android/ui/PoemScreen.kt | 11 +- .../ganjoor/android/ui/RecitationPlayer.kt | 168 +++++++++++++----- 2 files changed, 132 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt index 82a1ba2..3bc1fe4 100644 --- a/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt +++ b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt @@ -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, @@ -224,6 +228,7 @@ fun PoemScreen( } } } + } } } if (wide) { diff --git a/app/src/main/java/com/ganjoor/android/ui/RecitationPlayer.kt b/app/src/main/java/com/ganjoor/android/ui/RecitationPlayer.kt index 6513ab5..39f5eab 100644 --- a/app/src/main/java/com/ganjoor/android/ui/RecitationPlayer.kt +++ b/app/src/main/java/com/ganjoor/android/ui/RecitationPlayer.kt @@ -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(), 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(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,50 +125,96 @@ 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 } } - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - IconButton(onClick = ::toggle) { - when { - loading -> CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) - // Core Material icons ship no pause glyph, and the extended set is 4 MB for one. - playing -> Icon(painterResource(R.drawable.ic_pause), stringResource(R.string.pause)) - else -> Icon(Icons.Default.PlayArrow, stringResource(R.string.play_recitation)) + Column(modifier.fillMaxWidth().padding(bottom = 8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + IconButton(onClick = ::toggle) { + when { + loading -> CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + // Core Material icons ship no pause glyph, and the extended set is 4 MB for one. + playing -> Icon(painterResource(R.drawable.ic_pause), stringResource(R.string.pause)) + else -> Icon(Icons.Default.PlayArrow, stringResource(R.string.play_recitation)) + } + } + TextButton( + onClick = { if (recitations.size > 1) picking = true }, + modifier = Modifier.weight(1f, fill = false), + ) { + Text( + text = recitation.audioArtist.ifBlank { stringResource(R.string.play_recitation) }, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (recitations.size > 1) { + Text( + text = "${chosen + 1}/${recitations.size}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + DropdownMenu(expanded = picking, onDismissRequest = { picking = false }) { + recitations.forEachIndexed { index, item -> + DropdownMenuItem( + text = { Text(item.audioArtist.ifBlank { item.audioTitle }) }, + onClick = { chosen = index; picking = false }, + ) + } } } - TextButton( - onClick = { if (recitations.size > 1) picking = true }, - modifier = Modifier.weight(1f, fill = false), - ) { - Text( - text = recitation.audioArtist.ifBlank { stringResource(R.string.play_recitation) }, - style = MaterialTheme.typography.labelLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - if (recitations.size > 1) { - Text( - text = "${chosen + 1}/${recitations.size}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - DropdownMenu(expanded = picking, onDismissRequest = { picking = false }) { - recitations.forEachIndexed { index, item -> - DropdownMenuItem( - text = { Text(item.audioArtist.ifBlank { item.audioTitle }) }, - onClick = { chosen = index; picking = false }, - ) + + // 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, + ) + } } } }