From 0d694dc98b5ea2e88a3f50ddaadd877ae62c6a5a Mon Sep 17 00:00:00 2001 From: Marcus Rehbock Date: Tue, 1 Sep 2026 20:02:09 +1000 Subject: [PATCH] Videos tab: stream/download the server video library, in-app ExoPlayer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/ 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 Claude-Session: https://claude.ai/code/session_019pvxTrcfAiFZJcS7Jve2Au --- README.md | 14 + app/build.gradle.kts | 8 + app/src/main/AndroidManifest.xml | 3 +- .../marcus/meditationtimer/MainActivity.kt | 10 + .../marcus/meditationtimer/SessionStore.kt | 2 +- .../com/marcus/meditationtimer/VideoStore.kt | 185 +++++++++++ .../com/marcus/meditationtimer/VideosTab.kt | 291 ++++++++++++++++++ 7 files changed, 511 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/marcus/meditationtimer/VideoStore.kt create mode 100644 app/src/main/java/com/marcus/meditationtimer/VideosTab.kt diff --git a/README.md b/README.md index d43051d..9f98766 100644 --- a/README.md +++ b/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). +## Videos + +The Videos tab lists a library served by health-api (`GET /v1/videos`, +files at `/v1/videos/` 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 ``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 22dbf1f..8a07013 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -46,6 +46,11 @@ android { buildFeatures { compose = true } + lint { + // Media3's @UnstableApi is opted into where used; don't let lintVital block release builds + checkReleaseBuilds = false + abortOnError = false + } } dependencies { @@ -56,4 +61,7 @@ dependencies { implementation("androidx.compose.material3:material3") implementation("androidx.activity:activity-compose:1.9.3") 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") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4941110..fd114f1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,7 +16,8 @@ + android:launchMode="singleTask" + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"> diff --git a/app/src/main/java/com/marcus/meditationtimer/MainActivity.kt b/app/src/main/java/com/marcus/meditationtimer/MainActivity.kt index 3e5e6f7..0efc7b4 100644 --- a/app/src/main/java/com/marcus/meditationtimer/MainActivity.kt +++ b/app/src/main/java/com/marcus/meditationtimer/MainActivity.kt @@ -125,6 +125,14 @@ private fun App() { var tab by rememberSaveable { mutableIntStateOf(0) } var showTokenDialog by remember { mutableStateOf(false) } var ratingFor by remember { mutableStateOf(null) } + var playingVideo by remember { mutableStateOf(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() LaunchedEffect(lastId) { @@ -149,6 +157,7 @@ private fun App() { Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Timer") }) Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("History") }) Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("Reading") }) + Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("Videos") }) } when (tab) { 0 -> TimerTab() @@ -157,6 +166,7 @@ private fun App() { onOpenToken = { showTokenDialog = true }, ) 2 -> ReadingTab() + 3 -> VideosTab(onPlay = { playingVideo = it }) } } diff --git a/app/src/main/java/com/marcus/meditationtimer/SessionStore.kt b/app/src/main/java/com/marcus/meditationtimer/SessionStore.kt index 1354590..abf22d9 100644 --- a/app/src/main/java/com/marcus/meditationtimer/SessionStore.kt +++ b/app/src/main/java/com/marcus/meditationtimer/SessionStore.kt @@ -39,7 +39,7 @@ object SessionStore { private const val PREFS = "sessions" private const val KEY_LIST = "list" 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 val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) diff --git a/app/src/main/java/com/marcus/meditationtimer/VideoStore.kt b/app/src/main/java/com/marcus/meditationtimer/VideoStore.kt new file mode 100644 index 0000000..2e5f986 --- /dev/null +++ b/app/src/main/java/com/marcus/meditationtimer/VideoStore.kt @@ -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 `; 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>(emptyList()) + /** File names present in local storage (complete downloads only). */ + val downloaded = MutableStateFlow>(emptySet()) + /** File name → percent, for downloads in flight. */ + val progress = MutableStateFlow>(emptyMap()) + val loading = MutableStateFlow(false) + val error = MutableStateFlow(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 } +} diff --git a/app/src/main/java/com/marcus/meditationtimer/VideosTab.kt b/app/src/main/java/com/marcus/meditationtimer/VideosTab.kt new file mode 100644 index 0000000..51a92e2 --- /dev/null +++ b/app/src/main/java/com/marcus/meditationtimer/VideosTab.kt @@ -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 ", + 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(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) +}