5 Commits
v1.5 ... v1.10

Author SHA1 Message Date
0d694dc98b Videos tab: stream/download the server video library, in-app ExoPlayer
All checks were successful
Build & Release APK / build (push) Successful in 12m15s
- New Videos tab lists videos served by health-api (GET /v1/videos), with
  thumbnails, duration/size, "Download for offline" into app-private storage
  (progress bar, remove), and tap-to-play.
- Full-screen Media3/ExoPlayer: plays the local copy if downloaded, otherwise
  streams /v1/videos/<file> with the existing sync bearer token (range requests).
- Activity handles rotation itself (configChanges) so playback survives it.
- Server side: health-api /v1/videos + video-add.sh (yt-dlp) — see README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pvxTrcfAiFZJcS7Jve2Au
2026-09-01 20:02:09 +10:00
2e1bf3907d Collapse interval bell behind underlined dropdown; 5 selectable gong sounds with preview
All checks were successful
Build & Release APK / build (push) Successful in 6m11s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 23:38:33 -07:00
f0cdddf2d4 Interval bell setting: Off/5/10/15/20/30 min, distinct soft bell, intervalMin synced
All checks were successful
Build & Release APK / build (push) Successful in 2m54s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 23:08:05 -07:00
688147b8ef Pull server session history into local log (merge, legacy Jhana sessions included)
All checks were successful
Build & Release APK / build (push) Successful in 6m49s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 21:58:35 -07:00
b36a9c636b Prettier adaptive launcher icon: gradient gong with boss, aura, themed-icon monochrome
All checks were successful
Build & Release APK / build (push) Successful in 6m39s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 01:38:10 -07:00
18 changed files with 835 additions and 7 deletions

View File

@@ -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
``` ```

View File

@@ -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")
} }

View File

@@ -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" />

View File

@@ -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()

View File

@@ -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)
}) })
} }

View 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
}

View File

@@ -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

View 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 }
}

View 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)
}

View 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>

View 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>

View 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>

View 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>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.