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. // 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 side = if (wide) maxOf(20.dp, (maxWidth - 680.dp) / 2) else 20.dp
val sideBySide = wide && maxWidth - side * 2 >= 640.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( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues( contentPadding = PaddingValues(
start = side, start = side,
end = side, end = side,
top = insets.calculateTopPadding() + 8.dp, top = 8.dp,
bottom = insets.calculateBottomPadding() + 32.dp, bottom = insets.calculateBottomPadding() + 32.dp,
), ),
) { ) {
item { item {
Column(Modifier.padding(bottom = 12.dp)) { Column(Modifier.padding(bottom = 12.dp)) {
Breadcrumbs(poem.fullTitle, poem.fullUrl.ifBlank { fullUrl }, onCategory) Breadcrumbs(poem.fullTitle, poem.fullUrl.ifBlank { fullUrl }, onCategory)
RecitationPlayer(poem.id)
poem.metre?.rhythm?.let { rhythm -> poem.metre?.rhythm?.let { rhythm ->
Text( Text(
text = rhythm, text = rhythm,
@ -226,6 +230,7 @@ fun PoemScreen(
} }
} }
} }
}
if (wide) { if (wide) {
AnimatedVisibility( AnimatedVisibility(
visible = tapped != null, visible = tapped != null,

View File

@ -3,6 +3,7 @@ package com.ganjoor.android.ui
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.MediaPlayer import android.media.MediaPlayer
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -15,11 +16,14 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
@ -27,24 +31,40 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.runtime.CompositionLocalProvider
import com.ganjoor.android.R import com.ganjoor.android.R
import com.ganjoor.android.data.Ganjoor import com.ganjoor.android.data.Ganjoor
import com.ganjoor.android.data.Recitation 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. * Plays a reading of the poem, streamed from Ganjoor.
* *
* ponytail: the platform's MediaPlayer rather than ExoPlayer — one URL, play and pause, no * Placed above the scrolling text rather than inside it, and deliberately so: as an item of the
* playlist or seeking to justify a media library. Nothing is cached, so this is the one part of * LazyColumn it was disposed the moment it scrolled off, which released the MediaPlayer and cut
* the app that needs a connection; it simply doesn't appear when there is no reading or no * the reading off mid-line. Pinned here it stays in composition for as long as the poem is open,
* network. * 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 @Composable
fun RecitationPlayer(poemId: Int) { fun RecitationPlayer(poemId: Int, modifier: Modifier = Modifier) {
val recitations by produceState(emptyList<Recitation>(), poemId) { val recitations by produceState(emptyList<Recitation>(), poemId) {
value = Ganjoor.recitations(poemId) value = Ganjoor.recitations(poemId)
} }
@ -54,6 +74,10 @@ fun RecitationPlayer(poemId: Int) {
var playing by remember(poemId) { mutableStateOf(false) } var playing by remember(poemId) { mutableStateOf(false) }
var loading by remember(poemId) { mutableStateOf(false) } var loading by remember(poemId) { mutableStateOf(false) }
var picking by remember { 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 { val player = remember {
MediaPlayer().apply { 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() } } } DisposableEffect(player) { onDispose { runCatching { player.release() } } }
val recitation = recitations.getOrNull(chosen) ?: return val recitation = recitations.getOrNull(chosen) ?: return
@ -74,16 +98,26 @@ fun RecitationPlayer(poemId: Int) {
runCatching { player.reset() } runCatching { player.reset() }
playing = false playing = false
loading = false loading = false
position = 0
duration = 0
onDispose { } onDispose { }
} }
// The handle follows the audio only while it is actually moving.
LaunchedEffect(playing) {
while (playing) {
runCatching { position = player.currentPosition }
delay(250)
}
}
fun toggle() { fun toggle() {
if (playing) { if (playing) {
runCatching { player.pause() } runCatching { player.pause() }
playing = false playing = false
return return
} }
if (player.currentPosition > 0) { if (duration > 0) {
runCatching { player.start() }.onSuccess { playing = true } runCatching { player.start() }.onSuccess { playing = true }
return return
} }
@ -91,15 +125,21 @@ fun RecitationPlayer(poemId: Int) {
runCatching { runCatching {
player.reset() player.reset()
player.setDataSource(recitation.mp3Url) player.setDataSource(recitation.mp3Url)
player.setOnPreparedListener { it.start(); playing = true; loading = false } player.setOnPreparedListener {
player.setOnCompletionListener { playing = false } duration = it.duration
it.start()
playing = true
loading = false
}
player.setOnCompletionListener { playing = false; position = duration }
player.setOnErrorListener { _, _, _ -> playing = false; loading = false; true } player.setOnErrorListener { _, _, _ -> playing = false; loading = false; true }
player.prepareAsync() player.prepareAsync()
}.onFailure { loading = false } }.onFailure { loading = false }
} }
Column(modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp), 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,
)
}
}
}
}
} }