Compare commits

...
Author SHA1 Message Date
anasandClaude Opus 5 3486a89b56 Test the rule that chooses the dictionary's container
The guard added in #3 decides whether the dictionary opens beside the poem or as
a sheet over it, and until now nothing proved it. I tried four times to verify it
by hand and failed every time: reaching it needs a tap landing on the glyphs of
one Persian word inside a SelectionContainer, and adb's synthetic taps either
became a long press and opened the selection menu, or landed between words and
opened the couplet's actions instead. The verses are not exposed to uiautomator
either, so there is nothing to aim at.

The arithmetic behind the rule can be tested even though the gesture cannot, so
it moves into a pure function and gets four cases: a tablet keeps the panel, a
foldable open in portrait does not, a phone never gets it however wide the page
is measured, and the boundary sits exactly at the panel plus the minimum measure
(616dp passes, 615dp does not).

No behaviour changes — this is the same expression, named and covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 18:43:01 +02:00
Anas Rashid 701b984f4b Merge pull request #4 from anas-rashid/fix/dictionary-panel-crushes-narrow-page
Open the dictionary as a sheet where a panel would crush the poem
2026-10-07 18:34:27 +02:00
anasandClaude Opus 5 41d776a35d Merge main, keeping only the part of this branch that is still needed
main has moved on since this branch opened, and most of what it carried has been
answered better there:

- the reading settings no longer need a fallback to the sheet, because they fold
  the columns away and take the room that frees (aab8b2e);
- the two panels no longer share a width, because the dictionary is now 216dp
  against the settings' 360dp.

Both were dropped: the conflicting files are taken from main as they stand.

What survives is the reason this branch exists. Which container the dictionary
uses is still decided by the width of the *window*, and that is the wrong
question — what matters is what is left of the page once the columns have taken
theirs. On a 700dp foldable that is about 450dp, and a panel beside it leaves
the verse a couple of characters a line. It now opens beside the poem only while
the page keeps 400dp, and falls back to the sheet below that, which covers the
foot of the poem but leaves every line whole.

Recalculated for the narrower panel: a tablet keeps the panel (~900dp page less
216dp leaves 684dp), a foldable in portrait does not (236dp).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 18:33:24 +02:00
anasandClaude Opus 5 bcdad2e699 Merge fix/one-side-panel-at-a-time
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 18:28:34 +02:00
anasandClaude Opus 5 ce26676d4e Merge fix/recitation-keeps-playing
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 18:28:34 +02:00
anasandClaude Opus 5 488f48ad39 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>
2026-10-07 18:22:28 +02:00
anasandClaude Opus 5 936fd3cf3b Open the dictionary as a sheet where a panel would crush the poem
On a book-style foldable held open in portrait — 700dp, inside the 600–839dp
band — tapping a word made the poem unreadable. The panel is a fixed 360dp and
the columns keep their widths, so the page was left about 75dp: the verse broke
to one or two characters a line, and the breadcrumbs and the metre shattered the
same way.

The panel was chosen from the width of the window. That is the wrong question.
What matters is what is left of the page once the columns have taken their room,
so both panels now ask that instead:

- the dictionary opens beside the poem only while the page keeps 400dp;
- reading settings open beside the browser only while it keeps its own 600dp,
  below which the panel would squeeze the columns and the poem into less than
  the layout is built for.

Where they do not fit, the bottom sheet is used, exactly as on a phone: it
covers the foot of the poem but leaves every line whole. A tablet is unaffected
— verified at 2560x1600 that the panel still opens and the poem still reads.

The two panels now share one width, and the design notes record the rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 18:04:39 +02:00
5 changed files with 211 additions and 49 deletions

No files matched your search

