diff --git a/README.md b/README.md index 7c01ebd..ed518a6 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,15 @@ then with an affix stripped, then the parts of a ZWNJ compound. The lemma step i classical verse readable — `افتاد` is only findable as `افتادن`, and Wiktionary ships 149,589 form→lemma pairs that make that possible. +Each entry carries its pronunciation where Wiktionary has one — 101,306 of them, tagged with the +variety, Classical Persian first, because a word in a 14th-century ghazal was not said the way +Tehran says it now. Urdu Wiktionary adds the vowelled spelling and the syllable split in Urdu +script. When nothing matches at all, the sheet offers near words ranked by shared letters. + +Poems that Ganjoor has recordings for show a play button, streamed rather than stored: a famous +ghazal often has a dozen readings, and downloading them would dwarf the poems. It is the one +part of the app that needs a connection, and it simply doesn't appear without one. + See [`tools/README.md`](tools/README.md) to rebuild it, and for the licensing of each source. ## Where the poems come from diff --git a/app/src/main/java/com/ganjoor/android/MainActivity.kt b/app/src/main/java/com/ganjoor/android/MainActivity.kt index 6db461a..ea205dc 100644 --- a/app/src/main/java/com/ganjoor/android/MainActivity.kt +++ b/app/src/main/java/com/ganjoor/android/MainActivity.kt @@ -46,7 +46,7 @@ class MainActivity : ComponentActivity() { val systemInDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES window.setBackgroundDrawable( - windowBackground(settings.value.theme, systemInDark).toDrawable() + windowBackground(settings.value.theme, systemInDark, settings.value.oled).toDrawable() ) enableEdgeToEdge() @@ -62,7 +62,7 @@ class MainActivity : ComponentActivity() { // navigates right-to-left whichever UI language is selected. LocalLayoutDirection provides LayoutDirection.Rtl, ) { - GanjoorTheme(settings.value.theme, settings.value.language) { + GanjoorTheme(settings.value.theme, settings.value.language, settings.value.oled) { GanjoorApp() } } diff --git a/app/src/main/java/com/ganjoor/android/data/Ganjoor.kt b/app/src/main/java/com/ganjoor/android/data/Ganjoor.kt index f2ac4bc..6766741 100644 --- a/app/src/main/java/com/ganjoor/android/data/Ganjoor.kt +++ b/app/src/main/java/com/ganjoor/android/data/Ganjoor.kt @@ -145,6 +145,18 @@ private data class LiveCat(val poems: List = emptyList()) @Serializable private data class LivePoem(val id: Int = 0, val excerpt: String? = null) +/** A reading of a whole poem, hosted by Ganjoor. */ +@Serializable +data class Recitation( + val id: Int = 0, + val audioTitle: String = "", + val audioArtist: String = "", + val mp3Url: String = "", +) + +@Serializable +private data class LivePoemRecitations(val recitations: List = emptyList()) + private val liveJson = Json { ignoreUnknownKeys = true } @Serializable @@ -355,6 +367,27 @@ object Ganjoor { } } + /** + * Readings of a poem, by the people who recorded them for ganjoor.net. + * + * Streamed, never stored: the files are a few hundred kilobytes each and there are often a + * dozen readings of a famous ghazal, so downloading them all would dwarf the poems. Offline + * mode therefore has none of this, which is honest — a recording is the one thing here that + * genuinely needs the network. + */ + suspend fun recitations(poemId: Int): List = withContext(Dispatchers.IO) { + if (offline || poemId == 0) return@withContext emptyList() + val url = liveBase.newBuilder() + .addPathSegments("api/ganjoor/poem/$poemId") + .addQueryParameter("recitations", "true") + .addQueryParameter("verseDetails", "false") + .build() + runCatching { + liveJson.decodeFromString(fetch(url)).recitations + .filter { it.mp3Url.isNotBlank() } + }.getOrDefault(emptyList()) + } + /** * Saves a poet's whole tree — biography, every category and every poem — for offline reading. * Re-running it is cheap: anything already on disk is skipped, which is also how a download diff --git a/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt index e285a44..66415dc 100644 --- a/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt +++ b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt @@ -126,6 +126,7 @@ fun PoemScreen( 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, diff --git a/app/src/main/java/com/ganjoor/android/ui/ReadingSettings.kt b/app/src/main/java/com/ganjoor/android/ui/ReadingSettings.kt index 1a5bd69..c5c99a4 100644 --- a/app/src/main/java/com/ganjoor/android/ui/ReadingSettings.kt +++ b/app/src/main/java/com/ganjoor/android/ui/ReadingSettings.kt @@ -78,6 +78,15 @@ fun ReadingSettingsSheet(onDismiss: () -> Unit) { settings.update { it.copy(theme = mode) } } + // Only means anything on a dark theme, so it sits with them and says so. + Toggle( + title = R.string.oled, + note = R.string.oled_note, + checked = prefs.oled, + ) { on -> + settings.update { it.copy(oled = on) } + } + Label(R.string.font) Chips(ReadingFont.entries, prefs.font, { stringResource(it.label) }) { font -> settings.update { it.copy(font = font) } diff --git a/app/src/main/java/com/ganjoor/android/ui/RecitationPlayer.kt b/app/src/main/java/com/ganjoor/android/ui/RecitationPlayer.kt new file mode 100644 index 0000000..6513ab5 --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/ui/RecitationPlayer.kt @@ -0,0 +1,141 @@ +package com.ganjoor.android.ui + +import android.media.AudioAttributes +import android.media.MediaPlayer +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.ganjoor.android.R +import com.ganjoor.android.data.Ganjoor +import com.ganjoor.android.data.Recitation + +/** + * 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. + */ +@Composable +fun RecitationPlayer(poemId: Int) { + val recitations by produceState(emptyList(), poemId) { + value = Ganjoor.recitations(poemId) + } + if (recitations.isEmpty()) return + + var chosen by remember(poemId) { mutableIntStateOf(0) } + var playing by remember(poemId) { mutableStateOf(false) } + var loading by remember(poemId) { mutableStateOf(false) } + var picking by remember { mutableStateOf(false) } + + val player = remember { + MediaPlayer().apply { + setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build() + ) + } + } + // A reading left playing when the screen goes would keep the whole poem in memory. + DisposableEffect(player) { onDispose { runCatching { player.release() } } } + + val recitation = recitations.getOrNull(chosen) ?: return + // Changing reader stops whatever was playing, so the two never overlap. + DisposableEffect(recitation.mp3Url) { + runCatching { player.reset() } + playing = false + loading = false + onDispose { } + } + + fun toggle() { + if (playing) { + runCatching { player.pause() } + playing = false + return + } + if (player.currentPosition > 0) { + runCatching { player.start() }.onSuccess { playing = true } + return + } + loading = true + runCatching { + player.reset() + player.setDataSource(recitation.mp3Url) + player.setOnPreparedListener { it.start(); playing = true; loading = false } + player.setOnCompletionListener { playing = false } + 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)) + } + } + 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 }, + ) + } + } + } +} diff --git a/app/src/main/java/com/ganjoor/android/ui/Settings.kt b/app/src/main/java/com/ganjoor/android/ui/Settings.kt index ccccc2a..33a7466 100644 --- a/app/src/main/java/com/ganjoor/android/ui/Settings.kt +++ b/app/src/main/java/com/ganjoor/android/ui/Settings.kt @@ -18,9 +18,6 @@ enum class ThemeMode(@StringRes val label: Int) { Dark(R.string.theme_dark), Sepia(R.string.theme_sepia), SepiaDark(R.string.theme_sepia_dark), - - /** Pure black, so OLED panels can switch the pixels off entirely. */ - Black(R.string.theme_black), } enum class ReadingFont(@StringRes val label: Int) { @@ -58,6 +55,8 @@ data class Prefs( val showSummaries: Boolean = false, val language: Language = Language.Fa, val offline: Boolean = false, + /** True black backgrounds, applied to whichever dark theme is in use. */ + val oled: Boolean = false, val poetSort: PoetSort = PoetSort.Default, ) @@ -74,6 +73,8 @@ class Settings(context: Context) { showSummaries = prefs.getBoolean("showSummaries", false), language = languageOrDefault(prefs.getString(KEY_LANGUAGE, null)), offline = prefs.getBoolean("offline", false), + // "Black" used to be a sixth theme; it is a flag on the dark ones now. + oled = prefs.getBoolean("oled", prefs.getString("theme", null) == "Black"), poetSort = enumOrDefault(prefs.getString("poetSort", null), PoetSort.Default), ) ) @@ -91,6 +92,7 @@ class Settings(context: Context) { putBoolean("showSummaries", p.showSummaries) putString(KEY_LANGUAGE, p.language.tag) putBoolean("offline", p.offline) + putBoolean("oled", p.oled) putString("poetSort", p.poetSort.name) } } diff --git a/app/src/main/java/com/ganjoor/android/ui/WordSheet.kt b/app/src/main/java/com/ganjoor/android/ui/WordSheet.kt index 35360c2..cdc3678 100644 --- a/app/src/main/java/com/ganjoor/android/ui/WordSheet.kt +++ b/app/src/main/java/com/ganjoor/android/ui/WordSheet.kt @@ -2,6 +2,7 @@ package com.ganjoor.android.ui 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.navigationBarsPadding import androidx.compose.foundation.layout.padding @@ -22,6 +23,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState 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.stringResource @@ -93,19 +95,29 @@ fun WordSheet(word: String, onDismiss: () -> Unit) { modifier = Modifier.fillMaxWidth(), ) if (sounds.isNotEmpty()) { - FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - sounds.forEach { sound -> - Column { - // IPA is Latin-script, so it reads left to right whatever the UI does - LeftToRight { - Text(sound.text, style = MaterialTheme.typography.bodyMedium) - } - if (sound.label.isNotBlank()) { + // One direction for the whole block, and one pronunciation per line. Flowing + // them side by side put Latin IPA and Urdu spelling in the same right-to-left + // run, which reordered the chips and left each label under someone else's value. + LeftToRight { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + sounds.forEach { sound -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { Text( - text = sound.label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = sound.text, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f, fill = false), ) + if (sound.label.isNotBlank()) { + Text( + text = sound.label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } } diff --git a/app/src/main/java/com/ganjoor/android/ui/theme/Theme.kt b/app/src/main/java/com/ganjoor/android/ui/theme/Theme.kt index 49bb34a..3be6baf 100644 --- a/app/src/main/java/com/ganjoor/android/ui/theme/Theme.kt +++ b/app/src/main/java/com/ganjoor/android/ui/theme/Theme.kt @@ -2,12 +2,14 @@ package com.ganjoor.android.ui.theme import android.app.Activity import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalView import androidx.core.graphics.drawable.toDrawable @@ -66,36 +68,6 @@ private val DarkScheme = darkColorScheme( outline = Color(0xFF899393), ) -/** - * True black, for OLED panels: a black pixel is an unlit pixel, so a night-time reading session - * costs noticeably less battery than the regular dark theme's dark grey. Surfaces step up in - * near-black greys so cards and sheets stay distinguishable without lighting the whole screen. - */ -private val BlackScheme = darkColorScheme( - primary = Color(0xFF80D4DA), - onPrimary = Color(0xFF00363A), - primaryContainer = Color(0xFF004F53), - onPrimaryContainer = Color(0xFF9CF1F6), - secondary = Color(0xFFFFB873), - onSecondary = Color(0xFF4A2800), - secondaryContainer = Color(0xFF693C00), - onSecondaryContainer = Color(0xFFFFDCBE), - background = Color(0xFF000000), - onBackground = Color(0xFFE3E3E3), - surface = Color(0xFF000000), - onSurface = Color(0xFFE3E3E3), - surfaceVariant = Color(0xFF1C1C1C), - onSurfaceVariant = Color(0xFFBDBDBD), - outline = Color(0xFF6E6E6E), - surfaceBright = Color(0xFF262626), - surfaceDim = Color(0xFF000000), - surfaceContainerLowest = Color(0xFF000000), - surfaceContainerLow = Color(0xFF0A0A0A), - surfaceContainer = Color(0xFF101010), - surfaceContainerHigh = Color(0xFF1A1A1A), - surfaceContainerHighest = Color(0xFF242424), -) - // Aged paper, for long reading sessions. private val SepiaScheme = lightColorScheme( primary = Color(0xFF7A4E24), @@ -153,28 +125,61 @@ private val SepiaDarkScheme = darkColorScheme( * so the gap between the window appearing and the first frame matches the theme instead of * flashing the platform's white. A static XML theme can't express sepia or OLED, hence this. */ -fun windowBackground(mode: ThemeMode, systemInDark: Boolean): Int = when (mode) { - ThemeMode.Light -> LightScheme - ThemeMode.Dark -> DarkScheme - ThemeMode.Sepia -> SepiaScheme - ThemeMode.SepiaDark -> SepiaDarkScheme - ThemeMode.Black -> BlackScheme - ThemeMode.System -> if (systemInDark) DarkScheme else LightScheme -}.background.toArgb() +fun windowBackground(mode: ThemeMode, systemInDark: Boolean, oled: Boolean): Int { + val dark = when (mode) { + ThemeMode.System -> systemInDark + ThemeMode.Light, ThemeMode.Sepia -> false + ThemeMode.Dark, ThemeMode.SepiaDark -> true + } + if (oled && dark) return Color.Black.toArgb() + return when (mode) { + ThemeMode.Light -> LightScheme + ThemeMode.Dark -> DarkScheme + ThemeMode.Sepia -> SepiaScheme + ThemeMode.SepiaDark -> SepiaDarkScheme + ThemeMode.System -> if (systemInDark) DarkScheme else LightScheme + }.background.toArgb() +} + +/** + * Pushes a dark scheme to true black for OLED panels, where an unlit pixel costs no power. + * + * Only the surfaces move, and they move towards black rather than being replaced by it, so sepia + * night keeps its warmth instead of turning into the grey dark theme. Text and accents are left + * exactly as they were. + */ +private fun ColorScheme.asOled(): ColorScheme = copy( + background = Color.Black, + surface = Color.Black, + surfaceDim = Color.Black, + surfaceContainerLowest = Color.Black, + surfaceContainerLow = lerp(surfaceContainerLow, Color.Black, 0.80f), + surfaceContainer = lerp(surfaceContainer, Color.Black, 0.74f), + surfaceContainerHigh = lerp(surfaceContainerHigh, Color.Black, 0.64f), + surfaceContainerHighest = lerp(surfaceContainerHighest, Color.Black, 0.54f), + surfaceBright = lerp(surfaceBright, Color.Black, 0.46f), + surfaceVariant = lerp(surfaceVariant, Color.Black, 0.52f), +) @Composable -fun GanjoorTheme(mode: ThemeMode, language: Language, content: @Composable () -> Unit) { +fun GanjoorTheme( + mode: ThemeMode, + language: Language, + oled: Boolean, + content: @Composable () -> Unit, +) { val dark = when (mode) { ThemeMode.System -> isSystemInDarkTheme() ThemeMode.Light, ThemeMode.Sepia -> false - ThemeMode.Dark, ThemeMode.SepiaDark, ThemeMode.Black -> true + ThemeMode.Dark, ThemeMode.SepiaDark -> true } - val scheme = when (mode) { + val chosen = when (mode) { ThemeMode.Sepia -> SepiaScheme ThemeMode.SepiaDark -> SepiaDarkScheme - ThemeMode.Black -> BlackScheme else -> if (dark) DarkScheme else LightScheme } + // Only dark schemes have anything to gain from it. + val scheme = if (oled && dark) chosen.asOled() else chosen val view = LocalView.current if (!view.isInEditMode) { diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000..2831bcb --- /dev/null +++ b/app/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 1dcd185..877c051 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -58,7 +58,6 @@ جست‌وجو به اینترنت نیاز دارد. حالت برون‌خط را خاموش کنید. بیشتر تمام - سیاه (OLED) درباره و پروانه‌ها گنجور برای اندروید نرم‌افزار آزاد است. شعرها، قلم‌ها و همهٔ کتابخانه‌هایی که این برنامه بر آن‌ها ساخته شده در زیر آمده‌اند؛ برای خواندن متن کامل پروانه روی هر مورد بزنید. برای این واژه مدخلی یافت نشد. @@ -69,4 +68,8 @@ ویکی‌واژهٔ اردو — اردو به اردو (CC BY-SA 3.0) واژه‌های نزدیک ویکی‌واژه — عربی به انگلیسی (CC BY-SA 3.0) + پس‌زمینهٔ سیاه (OLED) + روی پوسته‌های تاریک اعمال می‌شود و در نمایشگر OLED باتری کمتری می‌برد + پخش خوانش + توقف diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index d35509b..a748790 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -58,7 +58,6 @@ تلاش کے لیے انٹرنیٹ درکار ہے۔ آف لائن موڈ بند کریں۔ مزید مکمل - سیاہ (OLED) تعارف اور لائسنس گنجور فار اینڈرائیڈ آزاد سافٹ ویئر ہے۔ کلام، فونٹس اور تمام لائبریریاں ذیل میں درج ہیں؛ مکمل لائسنس پڑھنے کے لیے کسی اندراج پر ٹیپ کریں۔ اس لفظ کا کوئی اندراج نہیں ملا۔ @@ -69,4 +68,8 @@ اردو ویکی لغت — اردو سے اردو (CC BY-SA 3.0) ملتے جلتے الفاظ ویکی لغت — عربی سے انگریزی (CC BY-SA 3.0) + سیاہ پس منظر (OLED) + تاریک تھیمز پر لاگو ہوتا ہے؛ OLED اسکرین پر بیٹری بچاتا ہے + قرات سنیں + وقفہ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9d1d8fd..afc5b66 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -63,7 +63,6 @@ Search needs a connection. Turn off offline mode to use it. Load more Done - Black (OLED) About & licences Ganjoor for Android is free software. The poems, the fonts and every library it is built on are credited below; tap an entry to read its full licence. No entry for this word. @@ -74,4 +73,8 @@ Urdu Wiktionary — Urdu to Urdu (CC BY-SA 3.0) Similar words Wiktionary — Arabic to English (CC BY-SA 3.0) + Black backgrounds (OLED) + Applies to the dark themes; saves power on OLED screens + Play recitation + Pause