Videos tab: stream/download the server video library, in-app ExoPlayer
All checks were successful
Build & Release APK / build (push) Successful in 12m15s
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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
185
app/src/main/java/com/marcus/meditationtimer/VideoStore.kt
Normal file
185
app/src/main/java/com/marcus/meditationtimer/VideoStore.kt
Normal file
@@ -0,0 +1,185 @@
|
||||
package com.marcus.meditationtimer
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
data class Video(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val channel: String?,
|
||||
val durationSec: Int?,
|
||||
val sizeBytes: Long,
|
||||
val file: String,
|
||||
val thumb: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Video library served by health-api (`GET /v1/videos`). Videos are added on the
|
||||
* server with `video-add.sh <youtube-url>`; here we list them, stream them, or
|
||||
* download them into app-private storage for offline playback. Authenticates
|
||||
* with the same bearer token as session sync.
|
||||
*/
|
||||
object VideoStore {
|
||||
val videos = MutableStateFlow<List<Video>>(emptyList())
|
||||
/** File names present in local storage (complete downloads only). */
|
||||
val downloaded = MutableStateFlow<Set<String>>(emptySet())
|
||||
/** File name → percent, for downloads in flight. */
|
||||
val progress = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
val loading = MutableStateFlow(false)
|
||||
val error = MutableStateFlow<String?>(null)
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
fun streamUrl(v: Video): String = fileUrl(v.file)
|
||||
|
||||
private fun fileUrl(name: String): String = "${SessionStore.BASE_URL}/v1/videos/${Uri.encode(name)}"
|
||||
|
||||
private fun dir(context: Context): File =
|
||||
File(context.getExternalFilesDir(null) ?: context.filesDir, "videos").apply { mkdirs() }
|
||||
|
||||
fun localFile(context: Context, v: Video): File? =
|
||||
File(dir(context), v.file).takeIf { it.isFile && it.length() > 0 }
|
||||
|
||||
fun refreshLocal(context: Context) {
|
||||
downloaded.value = dir(context).listFiles()
|
||||
?.filter { it.isFile && it.length() > 0 && !it.name.endsWith(".part") }
|
||||
?.map { it.name }
|
||||
?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
fun refresh(context: Context) {
|
||||
refreshLocal(context)
|
||||
val tok = SessionStore.token
|
||||
if (tok.isEmpty()) {
|
||||
error.value = "Set the sync token (History → Token) to load videos"
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = null
|
||||
scope.launch {
|
||||
try {
|
||||
val conn = URL("${SessionStore.BASE_URL}/v1/videos").openConnection() as HttpURLConnection
|
||||
conn.setRequestProperty("Authorization", "Bearer $tok")
|
||||
conn.connectTimeout = 10_000
|
||||
conn.readTimeout = 15_000
|
||||
val code = conn.responseCode
|
||||
if (code !in 200..299) {
|
||||
error.value = if (code == 401) "Token rejected by server" else "Server error $code"
|
||||
return@launch
|
||||
}
|
||||
val body = conn.inputStream.bufferedReader().readText()
|
||||
conn.disconnect()
|
||||
val arr = JSONObject(body).getJSONArray("videos")
|
||||
videos.value = (0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
Video(
|
||||
id = o.getString("id"),
|
||||
title = o.str("title") ?: o.getString("file"),
|
||||
channel = o.str("channel"),
|
||||
durationSec = if (o.isNull("durationSec")) null else o.optInt("durationSec"),
|
||||
sizeBytes = o.optLong("sizeBytes", 0L),
|
||||
file = o.getString("file"),
|
||||
thumb = o.str("thumb"),
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
error.value = "Could not load videos: ${e.message}"
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun download(context: Context, v: Video) {
|
||||
val tok = SessionStore.token
|
||||
if (tok.isEmpty() || progress.value.containsKey(v.file)) return
|
||||
progress.update { it + (v.file to 0) }
|
||||
scope.launch {
|
||||
val target = File(dir(context), v.file)
|
||||
val part = File(dir(context), v.file + ".part")
|
||||
try {
|
||||
val conn = URL(streamUrl(v)).openConnection() as HttpURLConnection
|
||||
conn.setRequestProperty("Authorization", "Bearer $tok")
|
||||
conn.connectTimeout = 10_000
|
||||
conn.readTimeout = 30_000
|
||||
if (conn.responseCode !in 200..299) throw IllegalStateException("HTTP ${conn.responseCode}")
|
||||
val total = conn.contentLengthLong.takeIf { it > 0 } ?: v.sizeBytes
|
||||
conn.inputStream.use { input ->
|
||||
part.outputStream().use { out ->
|
||||
val buf = ByteArray(64 * 1024)
|
||||
var done = 0L
|
||||
var lastPct = -1
|
||||
while (true) {
|
||||
val n = input.read(buf)
|
||||
if (n < 0) break
|
||||
out.write(buf, 0, n)
|
||||
done += n
|
||||
val pct = if (total > 0) (done * 100 / total).toInt().coerceIn(0, 100) else 0
|
||||
if (pct != lastPct) {
|
||||
lastPct = pct
|
||||
progress.update { it + (v.file to pct) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
conn.disconnect()
|
||||
if (!part.renameTo(target)) throw IllegalStateException("could not finalise file")
|
||||
} catch (e: Exception) {
|
||||
part.delete()
|
||||
error.value = "Download failed: ${e.message}"
|
||||
} finally {
|
||||
progress.update { it - v.file }
|
||||
refreshLocal(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(context: Context, v: Video) {
|
||||
File(dir(context), v.file).delete()
|
||||
refreshLocal(context)
|
||||
}
|
||||
|
||||
/** Thumbnail bitmap, cached under cacheDir/thumbs. Blocking — call off the main thread. */
|
||||
fun thumbnail(context: Context, v: Video): Bitmap? {
|
||||
val name = v.thumb ?: return null
|
||||
val cache = File(context.cacheDir, "thumbs").apply { mkdirs() }
|
||||
val f = File(cache, name)
|
||||
if (!f.isFile || f.length() == 0L) {
|
||||
val tok = SessionStore.token
|
||||
if (tok.isEmpty()) return null
|
||||
try {
|
||||
val conn = URL(fileUrl(name)).openConnection() as HttpURLConnection
|
||||
conn.setRequestProperty("Authorization", "Bearer $tok")
|
||||
conn.connectTimeout = 10_000
|
||||
conn.readTimeout = 15_000
|
||||
if (conn.responseCode !in 200..299) return null
|
||||
conn.inputStream.use { input -> f.outputStream().use { input.copyTo(it) } }
|
||||
conn.disconnect()
|
||||
} catch (_: Exception) {
|
||||
f.delete()
|
||||
return null
|
||||
}
|
||||
}
|
||||
return try {
|
||||
BitmapFactory.decodeFile(f.path, BitmapFactory.Options().apply { inSampleSize = 4 })
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// org.json returns the string "null" for JSON null via optString — avoid that.
|
||||
private fun JSONObject.str(key: String): String? =
|
||||
if (isNull(key)) null else optString(key).ifEmpty { null }
|
||||
}
|
||||
291
app/src/main/java/com/marcus/meditationtimer/VideosTab.kt
Normal file
291
app/src/main/java/com/marcus/meditationtimer/VideosTab.kt
Normal file
@@ -0,0 +1,291 @@
|
||||
package com.marcus.meditationtimer
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.ui.PlayerView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private val VGold = Color(0xFFE3BC4B)
|
||||
private val VDim = Color.White.copy(alpha = 0.35f)
|
||||
|
||||
// --------------------------------------------------------------- Videos tab
|
||||
|
||||
@Composable
|
||||
internal fun VideosTab(onPlay: (Video) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val videos by VideoStore.videos.collectAsState()
|
||||
val downloaded by VideoStore.downloaded.collectAsState()
|
||||
val progress by VideoStore.progress.collectAsState()
|
||||
val loading by VideoStore.loading.collectAsState()
|
||||
val error by VideoStore.error.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) { if (videos.isEmpty()) VideoStore.refresh(context) }
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 20.dp)
|
||||
) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
val err = error
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
loading -> "Loading…"
|
||||
err != null -> err
|
||||
videos.isEmpty() -> "No videos on the server yet"
|
||||
else -> "${videos.size} videos · ${downloaded.size} offline"
|
||||
},
|
||||
color = if (err != null) VGold.copy(alpha = 0.8f) else VDim,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = { VideoStore.refresh(context) }) {
|
||||
Text("Refresh", color = VGold, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = Color.White.copy(alpha = 0.08f))
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
items(videos, key = { it.file }) { v ->
|
||||
VideoRow(
|
||||
v = v,
|
||||
isLocal = v.file in downloaded,
|
||||
percent = progress[v.file],
|
||||
onPlay = { onPlay(v) },
|
||||
onDownload = { VideoStore.download(context, v) },
|
||||
onDelete = { VideoStore.delete(context, v) },
|
||||
)
|
||||
HorizontalDivider(color = Color.White.copy(alpha = 0.06f))
|
||||
}
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Add videos on the server: health-api/video-add.sh <youtube-url>",
|
||||
color = VDim,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoRow(
|
||||
v: Video,
|
||||
isLocal: Boolean,
|
||||
percent: Int?,
|
||||
onPlay: () -> Unit,
|
||||
onDownload: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var thumb by remember(v.file) { mutableStateOf<ImageBitmap?>(null) }
|
||||
LaunchedEffect(v.file) {
|
||||
thumb = withContext(Dispatchers.IO) { VideoStore.thumbnail(context, v) }?.asImageBitmap()
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp)
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onPlay),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(width = 112.dp, height = 63.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.White.copy(alpha = 0.06f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val t = thumb
|
||||
if (t != null) {
|
||||
Image(
|
||||
bitmap = t,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
Text("▶", color = Color.White.copy(alpha = 0.9f), fontSize = 22.sp)
|
||||
}
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
v.title,
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
fontSize = 14.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
text = listOfNotNull(
|
||||
v.channel,
|
||||
v.durationSec?.let { formatDuration(it) },
|
||||
formatSize(v.sizeBytes),
|
||||
if (isLocal) "offline ✓" else null,
|
||||
).joinToString(" · "),
|
||||
color = if (isLocal) VGold.copy(alpha = 0.8f) else VDim,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when {
|
||||
percent != null -> {
|
||||
LinearProgressIndicator(
|
||||
progress = { percent / 100f },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(3.dp),
|
||||
color = VGold,
|
||||
trackColor = Color.White.copy(alpha = 0.08f),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text("$percent%", color = VDim, fontSize = 11.sp)
|
||||
}
|
||||
isLocal -> TextButton(onClick = onDelete) {
|
||||
Text("Remove download", color = VDim, fontSize = 12.sp)
|
||||
}
|
||||
else -> TextButton(onClick = onDownload) {
|
||||
Text("Download for offline", color = VGold, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Player
|
||||
|
||||
/** Full-screen ExoPlayer. Plays the local copy if downloaded, otherwise streams with the bearer token. */
|
||||
@androidx.annotation.OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
internal fun VideoPlayer(video: Video, onClose: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val player = remember(video.file) {
|
||||
val local = VideoStore.localFile(context, video)
|
||||
val uri = if (local != null) Uri.fromFile(local) else Uri.parse(VideoStore.streamUrl(video))
|
||||
val http = DefaultHttpDataSource.Factory()
|
||||
.setDefaultRequestProperties(mapOf("Authorization" to "Bearer ${SessionStore.token}"))
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
val dataSources = DefaultDataSource.Factory(context, http)
|
||||
ExoPlayer.Builder(context)
|
||||
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSources))
|
||||
.build()
|
||||
.apply {
|
||||
setMediaItem(MediaItem.fromUri(uri))
|
||||
prepare()
|
||||
playWhenReady = true
|
||||
}
|
||||
}
|
||||
DisposableEffect(player) { onDispose { player.release() } }
|
||||
BackHandler(onBack = onClose)
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
this.player = player
|
||||
keepScreenOn = true
|
||||
setShowNextButton(false)
|
||||
setShowPreviousButton(false)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
TextButton(
|
||||
onClick = onClose,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.statusBarsPadding()
|
||||
.padding(4.dp),
|
||||
) {
|
||||
Text("‹ Back", color = Color.White.copy(alpha = 0.85f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- Helpers
|
||||
|
||||
private fun formatDuration(sec: Int): String {
|
||||
val h = sec / 3600
|
||||
val m = (sec % 3600) / 60
|
||||
val s = sec % 60
|
||||
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
|
||||
}
|
||||
|
||||
private fun formatSize(bytes: Long): String = when {
|
||||
bytes >= 1L shl 30 -> "%.1f GB".format(bytes / (1L shl 30).toDouble())
|
||||
bytes >= 1L shl 20 -> "%d MB".format(bytes / (1L shl 20))
|
||||
else -> "%d KB".format(bytes / 1024)
|
||||
}
|
||||
Reference in New Issue
Block a user