@@ -105,6 +105,14 @@ fun PoemScreen(
val sidePanelOpen = LocalSidePanelOpen.current
LaunchedEffect(sidePanelOpen) { if (sidePanelOpen) tapped = null }
BoxWithConstraints(Modifier.fillMaxSize()) {
// Whether the dictionary gets a column of its own is not a question about the window but
// about what is left of this page once the columns have taken theirs. On a book-style
// foldable held open in portrait the page is already down to ~450dp, and a panel beside it
// left the verse a couple of characters a line. Where it does not fit, the sheet is the
// better answer: it covers the foot of the poem but leaves the lines whole.
val roomForPanel = dictionaryFitsBeside(maxWidth, wide)
Load(
key = fullUrl,
block = { Ganjoor.poem(fullUrl) },
@@ -169,20 +177,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,
@@ -232,9 +244,10 @@ fun PoemScreen(
}
}
}
}
}
}
if (wide) {
if (roomForPanel) {
AnimatedVisibility(
visible = tapped != null,
enter = expandHorizontally() + fadeIn(),
@@ -252,7 +265,7 @@ fun PoemScreen(
}
}
if (!wide) {
if (!roomForPanel) {
tapped?.let { tap ->
WordSheet(
word = tap.word,
@@ -261,6 +274,7 @@ fun PoemScreen(
)
}
}
}
}
/** The positions that make a line of verse; anything else (Single, Paragraph, Comment) is prose. */
@@ -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,
)
}
}
}
}
@@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.ganjoor.android.R
import com.ganjoor.android.data.Definition
@@ -57,6 +58,28 @@ import com.ganjoor.android.ui.theme.readingStyle
*/
val DictionaryPanelWidth = 216.dp
/**
* The narrowest the page may be left once the dictionary opens beside it. Below this the panel is
* not worth its room: on a book-style foldable held open in portrait the columns have already
* taken theirs, and a panel there left the verse a couple of characters a line.
*/
val MinPageMeasure = 400.dp
/**
* Whether the dictionary earns a column of its own beside a page [pageWidth] wide.
*
* Not a question about the window but about the page: by the time a poem is open the columns have
* already taken their room, and on a book-style foldable held open in portrait what is left is
* about 450dp. A panel beside that leaves the verse a couple of characters a line, so below
* [MinPageMeasure] the bottom sheet is used instead — it covers the foot of the poem but leaves
* every line whole.
*
* A pure function so the rule can be tested: the gesture that reaches it cannot be driven
* reliably, but the arithmetic behind it can.
*/
internal fun dictionaryFitsBeside(pageWidth: Dp, wide: Boolean): Boolean =
wide && pageWidth - DictionaryPanelWidth >= MinPageMeasure
/** English prose inside an otherwise right-to-left sheet. */
@Composable
private fun LeftToRight(content: @Composable () -> Unit) {
@@ -0,0 +1,41 @@
package com.ganjoor.android
import androidx.compose.ui.unit.dp
import com.ganjoor.android.ui.dictionaryFitsBeside
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The rule that decides whether the dictionary opens beside the poem or as a sheet over it.
*
* Driving the gesture that reaches it — a tap landing on the glyphs of one Persian word — is not
* something adb does reliably, so the arithmetic is tested directly instead.
*/
class DictionaryPanelFitTest {
@Test
fun `a tablet has room beside the poem`() {
// 1280dp window, less the poets rail and two list columns, leaves the page about 900dp.
assertTrue(dictionaryFitsBeside(900.dp, wide = true))
}
@Test
fun `a foldable open in portrait does not`() {
// 700dp window, less the rail and the newest column, leaves about 450dp — and 450 less
// the 216dp panel is 234dp, which is a cramped phone column, not a measure for a verse.
assertFalse(dictionaryFitsBeside(450.dp, wide = true))
}
@Test
fun `a phone never gets the panel, however the page is measured`() {
assertFalse(dictionaryFitsBeside(420.dp, wide = false))
assertFalse(dictionaryFitsBeside(2000.dp, wide = false))
}
@Test
fun `the boundary is the panel plus the minimum measure`() {
assertTrue(dictionaryFitsBeside(616.dp, wide = true)) // 400 + 216, exactly
assertFalse(dictionaryFitsBeside(615.dp, wide = true))
}
}
@@ -45,6 +45,10 @@ On large screens, tapping a word opens **WordPanel** on the left instead of the
- The poem moves over to make room, so nothing being read is covered.
- The tapped word stays highlighted in the verse (`secondary-container` / `on-secondary-container`), on phones too.
- ✕ or Back closes the panel.
- The panel only opens where the page keeps at least **400dp** beside it. On a book-style
foldable held open in portrait the columns have already taken their room, and a panel
there left the verse a couple of characters a line. Below that the sheet is used instead:
it covers the foot of the poem but leaves the lines whole.
## Reading settings