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>
@ -61,6 +61,7 @@ import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@ -156,6 +157,12 @@ private val CatEntry.url
|
||||
is CatEntry.Poem -> poem.fullUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* True while a side panel (the reading settings) is open beside the page on a large screen.
|
||||
* The columns step aside for it, so the page keeps its room, and come back when it closes.
|
||||
*/
|
||||
val LocalSidePanelOpen = compositionLocalOf { false }
|
||||
|
||||
/**
|
||||
* The columns beside [content]. [expanded] (840dp and up) shows up to three list columns;
|
||||
* narrower, only the newest one, with a way back up in its header.
|
||||
@ -174,7 +181,11 @@ fun ColumnBrowser(
|
||||
content: @Composable (toggle: @Composable () -> Unit) -> Unit,
|
||||
) {
|
||||
val settings = LocalSettings.current
|
||||
val hidden = settings.value.columnsHidden
|
||||
// Hidden by the reader (reader view, remembered) or only while the settings panel is open.
|
||||
// The second never touches the saved choice, so closing the panel puts back exactly what
|
||||
// was there: the columns if they were showing, reader view if it was on.
|
||||
val makingRoom = LocalSidePanelOpen.current
|
||||
val hidden = settings.value.columnsHidden || makingRoom
|
||||
val columns = remember(url, isPoem) { columnUrls(url, isPoem) }
|
||||
// What is selected in each column: the next step of the path, down to the open poem.
|
||||
val path = if (isPoem) columns + url else columns
|
||||
@ -219,13 +230,14 @@ fun ColumnBrowser(
|
||||
Box(Modifier.weight(1f).fillMaxHeight()) {
|
||||
content {
|
||||
// Hiding is in the top bar; bringing them back is the floating button below,
|
||||
// which is there whenever they are hidden.
|
||||
// which is there whenever the reader has hidden them.
|
||||
if (!hidden) ColumnsToggle { settings.update { it.copy(columnsHidden = true) } }
|
||||
}
|
||||
// Reader view: the page has the whole screen, and one button, at the edge the
|
||||
// columns went to, brings them back as they were.
|
||||
// Not while the settings panel is open: the columns would stay away for it anyway.
|
||||
ShowColumnsButton(
|
||||
visible = hidden,
|
||||
visible = hidden && !makingRoom,
|
||||
onShow = { settings.update { it.copy(columnsHidden = false) } },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
|
||||
@ -110,13 +110,12 @@ fun GanjoorApp() {
|
||||
// Up to three list columns beside the poets; below this, only the newest one.
|
||||
val expanded by rememberUpdatedState(maxWidth >= 840.dp)
|
||||
// On a large screen reading settings open in a panel on the left, where the dictionary
|
||||
// opens, so the page stays in view and shows each change as it is made — but only where
|
||||
// the browser still has its own room beside the panel. Below that the panel would squeeze
|
||||
// the columns and the poem into less than the layout is built for, so the sheet is used
|
||||
// instead, exactly as on a phone.
|
||||
val settingsPanel by rememberUpdatedState(maxWidth - SidePanelWidth >= 600.dp)
|
||||
// opens, so the page stays in view and shows each change as it is made.
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
// The columns fold away while the settings panel is open, so the page doesn't end up
|
||||
// squeezed between them and the panel.
|
||||
Box(Modifier.weight(1f)) {
|
||||
CompositionLocalProvider(LocalSidePanelOpen provides (wide && settingsOpen)) {
|
||||
NavHost(
|
||||
navController = nav,
|
||||
startDestination = PoetsRoute,
|
||||
@ -241,7 +240,8 @@ fun GanjoorApp() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (settingsPanel) {
|
||||
}
|
||||
if (wide) {
|
||||
AnimatedVisibility(
|
||||
visible = settingsOpen,
|
||||
enter = expandHorizontally() + fadeIn(),
|
||||
@ -253,7 +253,7 @@ fun GanjoorApp() {
|
||||
}
|
||||
|
||||
// Inside the provider: the sheet reads LocalOpenAbout, so it has to be in scope.
|
||||
if (settingsOpen && !settingsPanel) ReadingSettingsSheet(onDismiss = { settingsOpen = false })
|
||||
if (settingsOpen && !wide) ReadingSettingsSheet(onDismiss = { settingsOpen = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,10 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@ -93,13 +97,21 @@ fun PoemScreen(
|
||||
// finds, so the couplet's own actions live at its foot rather than behind a tap between words.
|
||||
var tapped by remember { mutableStateOf<WordTap?>(null) }
|
||||
|
||||
// One side panel at a time. The dictionary and the reading settings both want the left of the
|
||||
// screen, and opening the second put two panels there at once — or, where there was no longer
|
||||
// room for two, left the dictionary as a sheet in the middle of the page while the settings
|
||||
// sat beside it. Either way the reader is asked to look in two places. The settings replace
|
||||
// the dictionary instead; closing them leaves the poem, which is where the reader was.
|
||||
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 about 75dp wide — one or two 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 = wide && maxWidth - SidePanelWidth >= MinPageMeasure
|
||||
// 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 = wide && maxWidth - DictionaryPanelWidth >= MinPageMeasure
|
||||
|
||||
Load(
|
||||
key = fullUrl,
|
||||
@ -165,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,
|
||||
@ -228,6 +244,7 @@ fun PoemScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (roomForPanel) {
|
||||
@ -260,6 +277,9 @@ fun PoemScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** The positions that make a line of verse; anything else (Single, Paragraph, Comment) is prose. */
|
||||
private val VERSE_POSITIONS = setOf(Verse.RIGHT, Verse.LEFT, Verse.CENTERED_1, Verse.CENTERED_2)
|
||||
|
||||
/** A word someone tapped: what to look up, the couplet it came from, and where it sits in its verse. */
|
||||
private data class WordTap(val word: String, val passage: Bookmark, val verse: Int, val range: IntRange)
|
||||
|
||||
@ -388,7 +408,26 @@ private fun Couplet(
|
||||
val oneLine = sideBySide && couplet.size == 2 &&
|
||||
couplet[0].position == Verse.RIGHT && couplet[1].position == Verse.LEFT
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
// Each line of verse sits in its own soft card, so the eye finds where one couplet ends and
|
||||
// the next begins, and the couplet's actions visibly belong to it. Prose (Golestan,
|
||||
// Nowruznameh) stays bare: a paragraph in a box reads as a quotation, not as the text.
|
||||
val isVerse = couplet.all { it.position in VERSE_POSITIONS }
|
||||
val colors = MaterialTheme.colorScheme
|
||||
// A step lighter than the page. On OLED black the usual step is all but black itself, so
|
||||
// the card takes the next one up: still dim, but there.
|
||||
val cardColor =
|
||||
if (colors.surface == Color.Black) colors.surfaceContainerHighest else colors.surfaceContainerHigh
|
||||
val card = if (isVerse) {
|
||||
Modifier
|
||||
.padding(vertical = 4.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(cardColor)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
} else {
|
||||
Modifier.padding(vertical = 6.dp)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().then(card)) {
|
||||
if (oneLine) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
couplet.forEachIndexed { index, verse ->
|
||||
|
||||
@ -81,7 +81,7 @@ fun ReadingSettingsPanel(onDismiss: () -> Unit) {
|
||||
BackHandler(onBack = onDismiss)
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
modifier = Modifier.width(SidePanelWidth).fillMaxHeight(),
|
||||
modifier = Modifier.width(360.dp).fillMaxHeight(),
|
||||
) {
|
||||
Column(Modifier.statusBarsPadding()) {
|
||||
Row(
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,13 +48,19 @@ import com.ganjoor.android.data.LocalAssistant
|
||||
import com.ganjoor.android.data.Pronunciation
|
||||
import com.ganjoor.android.ui.theme.readingStyle
|
||||
|
||||
/** The width of a side panel — the dictionary and the reading settings share it. */
|
||||
val SidePanelWidth = 360.dp
|
||||
/**
|
||||
* How wide the dictionary sits beside the poem.
|
||||
*
|
||||
* Narrower than the reading settings, deliberately: the settings fold the columns away and take
|
||||
* the room that frees, while the dictionary is read *against* the line it came from, so the verse
|
||||
* keeps the width instead. A definition is short; a hemistich is not.
|
||||
*/
|
||||
val DictionaryPanelWidth = 216.dp
|
||||
|
||||
/**
|
||||
* The narrowest the page may be left once a side panel opens beside it. Below this the panel is
|
||||
* not worth its room: on a book-style foldable the columns had already taken theirs, and a 360dp
|
||||
* panel left the verse about 75dp wide, breaking it to one or two characters a line.
|
||||
* 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
|
||||
|
||||
@ -112,7 +118,7 @@ fun WordPanel(word: String, onDismiss: () -> Unit, passage: Bookmark? = null) {
|
||||
BackHandler(onBack = onDismiss)
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
modifier = Modifier.width(SidePanelWidth).fillMaxHeight(),
|
||||
modifier = Modifier.width(DictionaryPanelWidth).fillMaxHeight(),
|
||||
) {
|
||||
Column(Modifier.statusBarsPadding()) {
|
||||
Row(
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# ColumnBrowser
|
||||
|
||||
The layout for tablets and unfolded foldables. A narrow column of poets sits on the right. Beside it is a column for each level of the open book (books › chapters › poems), and the open page fills the rest of the screen on the left. Phones keep today's navigation. Built in `app/src/main/java/com/ganjoor/android/ui/ColumnBrowser.kt`; wireframes are in `design/wireframes/` (1–8).
|
||||
The layout for tablets and unfolded foldables. A narrow column of poets sits on the right. Beside it is a column for each level of the open book (books › chapters › poems), and the open page fills the rest of the screen on the left. Phones keep today's navigation. Built in `app/src/main/java/com/ganjoor/android/ui/ColumnBrowser.kt`; wireframes are in `design/wireframes/` (1–9).
|
||||
|
||||
## Window sizes
|
||||
|
||||
@ -45,18 +45,18 @@ 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.
|
||||
- A panel is only opened where the page keeps at least **400dp** beside it. On a book-style
|
||||
- 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 about 75dp wide — one or two characters a line. Below that the sheet
|
||||
is used instead: it covers the foot of the poem but leaves the lines whole.
|
||||
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
|
||||
|
||||
On large screens the gear opens **ReadingSettingsPanel** on the left, where the dictionary opens, instead of the bottom sheet:
|
||||
- It has the same width and surface as the dictionary panel, with a "Reading settings" header and a ✕ button. The settings and their order are the same as in the sheet.
|
||||
- The page stays in view beside the panel, so a change of theme, font, weight or size shows on the poem as it's made.
|
||||
- ✕ or Back closes it. Phones keep the bottom sheet, and so does any window too narrow to
|
||||
leave the browser its own 600dp beside the panel.
|
||||
- While the panel is open, the poets and list columns fold away so the page keeps its room. When it closes they come back, but only if they were showing before: this never changes the saved reader-view choice, and the floating "show the list" button stays hidden while the panel is open.
|
||||
- ✕ or Back closes it. Phones keep the bottom sheet.
|
||||
|
||||
## Loading
|
||||
|
||||
|
||||
@ -4,7 +4,9 @@ One line of poetry: two hemistichs stacked on a phone, the first pushed to the s
|
||||
|
||||
- **Consumer provides:** the couplet's verses (each with its position), the reading style from settings (`poem-naskh` or `poem-nastaliq` at the reader's size and weight), and handlers for a tapped word and for the actions.
|
||||
- Alignment by verse position: first hemistich `start`, second `end`, centred verses `center`, prose (single/paragraph/comment) `justify` so Golestan and Nowruznameh fill the column.
|
||||
- `space-6` above and below each couplet. Text is `on-surface`.
|
||||
- Every line of verse sits in its own **card**: `surface-container-high` (`surface-container-highest` on OLED black, where the usual step is all but black), `radius-md` (12dp) corners, 12dp/8dp inner padding and 4dp above and below. The card holds the couplet's options chevron, its actions and its summary, so they visibly belong to it. Text is `on-surface`, at least 6:1 on the card in every theme.
|
||||
- Prose (Single, Paragraph or Comment positions: Golestan, Nowruznameh) has no card; it keeps `space-6` above and below. A paragraph in a box would read as a quotation.
|
||||
- The cards are the same on phones, tablets and foldables (wireframes 4–9).
|
||||
- Tapping a word opens the dictionary sheet; tapping between words, or the 32px chevron (`touch-compact`, 20px icon, `on-surface-variant`), opens the couplet's actions: **Save this passage**, **Copy**, **Share** as TextButtons. Save keeps the link back to the poem; Copy doesn't.
|
||||
- Long-press belongs to text selection — never bind actions to it here.
|
||||
- With summaries on, Ganjoor's couplet summary sits underneath in `body-small` / `on-surface-variant`.
|
||||
|
||||
@ -28,7 +28,8 @@
|
||||
.gj-metre { font-size: 12px; line-height: 16px; letter-spacing: .4px; color: var(--on-surface-variant); }
|
||||
|
||||
/* Couplet: two hemistichs stacked; first to the start, second to the end. */
|
||||
.gj-couplet { padding: var(--space-6) 0; }
|
||||
.gj-couplet { margin: var(--space-4) 0; padding: var(--space-8) var(--space-12); border-radius: var(--radius-md); background: var(--surface-container-high); }
|
||||
.gj-couplet.is-prose { margin: 0; padding: var(--space-6) 0; background: none; }
|
||||
.gj-verse { font-family: var(--font-naskh); font-size: 22px; line-height: 1.8; color: var(--on-surface); margin: 0; }
|
||||
.gj-couplet.is-nastaliq .gj-verse { font-family: var(--font-nastaliq); line-height: 2.4; }
|
||||
.gj-verse.is-first { text-align: start; }
|
||||
|
||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 110 KiB |
BIN
design/wireframes/9-phone-couplets.png
Normal file
|
After Width: | Height: | Size: 78 KiB |