Merge fix/recitation-keeps-playing
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
ce26676d4e
@ -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) {
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user