- _portal_device_id() reads from session, falls back to DEVICE_NAME env var - GET/POST /api/v1/portal/select-device for selector data + set - Dropdown in server base.html topbar, JS populates from API, page reloads on change - All portal pages (overview, trips, dashcam, DTC, device settings) now use selected device
291 lines
11 KiB
Kotlin
291 lines
11 KiB
Kotlin
package com.yarisobd.app
|
|
|
|
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.width
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.foundation.lazy.items
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
import androidx.compose.material3.Button
|
|
import androidx.compose.material3.ButtonDefaults
|
|
import androidx.compose.material3.Card
|
|
import androidx.compose.material3.CardDefaults
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.LinearProgressIndicator
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.clip
|
|
import androidx.compose.ui.layout.ContentScale
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.unit.dp
|
|
import coil3.compose.AsyncImage
|
|
import coil3.request.ImageRequest
|
|
import coil3.request.crossfade
|
|
import com.yarisobd.app.data.remote.dto.VideoFile
|
|
|
|
@Composable
|
|
fun DashcamScreen(
|
|
videos: List<VideoFile>,
|
|
totalSize: Long,
|
|
retentionDays: Int,
|
|
isLoading: Boolean,
|
|
error: String?,
|
|
downloadProgress: Map<String, DownloadProgress>,
|
|
onRefresh: () -> Unit,
|
|
onDownload: (VideoFile) -> Unit,
|
|
onDelete: (VideoFile) -> Unit,
|
|
) {
|
|
Column(modifier = Modifier.fillMaxSize()) {
|
|
// Header
|
|
Card(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(16.dp),
|
|
colors = CardDefaults.cardColors(
|
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
|
)
|
|
) {
|
|
Column(modifier = Modifier.padding(16.dp)) {
|
|
Text(
|
|
text = "Dashcam Videos",
|
|
style = MaterialTheme.typography.titleLarge,
|
|
color = MaterialTheme.colorScheme.primary
|
|
)
|
|
Spacer(modifier = Modifier.height(4.dp))
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween
|
|
) {
|
|
Text(
|
|
text = "${videos.size} files",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurface
|
|
)
|
|
Text(
|
|
text = formatBytes(totalSize),
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
if (retentionDays > 0) {
|
|
Text(
|
|
text = "Auto-delete after ${retentionDays}d (if uploaded)",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
Spacer(modifier = Modifier.height(8.dp))
|
|
Button(
|
|
onClick = onRefresh,
|
|
enabled = !isLoading,
|
|
modifier = Modifier.fillMaxWidth()
|
|
) {
|
|
Text(if (isLoading) "Loading..." else "Refresh")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Content
|
|
when {
|
|
isLoading && videos.isEmpty() -> {
|
|
Box(
|
|
modifier = Modifier.fillMaxSize(),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
|
}
|
|
}
|
|
error != null -> {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.padding(32.dp),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
Text(
|
|
text = error,
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
color = MaterialTheme.colorScheme.error
|
|
)
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
Button(onClick = onRefresh) { Text("Retry") }
|
|
}
|
|
}
|
|
}
|
|
videos.isEmpty() -> {
|
|
Box(
|
|
modifier = Modifier.fillMaxSize(),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Text(
|
|
text = "No imported videos on the Pi.\nImport some from the dashcam first.",
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
}
|
|
else -> {
|
|
LazyColumn(
|
|
modifier = Modifier.fillMaxSize(),
|
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
|
start = 16.dp, end = 16.dp, bottom = 16.dp
|
|
),
|
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
) {
|
|
items(videos, key = { it.path }) { video ->
|
|
val progress = downloadProgress[video.path]
|
|
VideoRow(
|
|
video = video,
|
|
downloadProgress = progress,
|
|
onDownload = { onDownload(video) },
|
|
onDelete = { onDelete(video) },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun VideoRow(
|
|
video: VideoFile,
|
|
downloadProgress: DownloadProgress?,
|
|
onDownload: () -> Unit,
|
|
onDelete: () -> Unit,
|
|
) {
|
|
Card(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
colors = CardDefaults.cardColors(
|
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
|
)
|
|
) {
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(12.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
// Thumbnail
|
|
AsyncImage(
|
|
model = ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
|
|
.data("http://10.42.0.1/api/local-video/thumbnail?path=${video.path}")
|
|
.crossfade(true)
|
|
.build(),
|
|
contentDescription = "Thumbnail",
|
|
modifier = Modifier
|
|
.size(64.dp)
|
|
.clip(RoundedCornerShape(8.dp))
|
|
.background(MaterialTheme.colorScheme.surfaceVariant),
|
|
contentScale = ContentScale.Crop,
|
|
)
|
|
|
|
Spacer(modifier = Modifier.width(12.dp))
|
|
|
|
// Info
|
|
Column(modifier = Modifier.weight(1f)) {
|
|
Text(
|
|
text = video.path.substringAfterLast("/"),
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
fontWeight = FontWeight.Medium,
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis,
|
|
color = MaterialTheme.colorScheme.onSurface
|
|
)
|
|
Spacer(modifier = Modifier.height(2.dp))
|
|
Row(
|
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
) {
|
|
Text(
|
|
text = video.formattedSize(),
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
Text(
|
|
text = video.formattedDate(),
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
Spacer(modifier = Modifier.height(2.dp))
|
|
if (video.converting) {
|
|
Text(
|
|
text = "Converting ${video.convert_progress?.pct ?: 0}%",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.tertiary
|
|
)
|
|
} else if (downloadProgress != null) {
|
|
LinearProgressIndicator(
|
|
progress = {
|
|
if (downloadProgress.totalBytes > 0)
|
|
downloadProgress.bytesDownloaded.toFloat() / downloadProgress.totalBytes
|
|
else 0f
|
|
},
|
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
|
)
|
|
Text(
|
|
text = "Downloading ${downloadProgress.bytesDownloaded / 1_000_000}MB / ${downloadProgress.totalBytes / 1_000_000}MB",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.primary
|
|
)
|
|
} else {
|
|
Text(
|
|
text = if (video.uploaded) "Uploaded" else "Pending",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = if (video.uploaded) MaterialTheme.colorScheme.primary
|
|
else MaterialTheme.colorScheme.error
|
|
)
|
|
}
|
|
}
|
|
|
|
// Actions
|
|
Row {
|
|
Button(
|
|
onClick = onDownload,
|
|
enabled = !video.converting,
|
|
colors = ButtonDefaults.buttonColors(
|
|
containerColor = MaterialTheme.colorScheme.primary
|
|
),
|
|
modifier = Modifier.height(36.dp)
|
|
) {
|
|
Text("↓", style = MaterialTheme.typography.bodySmall)
|
|
}
|
|
Spacer(modifier = Modifier.width(4.dp))
|
|
Button(
|
|
onClick = onDelete,
|
|
colors = ButtonDefaults.buttonColors(
|
|
containerColor = MaterialTheme.colorScheme.error
|
|
),
|
|
modifier = Modifier.height(36.dp)
|
|
) {
|
|
Text("✕", style = MaterialTheme.typography.bodySmall)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun formatBytes(bytes: Long): String {
|
|
return when {
|
|
bytes >= 1_000_000_000 -> "%.1f GB".format(bytes / 1_000_000_000.0)
|
|
bytes >= 1_000_000 -> "%.1f MB".format(bytes / 1_000_000.0)
|
|
bytes >= 1_000 -> "%.1f KB".format(bytes / 1_000.0)
|
|
else -> "$bytes B"
|
|
}
|
|
}
|