2 Commits
v1.8 ... 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
13 changed files with 615 additions and 22 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).
## 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
```

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

@@ -36,6 +36,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
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.Button
import androidx.compose.material3.ButtonDefaults
@@ -124,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) {
@@ -148,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()
@@ -156,6 +166,7 @@ private fun App() {
onOpenToken = { showTokenDialog = true },
)
2 -> ReadingTab()
3 -> VideosTab(onPlay = { playingVideo = it })
}
}
@@ -187,6 +198,7 @@ private fun TimerTab() {
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 selectedTotalMs = durationMin * 60_000L
@@ -222,7 +234,15 @@ private fun TimerTab() {
settings.edit().putInt("interval_min", it).apply()
},
)
Spacer(Modifier.height(20.dp))
Spacer(Modifier.height(6.dp))
GongSoundRow(
soundKey = gongKey,
onChange = {
gongKey = it
settings.edit().putString("gong_sound", it).apply()
},
)
Spacer(Modifier.height(16.dp))
Button(
onClick = { TimerService.start(context, selectedTotalMs, intervalMin) },
colors = ButtonDefaults.buttonColors(containerColor = Gold, contentColor = DeepBlue),
@@ -274,14 +294,20 @@ 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(
"INTERVAL BELL",
color = Color.White.copy(alpha = 0.35f),
fontSize = 11.sp,
letterSpacing = 2.sp,
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),
)
Spacer(Modifier.height(8.dp))
if (expanded) {
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
INTERVAL_OPTIONS.forEach { min ->
val selected = intervalMin == min
@@ -292,12 +318,58 @@ private fun IntervalBellRow(intervalMin: Int, onChange: (Int) -> Unit) {
modifier = Modifier
.clip(RoundedCornerShape(14.dp))
.background(if (selected) Gold else Color.White.copy(alpha = 0.06f))
.clickable { onChange(min) }
.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)

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

@@ -69,6 +69,7 @@ class TimerService : Service() {
private var pauseCount = 0
private var intervalMin = 0
private var nextBellElapsedMs = 0L
private var gongResId = R.raw.gong
override fun onBind(intent: Intent?) = null
@@ -89,6 +90,9 @@ class TimerService : Service() {
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))
acquireWakeLock(totalMs)
playGong()
@@ -185,7 +189,7 @@ class TimerService : Service() {
private fun playGong(onComplete: (() -> Unit)? = null) {
player?.release()
player = MediaPlayer.create(this, R.raw.gong)?.apply {
player = MediaPlayer.create(this, gongResId)?.apply {
setOnCompletionListener {
it.release()
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)
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.