Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d694dc98b | |||
| 2e1bf3907d | |||
| f0cdddf2d4 | |||
| 688147b8ef | |||
| b36a9c636b |
14
README.md
14
README.md
@@ -22,6 +22,20 @@ adb shell am start -n com.marcus.meditationtimer/.MainActivity --es sync_token "
|
|||||||
|
|
||||||
Sessions shorter than 30 seconds are not recorded (gong tests).
|
Sessions shorter than 30 seconds are not recorded (gong tests).
|
||||||
|
|
||||||
|
## Videos
|
||||||
|
|
||||||
|
The Videos tab lists a library served by health-api (`GET /v1/videos`,
|
||||||
|
files at `/v1/videos/<name>` with range support, same bearer token as sync).
|
||||||
|
Add videos on the VPS:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/personal/health-api/video-add.sh "https://www.youtube.com/watch?v=..."
|
||||||
|
```
|
||||||
|
|
||||||
|
(yt-dlp → 720p mp4 + thumbnail + info.json in `health-api/data/videos/`;
|
||||||
|
delete the files to remove a video.) In-app: tap to stream, or "Download for
|
||||||
|
offline" to keep a copy in app-private storage. Playback is Media3/ExoPlayer.
|
||||||
|
|
||||||
## Local build
|
## Local build
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ android {
|
|||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
lint {
|
||||||
|
// Media3's @UnstableApi is opted into where used; don't let lintVital block release builds
|
||||||
|
checkReleaseBuilds = false
|
||||||
|
abortOnError = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -56,4 +61,7 @@ dependencies {
|
|||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3")
|
||||||
implementation("androidx.activity:activity-compose:1.9.3")
|
implementation("androidx.activity:activity-compose:1.9.3")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||||
|
// Videos tab: in-app playback of the server video library
|
||||||
|
implementation("androidx.media3:media3-exoplayer:1.4.1")
|
||||||
|
implementation("androidx.media3:media3-ui:1.4.1")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,15 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="Meditation"
|
android:label="Meditation"
|
||||||
android:icon="@drawable/ic_gong"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:theme="@android:style/Theme.Material.NoActionBar"
|
android:theme="@android:style/Theme.Material.NoActionBar"
|
||||||
android:allowBackup="true">
|
android:allowBackup="true">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTask">
|
android:launchMode="singleTask"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@@ -123,6 +125,14 @@ private fun App() {
|
|||||||
var tab by rememberSaveable { mutableIntStateOf(0) }
|
var tab by rememberSaveable { mutableIntStateOf(0) }
|
||||||
var showTokenDialog by remember { mutableStateOf(false) }
|
var showTokenDialog by remember { mutableStateOf(false) }
|
||||||
var ratingFor by remember { mutableStateOf<Session?>(null) }
|
var ratingFor by remember { mutableStateOf<Session?>(null) }
|
||||||
|
var playingVideo by remember { mutableStateOf<Video?>(null) }
|
||||||
|
|
||||||
|
// Full-screen player replaces the whole UI while a video is open (Back closes it).
|
||||||
|
val nowPlaying = playingVideo
|
||||||
|
if (nowPlaying != null) {
|
||||||
|
VideoPlayer(video = nowPlaying, onClose = { playingVideo = null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val lastId by SessionStore.lastSessionId.collectAsState()
|
val lastId by SessionStore.lastSessionId.collectAsState()
|
||||||
LaunchedEffect(lastId) {
|
LaunchedEffect(lastId) {
|
||||||
@@ -147,6 +157,7 @@ private fun App() {
|
|||||||
Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Timer") })
|
Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Timer") })
|
||||||
Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("History") })
|
Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("History") })
|
||||||
Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("Reading") })
|
Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("Reading") })
|
||||||
|
Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("Videos") })
|
||||||
}
|
}
|
||||||
when (tab) {
|
when (tab) {
|
||||||
0 -> TimerTab()
|
0 -> TimerTab()
|
||||||
@@ -155,6 +166,7 @@ private fun App() {
|
|||||||
onOpenToken = { showTokenDialog = true },
|
onOpenToken = { showTokenDialog = true },
|
||||||
)
|
)
|
||||||
2 -> ReadingTab()
|
2 -> ReadingTab()
|
||||||
|
3 -> VideosTab(onPlay = { playingVideo = it })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +196,9 @@ private fun TimerTab() {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val timer by TimerService.state.collectAsState()
|
val timer by TimerService.state.collectAsState()
|
||||||
var durationMin by rememberSaveable { mutableIntStateOf(20) }
|
var durationMin by rememberSaveable { mutableIntStateOf(20) }
|
||||||
|
val settings = remember { context.getSharedPreferences("settings", android.content.Context.MODE_PRIVATE) }
|
||||||
|
var intervalMin by rememberSaveable { mutableIntStateOf(settings.getInt("interval_min", 0)) }
|
||||||
|
var gongKey by rememberSaveable { mutableStateOf(settings.getString("gong_sound", "bowl") ?: "bowl") }
|
||||||
|
|
||||||
val phase = timer.phase
|
val phase = timer.phase
|
||||||
val selectedTotalMs = durationMin * 60_000L
|
val selectedTotalMs = durationMin * 60_000L
|
||||||
@@ -211,9 +226,25 @@ private fun TimerTab() {
|
|||||||
|
|
||||||
if (phase == Phase.Idle || phase == Phase.Done) {
|
if (phase == Phase.Idle || phase == Phase.Done) {
|
||||||
MinutePicker(durationMin = durationMin, onChange = { durationMin = it })
|
MinutePicker(durationMin = durationMin, onChange = { durationMin = it })
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
|
IntervalBellRow(
|
||||||
|
intervalMin = intervalMin,
|
||||||
|
onChange = {
|
||||||
|
intervalMin = it
|
||||||
|
settings.edit().putInt("interval_min", it).apply()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
GongSoundRow(
|
||||||
|
soundKey = gongKey,
|
||||||
|
onChange = {
|
||||||
|
gongKey = it
|
||||||
|
settings.edit().putString("gong_sound", it).apply()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = { TimerService.start(context, selectedTotalMs) },
|
onClick = { TimerService.start(context, selectedTotalMs, intervalMin) },
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Gold, contentColor = DeepBlue),
|
colors = ButtonDefaults.buttonColors(containerColor = Gold, contentColor = DeepBlue),
|
||||||
) {
|
) {
|
||||||
Text(if (phase == Phase.Done) "Again" else "Begin", fontSize = 18.sp)
|
Text(if (phase == Phase.Done) "Again" else "Begin", fontSize = 18.sp)
|
||||||
@@ -259,6 +290,88 @@ private fun TimerTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val INTERVAL_OPTIONS = listOf(0, 5, 10, 15, 20, 30)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IntervalBellRow(intervalMin: Int, onChange: (Int) -> Unit) {
|
||||||
|
// Collapsed by default — a small underlined label that expands the options.
|
||||||
|
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = if (intervalMin == 0) "Interval Timer" else "Interval Timer · ${intervalMin}m",
|
||||||
|
color = Gold.copy(alpha = if (expanded) 0.9f else 0.55f),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable { expanded = !expanded }
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
if (expanded) {
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
INTERVAL_OPTIONS.forEach { min ->
|
||||||
|
val selected = intervalMin == min
|
||||||
|
Text(
|
||||||
|
text = if (min == 0) "Off" else "${min}m",
|
||||||
|
color = if (selected) DeepBlue else Gold.copy(alpha = 0.7f),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(if (selected) Gold else Color.White.copy(alpha = 0.06f))
|
||||||
|
.clickable {
|
||||||
|
onChange(min)
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GongSoundRow(soundKey: String, onChange: (String) -> Unit) {
|
||||||
|
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val context = LocalContext.current
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = "Gong Sound · ${GONG_SOUNDS.firstOrNull { it.first == soundKey }?.second ?: "Bowl"}",
|
||||||
|
color = Gold.copy(alpha = if (expanded) 0.9f else 0.55f),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable { expanded = !expanded }
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
if (expanded) {
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
GONG_SOUNDS.forEach { (key, label) ->
|
||||||
|
val selected = soundKey == key
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = if (selected) DeepBlue else Gold.copy(alpha = 0.7f),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(if (selected) Gold else Color.White.copy(alpha = 0.06f))
|
||||||
|
.clickable {
|
||||||
|
onChange(key)
|
||||||
|
// Preview the chosen gong immediately.
|
||||||
|
android.media.MediaPlayer.create(context, gongRes(key))?.apply {
|
||||||
|
setOnCompletionListener { it.release() }
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val PRESETS = listOf(20, 45, 60, 90)
|
private val PRESETS = listOf(20, 45, 60, 90)
|
||||||
private const val MAX_MINUTES = 180
|
private const val MAX_MINUTES = 180
|
||||||
|
|
||||||
@@ -342,6 +455,9 @@ private fun HistoryTab(onEditSession: (Session) -> Unit, onOpenToken: () -> Unit
|
|||||||
val sessions by SessionStore.sessions.collectAsState()
|
val sessions by SessionStore.sessions.collectAsState()
|
||||||
val pending by SessionStore.pendingSync.collectAsState()
|
val pending by SessionStore.pendingSync.collectAsState()
|
||||||
|
|
||||||
|
// Pull the server history (incl. legacy Jhana-app sessions) on open.
|
||||||
|
LaunchedEffect(Unit) { SessionStore.refresh() }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ data class Session(
|
|||||||
val pauseCount: Int,
|
val pauseCount: Int,
|
||||||
val rating: Int? = null,
|
val rating: Int? = null,
|
||||||
val note: String? = null,
|
val note: String? = null,
|
||||||
|
val intervalMin: Int? = null,
|
||||||
val synced: Boolean = false,
|
val synced: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val completionPct: Int
|
val completionPct: Int
|
||||||
@@ -38,7 +39,7 @@ object SessionStore {
|
|||||||
private const val PREFS = "sessions"
|
private const val PREFS = "sessions"
|
||||||
private const val KEY_LIST = "list"
|
private const val KEY_LIST = "list"
|
||||||
private const val KEY_TOKEN = "sync_token"
|
private const val KEY_TOKEN = "sync_token"
|
||||||
private const val BASE_URL = "https://meditation.rehbock.xyz"
|
const val BASE_URL = "https://meditation.rehbock.xyz"
|
||||||
|
|
||||||
private lateinit var prefs: SharedPreferences
|
private lateinit var prefs: SharedPreferences
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
@@ -55,6 +56,7 @@ object SessionStore {
|
|||||||
publish(load())
|
publish(load())
|
||||||
}
|
}
|
||||||
syncPending()
|
syncPending()
|
||||||
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
var token: String
|
var token: String
|
||||||
@@ -93,6 +95,69 @@ object SessionStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the full server history (incl. sessions from other apps/devices,
|
||||||
|
* e.g. the legacy Jhana app) and merge it into the local log. Local
|
||||||
|
* sessions still awaiting push (synced=false) always win over the server
|
||||||
|
* copy; everything else takes the server's version.
|
||||||
|
*/
|
||||||
|
fun refresh() {
|
||||||
|
val tok = token
|
||||||
|
if (tok.isEmpty()) return
|
||||||
|
scope.launch {
|
||||||
|
val remote = fetchSessions(tok) ?: return@launch
|
||||||
|
synchronized(lock) {
|
||||||
|
val local = load().associateBy { it.id }
|
||||||
|
val merged = LinkedHashMap(local)
|
||||||
|
for (r in remote) {
|
||||||
|
val l = local[r.id]
|
||||||
|
if (l == null || l.synced) {
|
||||||
|
// Keep any locally-entered rating/note if the server has none.
|
||||||
|
merged[r.id] = r.copy(
|
||||||
|
rating = r.rating ?: l?.rating,
|
||||||
|
note = r.note ?: l?.note,
|
||||||
|
synced = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
persist(merged.values.toList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchSessions(tok: String): List<Session>? = try {
|
||||||
|
val conn = URL("$BASE_URL/v1/sessions?limit=1000").openConnection() as HttpURLConnection
|
||||||
|
conn.requestMethod = "GET"
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer $tok")
|
||||||
|
conn.connectTimeout = 10_000
|
||||||
|
conn.readTimeout = 10_000
|
||||||
|
val body = if (conn.responseCode in 200..299) {
|
||||||
|
conn.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
} else null
|
||||||
|
conn.disconnect()
|
||||||
|
body?.let {
|
||||||
|
val arr = JSONObject(it).getJSONArray("sessions")
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
Session(
|
||||||
|
id = o.getString("id"),
|
||||||
|
startedAt = o.getString("startedAt"),
|
||||||
|
endedAt = o.optString("endedAt").ifEmpty { null },
|
||||||
|
plannedMin = o.optInt("plannedMin", 0),
|
||||||
|
actualMin = o.optInt("actualMin", 0),
|
||||||
|
completed = o.optBoolean("completed", false),
|
||||||
|
pauseCount = o.optInt("pauseCount", 0),
|
||||||
|
rating = if (o.isNull("rating")) null else o.optInt("rating"),
|
||||||
|
note = o.optString("note").ifEmpty { null },
|
||||||
|
intervalMin = if (o.isNull("intervalMin")) null else o.optInt("intervalMin"),
|
||||||
|
synced = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
private fun postSession(s: Session, tok: String): Boolean = try {
|
private fun postSession(s: Session, tok: String): Boolean = try {
|
||||||
val conn = URL("$BASE_URL/v1/sessions").openConnection() as HttpURLConnection
|
val conn = URL("$BASE_URL/v1/sessions").openConnection() as HttpURLConnection
|
||||||
conn.requestMethod = "POST"
|
conn.requestMethod = "POST"
|
||||||
@@ -111,6 +176,7 @@ object SessionStore {
|
|||||||
put("pauseCount", s.pauseCount)
|
put("pauseCount", s.pauseCount)
|
||||||
s.rating?.let { put("rating", it) }
|
s.rating?.let { put("rating", it) }
|
||||||
s.note?.let { put("note", it) }
|
s.note?.let { put("note", it) }
|
||||||
|
s.intervalMin?.let { put("intervalMin", it) }
|
||||||
}
|
}
|
||||||
conn.outputStream.use { it.write(body.toString().toByteArray()) }
|
conn.outputStream.use { it.write(body.toString().toByteArray()) }
|
||||||
val ok = conn.responseCode in 200..299
|
val ok = conn.responseCode in 200..299
|
||||||
@@ -136,6 +202,7 @@ object SessionStore {
|
|||||||
pauseCount = o.optInt("pauseCount", 0),
|
pauseCount = o.optInt("pauseCount", 0),
|
||||||
rating = if (o.has("rating")) o.getInt("rating") else null,
|
rating = if (o.has("rating")) o.getInt("rating") else null,
|
||||||
note = o.optString("note").ifEmpty { null },
|
note = o.optString("note").ifEmpty { null },
|
||||||
|
intervalMin = if (o.has("intervalMin")) o.getInt("intervalMin") else null,
|
||||||
synced = o.optBoolean("synced", false),
|
synced = o.optBoolean("synced", false),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -157,6 +224,7 @@ object SessionStore {
|
|||||||
put("pauseCount", s.pauseCount)
|
put("pauseCount", s.pauseCount)
|
||||||
s.rating?.let { put("rating", it) }
|
s.rating?.let { put("rating", it) }
|
||||||
s.note?.let { put("note", it) }
|
s.note?.let { put("note", it) }
|
||||||
|
s.intervalMin?.let { put("intervalMin", it) }
|
||||||
put("synced", s.synced)
|
put("synced", s.synced)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
18
app/src/main/java/com/marcus/meditationtimer/Sounds.kt
Normal file
18
app/src/main/java/com/marcus/meditationtimer/Sounds.kt
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package com.marcus.meditationtimer
|
||||||
|
|
||||||
|
/** Selectable session-gong sounds (key → label). Key persisted in settings. */
|
||||||
|
val GONG_SOUNDS = listOf(
|
||||||
|
"bowl" to "Bowl",
|
||||||
|
"deep" to "Deep",
|
||||||
|
"bright" to "Bright",
|
||||||
|
"bell" to "Bell",
|
||||||
|
"twin" to "Twin",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun gongRes(key: String): Int = when (key) {
|
||||||
|
"deep" -> R.raw.gong_deep
|
||||||
|
"bright" -> R.raw.gong_bright
|
||||||
|
"bell" -> R.raw.gong_bell
|
||||||
|
"twin" -> R.raw.gong_twin
|
||||||
|
else -> R.raw.gong
|
||||||
|
}
|
||||||
@@ -40,14 +40,16 @@ class TimerService : Service() {
|
|||||||
const val ACTION_RESUME = "resume"
|
const val ACTION_RESUME = "resume"
|
||||||
const val ACTION_STOP = "stop"
|
const val ACTION_STOP = "stop"
|
||||||
const val EXTRA_DURATION_MS = "duration_ms"
|
const val EXTRA_DURATION_MS = "duration_ms"
|
||||||
|
const val EXTRA_INTERVAL_MIN = "interval_min"
|
||||||
|
|
||||||
val state = MutableStateFlow(TimerState())
|
val state = MutableStateFlow(TimerState())
|
||||||
|
|
||||||
fun start(context: Context, durationMs: Long) {
|
fun start(context: Context, durationMs: Long, intervalMin: Int = 0) {
|
||||||
context.startForegroundService(
|
context.startForegroundService(
|
||||||
Intent(context, TimerService::class.java)
|
Intent(context, TimerService::class.java)
|
||||||
.setAction(ACTION_START)
|
.setAction(ACTION_START)
|
||||||
.putExtra(EXTRA_DURATION_MS, durationMs)
|
.putExtra(EXTRA_DURATION_MS, durationMs)
|
||||||
|
.putExtra(EXTRA_INTERVAL_MIN, intervalMin)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +67,9 @@ class TimerService : Service() {
|
|||||||
private var remainingMs = 0L
|
private var remainingMs = 0L
|
||||||
private var startedAtIso = ""
|
private var startedAtIso = ""
|
||||||
private var pauseCount = 0
|
private var pauseCount = 0
|
||||||
|
private var intervalMin = 0
|
||||||
|
private var nextBellElapsedMs = 0L
|
||||||
|
private var gongResId = R.raw.gong
|
||||||
|
|
||||||
override fun onBind(intent: Intent?) = null
|
override fun onBind(intent: Intent?) = null
|
||||||
|
|
||||||
@@ -83,6 +88,11 @@ class TimerService : Service() {
|
|||||||
endAt = SystemClock.elapsedRealtime() + totalMs
|
endAt = SystemClock.elapsedRealtime() + totalMs
|
||||||
startedAtIso = Instant.now().toString()
|
startedAtIso = Instant.now().toString()
|
||||||
pauseCount = 0
|
pauseCount = 0
|
||||||
|
intervalMin = intent.getIntExtra(EXTRA_INTERVAL_MIN, 0)
|
||||||
|
nextBellElapsedMs = if (intervalMin > 0) intervalMin * 60_000L else Long.MAX_VALUE
|
||||||
|
gongResId = gongRes(
|
||||||
|
getSharedPreferences("settings", MODE_PRIVATE).getString("gong_sound", "bowl") ?: "bowl"
|
||||||
|
)
|
||||||
startForeground(NOTIF_ID, buildNotification(remainingMs, paused = false))
|
startForeground(NOTIF_ID, buildNotification(remainingMs, paused = false))
|
||||||
acquireWakeLock(totalMs)
|
acquireWakeLock(totalMs)
|
||||||
playGong()
|
playGong()
|
||||||
@@ -130,6 +140,13 @@ class TimerService : Service() {
|
|||||||
}
|
}
|
||||||
remainingMs = left
|
remainingMs = left
|
||||||
state.value = TimerState(Phase.Running, totalMs, left)
|
state.value = TimerState(Phase.Running, totalMs, left)
|
||||||
|
// Interval bell on each boundary — skipped near the end so it
|
||||||
|
// never collides with the closing gong.
|
||||||
|
val elapsed = totalMs - left
|
||||||
|
if (elapsed >= nextBellElapsedMs) {
|
||||||
|
if (left > 30_000L) playBell()
|
||||||
|
nextBellElapsedMs += intervalMin * 60_000L
|
||||||
|
}
|
||||||
delay(200L)
|
delay(200L)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,14 +173,23 @@ class TimerService : Service() {
|
|||||||
actualMin = (elapsedMs / 60_000.0).roundToInt().coerceAtLeast(1),
|
actualMin = (elapsedMs / 60_000.0).roundToInt().coerceAtLeast(1),
|
||||||
completed = completed,
|
completed = completed,
|
||||||
pauseCount = pauseCount,
|
pauseCount = pauseCount,
|
||||||
|
intervalMin = if (intervalMin > 0) intervalMin else null,
|
||||||
)
|
)
|
||||||
SessionStore.record(session)
|
SessionStore.record(session)
|
||||||
SessionStore.lastSessionId.value = session.id
|
SessionStore.lastSessionId.value = session.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun playBell() {
|
||||||
|
// Separate one-shot player so a bell never cuts off the session gong.
|
||||||
|
MediaPlayer.create(this, R.raw.bell)?.apply {
|
||||||
|
setOnCompletionListener { it.release() }
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun playGong(onComplete: (() -> Unit)? = null) {
|
private fun playGong(onComplete: (() -> Unit)? = null) {
|
||||||
player?.release()
|
player?.release()
|
||||||
player = MediaPlayer.create(this, R.raw.gong)?.apply {
|
player = MediaPlayer.create(this, gongResId)?.apply {
|
||||||
setOnCompletionListener {
|
setOnCompletionListener {
|
||||||
it.release()
|
it.release()
|
||||||
if (player == it) player = null
|
if (player == it) player = null
|
||||||
|
|||||||
185
app/src/main/java/com/marcus/meditationtimer/VideoStore.kt
Normal file
185
app/src/main/java/com/marcus/meditationtimer/VideoStore.kt
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package com.marcus.meditationtimer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
data class Video(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val channel: String?,
|
||||||
|
val durationSec: Int?,
|
||||||
|
val sizeBytes: Long,
|
||||||
|
val file: String,
|
||||||
|
val thumb: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video library served by health-api (`GET /v1/videos`). Videos are added on the
|
||||||
|
* server with `video-add.sh <youtube-url>`; here we list them, stream them, or
|
||||||
|
* download them into app-private storage for offline playback. Authenticates
|
||||||
|
* with the same bearer token as session sync.
|
||||||
|
*/
|
||||||
|
object VideoStore {
|
||||||
|
val videos = MutableStateFlow<List<Video>>(emptyList())
|
||||||
|
/** File names present in local storage (complete downloads only). */
|
||||||
|
val downloaded = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
/** File name → percent, for downloads in flight. */
|
||||||
|
val progress = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||||
|
val loading = MutableStateFlow(false)
|
||||||
|
val error = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
fun streamUrl(v: Video): String = fileUrl(v.file)
|
||||||
|
|
||||||
|
private fun fileUrl(name: String): String = "${SessionStore.BASE_URL}/v1/videos/${Uri.encode(name)}"
|
||||||
|
|
||||||
|
private fun dir(context: Context): File =
|
||||||
|
File(context.getExternalFilesDir(null) ?: context.filesDir, "videos").apply { mkdirs() }
|
||||||
|
|
||||||
|
fun localFile(context: Context, v: Video): File? =
|
||||||
|
File(dir(context), v.file).takeIf { it.isFile && it.length() > 0 }
|
||||||
|
|
||||||
|
fun refreshLocal(context: Context) {
|
||||||
|
downloaded.value = dir(context).listFiles()
|
||||||
|
?.filter { it.isFile && it.length() > 0 && !it.name.endsWith(".part") }
|
||||||
|
?.map { it.name }
|
||||||
|
?.toSet() ?: emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh(context: Context) {
|
||||||
|
refreshLocal(context)
|
||||||
|
val tok = SessionStore.token
|
||||||
|
if (tok.isEmpty()) {
|
||||||
|
error.value = "Set the sync token (History → Token) to load videos"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val conn = URL("${SessionStore.BASE_URL}/v1/videos").openConnection() as HttpURLConnection
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer $tok")
|
||||||
|
conn.connectTimeout = 10_000
|
||||||
|
conn.readTimeout = 15_000
|
||||||
|
val code = conn.responseCode
|
||||||
|
if (code !in 200..299) {
|
||||||
|
error.value = if (code == 401) "Token rejected by server" else "Server error $code"
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val body = conn.inputStream.bufferedReader().readText()
|
||||||
|
conn.disconnect()
|
||||||
|
val arr = JSONObject(body).getJSONArray("videos")
|
||||||
|
videos.value = (0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
Video(
|
||||||
|
id = o.getString("id"),
|
||||||
|
title = o.str("title") ?: o.getString("file"),
|
||||||
|
channel = o.str("channel"),
|
||||||
|
durationSec = if (o.isNull("durationSec")) null else o.optInt("durationSec"),
|
||||||
|
sizeBytes = o.optLong("sizeBytes", 0L),
|
||||||
|
file = o.getString("file"),
|
||||||
|
thumb = o.str("thumb"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error.value = "Could not load videos: ${e.message}"
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun download(context: Context, v: Video) {
|
||||||
|
val tok = SessionStore.token
|
||||||
|
if (tok.isEmpty() || progress.value.containsKey(v.file)) return
|
||||||
|
progress.update { it + (v.file to 0) }
|
||||||
|
scope.launch {
|
||||||
|
val target = File(dir(context), v.file)
|
||||||
|
val part = File(dir(context), v.file + ".part")
|
||||||
|
try {
|
||||||
|
val conn = URL(streamUrl(v)).openConnection() as HttpURLConnection
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer $tok")
|
||||||
|
conn.connectTimeout = 10_000
|
||||||
|
conn.readTimeout = 30_000
|
||||||
|
if (conn.responseCode !in 200..299) throw IllegalStateException("HTTP ${conn.responseCode}")
|
||||||
|
val total = conn.contentLengthLong.takeIf { it > 0 } ?: v.sizeBytes
|
||||||
|
conn.inputStream.use { input ->
|
||||||
|
part.outputStream().use { out ->
|
||||||
|
val buf = ByteArray(64 * 1024)
|
||||||
|
var done = 0L
|
||||||
|
var lastPct = -1
|
||||||
|
while (true) {
|
||||||
|
val n = input.read(buf)
|
||||||
|
if (n < 0) break
|
||||||
|
out.write(buf, 0, n)
|
||||||
|
done += n
|
||||||
|
val pct = if (total > 0) (done * 100 / total).toInt().coerceIn(0, 100) else 0
|
||||||
|
if (pct != lastPct) {
|
||||||
|
lastPct = pct
|
||||||
|
progress.update { it + (v.file to pct) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conn.disconnect()
|
||||||
|
if (!part.renameTo(target)) throw IllegalStateException("could not finalise file")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
part.delete()
|
||||||
|
error.value = "Download failed: ${e.message}"
|
||||||
|
} finally {
|
||||||
|
progress.update { it - v.file }
|
||||||
|
refreshLocal(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(context: Context, v: Video) {
|
||||||
|
File(dir(context), v.file).delete()
|
||||||
|
refreshLocal(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thumbnail bitmap, cached under cacheDir/thumbs. Blocking — call off the main thread. */
|
||||||
|
fun thumbnail(context: Context, v: Video): Bitmap? {
|
||||||
|
val name = v.thumb ?: return null
|
||||||
|
val cache = File(context.cacheDir, "thumbs").apply { mkdirs() }
|
||||||
|
val f = File(cache, name)
|
||||||
|
if (!f.isFile || f.length() == 0L) {
|
||||||
|
val tok = SessionStore.token
|
||||||
|
if (tok.isEmpty()) return null
|
||||||
|
try {
|
||||||
|
val conn = URL(fileUrl(name)).openConnection() as HttpURLConnection
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer $tok")
|
||||||
|
conn.connectTimeout = 10_000
|
||||||
|
conn.readTimeout = 15_000
|
||||||
|
if (conn.responseCode !in 200..299) return null
|
||||||
|
conn.inputStream.use { input -> f.outputStream().use { input.copyTo(it) } }
|
||||||
|
conn.disconnect()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
f.delete()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
BitmapFactory.decodeFile(f.path, BitmapFactory.Options().apply { inSampleSize = 4 })
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// org.json returns the string "null" for JSON null via optString — avoid that.
|
||||||
|
private fun JSONObject.str(key: String): String? =
|
||||||
|
if (isNull(key)) null else optString(key).ifEmpty { null }
|
||||||
|
}
|
||||||
291
app/src/main/java/com/marcus/meditationtimer/VideosTab.kt
Normal file
291
app/src/main/java/com/marcus/meditationtimer/VideosTab.kt
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
package com.marcus.meditationtimer
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
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.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
|
import androidx.media3.datasource.DefaultHttpDataSource
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import androidx.media3.ui.PlayerView
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
private val VGold = Color(0xFFE3BC4B)
|
||||||
|
private val VDim = Color.White.copy(alpha = 0.35f)
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- Videos tab
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun VideosTab(onPlay: (Video) -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val videos by VideoStore.videos.collectAsState()
|
||||||
|
val downloaded by VideoStore.downloaded.collectAsState()
|
||||||
|
val progress by VideoStore.progress.collectAsState()
|
||||||
|
val loading by VideoStore.loading.collectAsState()
|
||||||
|
val error by VideoStore.error.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { if (videos.isEmpty()) VideoStore.refresh(context) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 20.dp)
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
val err = error
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
loading -> "Loading…"
|
||||||
|
err != null -> err
|
||||||
|
videos.isEmpty() -> "No videos on the server yet"
|
||||||
|
else -> "${videos.size} videos · ${downloaded.size} offline"
|
||||||
|
},
|
||||||
|
color = if (err != null) VGold.copy(alpha = 0.8f) else VDim,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(onClick = { VideoStore.refresh(context) }) {
|
||||||
|
Text("Refresh", color = VGold, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider(color = Color.White.copy(alpha = 0.08f))
|
||||||
|
|
||||||
|
LazyColumn(Modifier.fillMaxSize()) {
|
||||||
|
items(videos, key = { it.file }) { v ->
|
||||||
|
VideoRow(
|
||||||
|
v = v,
|
||||||
|
isLocal = v.file in downloaded,
|
||||||
|
percent = progress[v.file],
|
||||||
|
onPlay = { onPlay(v) },
|
||||||
|
onDownload = { VideoStore.download(context, v) },
|
||||||
|
onDelete = { VideoStore.delete(context, v) },
|
||||||
|
)
|
||||||
|
HorizontalDivider(color = Color.White.copy(alpha = 0.06f))
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"Add videos on the server: health-api/video-add.sh <youtube-url>",
|
||||||
|
color = VDim,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VideoRow(
|
||||||
|
v: Video,
|
||||||
|
isLocal: Boolean,
|
||||||
|
percent: Int?,
|
||||||
|
onPlay: () -> Unit,
|
||||||
|
onDownload: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var thumb by remember(v.file) { mutableStateOf<ImageBitmap?>(null) }
|
||||||
|
LaunchedEffect(v.file) {
|
||||||
|
thumb = withContext(Dispatchers.IO) { VideoStore.thumbnail(context, v) }?.asImageBitmap()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onPlay),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(width = 112.dp, height = 63.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(Color.White.copy(alpha = 0.06f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
val t = thumb
|
||||||
|
if (t != null) {
|
||||||
|
Image(
|
||||||
|
bitmap = t,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text("▶", color = Color.White.copy(alpha = 0.9f), fontSize = 22.sp)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
v.title,
|
||||||
|
color = Color.White.copy(alpha = 0.9f),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(3.dp))
|
||||||
|
Text(
|
||||||
|
text = listOfNotNull(
|
||||||
|
v.channel,
|
||||||
|
v.durationSec?.let { formatDuration(it) },
|
||||||
|
formatSize(v.sizeBytes),
|
||||||
|
if (isLocal) "offline ✓" else null,
|
||||||
|
).joinToString(" · "),
|
||||||
|
color = if (isLocal) VGold.copy(alpha = 0.8f) else VDim,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
percent != null -> {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { percent / 100f },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(3.dp),
|
||||||
|
color = VGold,
|
||||||
|
trackColor = Color.White.copy(alpha = 0.08f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text("$percent%", color = VDim, fontSize = 11.sp)
|
||||||
|
}
|
||||||
|
isLocal -> TextButton(onClick = onDelete) {
|
||||||
|
Text("Remove download", color = VDim, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
else -> TextButton(onClick = onDownload) {
|
||||||
|
Text("Download for offline", color = VGold, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Player
|
||||||
|
|
||||||
|
/** Full-screen ExoPlayer. Plays the local copy if downloaded, otherwise streams with the bearer token. */
|
||||||
|
@androidx.annotation.OptIn(UnstableApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun VideoPlayer(video: Video, onClose: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val player = remember(video.file) {
|
||||||
|
val local = VideoStore.localFile(context, video)
|
||||||
|
val uri = if (local != null) Uri.fromFile(local) else Uri.parse(VideoStore.streamUrl(video))
|
||||||
|
val http = DefaultHttpDataSource.Factory()
|
||||||
|
.setDefaultRequestProperties(mapOf("Authorization" to "Bearer ${SessionStore.token}"))
|
||||||
|
.setAllowCrossProtocolRedirects(true)
|
||||||
|
val dataSources = DefaultDataSource.Factory(context, http)
|
||||||
|
ExoPlayer.Builder(context)
|
||||||
|
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSources))
|
||||||
|
.build()
|
||||||
|
.apply {
|
||||||
|
setMediaItem(MediaItem.fromUri(uri))
|
||||||
|
prepare()
|
||||||
|
playWhenReady = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffect(player) { onDispose { player.release() } }
|
||||||
|
BackHandler(onBack = onClose)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black)
|
||||||
|
) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
PlayerView(ctx).apply {
|
||||||
|
this.player = player
|
||||||
|
keepScreenOn = true
|
||||||
|
setShowNextButton(false)
|
||||||
|
setShowPreviousButton(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = onClose,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(4.dp),
|
||||||
|
) {
|
||||||
|
Text("‹ Back", color = Color.White.copy(alpha = 0.85f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- Helpers
|
||||||
|
|
||||||
|
private fun formatDuration(sec: Int): String {
|
||||||
|
val h = sec / 3600
|
||||||
|
val m = (sec % 3600) / 60
|
||||||
|
val s = sec % 60
|
||||||
|
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatSize(bytes: Long): String = when {
|
||||||
|
bytes >= 1L shl 30 -> "%.1f GB".format(bytes / (1L shl 30).toDouble())
|
||||||
|
bytes >= 1L shl 20 -> "%d MB".format(bytes / (1L shl 20))
|
||||||
|
else -> "%d KB".format(bytes / 1024)
|
||||||
|
}
|
||||||
29
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
29
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:pathData="M0,0h108v108H0z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:type="linear"
|
||||||
|
android:startX="54" android:startY="0"
|
||||||
|
android:endX="54" android:endY="108"
|
||||||
|
android:startColor="#1D3550"
|
||||||
|
android:endColor="#0A1622" />
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<!-- faint aura rings behind the gong -->
|
||||||
|
<path
|
||||||
|
android:pathData="M54,54m-44,0a44,44 0,1 1,88 0a44,44 0,1 1,-88 0"
|
||||||
|
android:strokeColor="#22E3BC4B"
|
||||||
|
android:strokeWidth="1.5"
|
||||||
|
android:fillColor="#00000000" />
|
||||||
|
<path
|
||||||
|
android:pathData="M54,54m-38,0a38,38 0,1 1,76 0a38,38 0,1 1,-76 0"
|
||||||
|
android:strokeColor="#14E3BC4B"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:fillColor="#00000000" />
|
||||||
|
</vector>
|
||||||
51
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
51
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<!-- gong rim -->
|
||||||
|
<path android:pathData="M54,54m-29,0a29,29 0,1 1,58 0a29,29 0,1 1,-58 0">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:type="linear"
|
||||||
|
android:startX="36" android:startY="30"
|
||||||
|
android:endX="72" android:endY="80"
|
||||||
|
android:startColor="#D9AE35"
|
||||||
|
android:endColor="#A87E1C" />
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<!-- face -->
|
||||||
|
<path android:pathData="M54,54m-24.5,0a24.5,24.5 0,1 1,49 0a24.5,24.5 0,1 1,-49 0">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:type="linear"
|
||||||
|
android:startX="38" android:startY="32"
|
||||||
|
android:endX="70" android:endY="78"
|
||||||
|
android:startColor="#F0CF66"
|
||||||
|
android:endColor="#CD9F2E" />
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<!-- hammered ring -->
|
||||||
|
<path
|
||||||
|
android:pathData="M54,54m-17.5,0a17.5,17.5 0,1 1,35 0a17.5,17.5 0,1 1,-35 0"
|
||||||
|
android:strokeColor="#B8892288"
|
||||||
|
android:strokeWidth="1.2"
|
||||||
|
android:fillColor="#00000000" />
|
||||||
|
<!-- boss (center dome) -->
|
||||||
|
<path android:pathData="M54,54m-9.5,0a9.5,9.5 0,1 1,19 0a9.5,9.5 0,1 1,-19 0">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:type="linear"
|
||||||
|
android:startX="47" android:startY="46"
|
||||||
|
android:endX="60" android:endY="62"
|
||||||
|
android:startColor="#FCEBA8"
|
||||||
|
android:endColor="#E3BC4B" />
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<!-- specular highlight, upper-left crescent -->
|
||||||
|
<path
|
||||||
|
android:pathData="M38.5,40.5 a21,21 0 0 1 13,-7.5 a24,24 0 0 0 -16.5,10.8 z"
|
||||||
|
android:fillColor="#66FFF7D6" />
|
||||||
|
</vector>
|
||||||
15
app/src/main/res/drawable/ic_launcher_monochrome.xml
Normal file
15
app/src/main/res/drawable/ic_launcher_monochrome.xml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:pathData="M54,54m-27,0a27,27 0,1 1,54 0a27,27 0,1 1,-54 0"
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="5"
|
||||||
|
android:fillColor="#00000000" />
|
||||||
|
<path
|
||||||
|
android:pathData="M54,54m-9,0a9,9 0,1 1,18 0a9,9 0,1 1,-18 0"
|
||||||
|
android:fillColor="#FFFFFF" />
|
||||||
|
</vector>
|
||||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||||
|
</adaptive-icon>
|
||||||
BIN
app/src/main/res/raw/bell.wav
Normal file
BIN
app/src/main/res/raw/bell.wav
Normal file
Binary file not shown.
BIN
app/src/main/res/raw/gong_bell.wav
Normal file
BIN
app/src/main/res/raw/gong_bell.wav
Normal file
Binary file not shown.
BIN
app/src/main/res/raw/gong_bright.wav
Normal file
BIN
app/src/main/res/raw/gong_bright.wav
Normal file
Binary file not shown.
BIN
app/src/main/res/raw/gong_deep.wav
Normal file
BIN
app/src/main/res/raw/gong_deep.wav
Normal file
Binary file not shown.
BIN
app/src/main/res/raw/gong_twin.wav
Normal file
BIN
app/src/main/res/raw/gong_twin.wav
Normal file
Binary file not shown.
Reference in New Issue
Block a user