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
This commit is contained in:
2026-09-01 20:02:09 +10:00
parent 2e1bf3907d
commit 0d694dc98b
7 changed files with 511 additions and 2 deletions

View File

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

View File

@@ -16,7 +16,8 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

View File

@@ -125,6 +125,14 @@ private fun App() {
var tab by rememberSaveable { mutableIntStateOf(0) }
var showTokenDialog by remember { mutableStateOf(false) }
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()
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 })
}
}

View File

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

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