Merge fix/recitation-keeps-playing

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
anas 2026-10-07 18:28:34 +02:00
commit ce26676d4e
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,
@ -224,6 +228,7 @@ fun PoemScreen(
}
}
}
}
}
}
if (wide) {

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,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,
)
}
}
}
}