feat(android): Compose app with bundled yt-dlp, theme pack, nav & progress

Turn android/ into a real Gradle app (Kotlin + Jetpack Compose + Material3)
that bundles an on-device yt-dlp engine and reuses the Phase-4 Rust JNI core.

- Bundled engine: youtubedl-android (Python + yt-dlp + ffmpeg + aria2c) via
  Engine.kt; initialises off the main thread on first launch. Requires legacy
  jniLib packaging so the bundled .zip.so payloads extract to disk.
- Intuitive navigation: bottom NavigationBar — Download / Files / Settings.
- Settings section: dedicated screen with a live theme picker, default-quality
  chips, engine info, and a Rust-core demo.
- All 19 desktop themes ported from src/theme.rs into Theme.kt as Material3
  colour schemes, shown as tappable swatches; selection persisted (Prefs.kt).
- Download screen: live platform detection through the Rust core as you type,
  plus an improved animated determinate progress indicator (%/ETA/status/
  success-error) and a scrollable log.

Toolchain pinned for reproducibility: Gradle 8.9 wrapper, AGP 8.5.2, Kotlin
1.9.24, Compose BOM 2024.06.00, compileSdk 34, minSdk 24, ABIs arm64-v8a +
x86_64. build-apk.sh runs Gradle on a JDK <= 21 (system JDK may break R8) and
builds the Rust libs first.

Verified on an Android 14 x86_64 emulator: installs, launches, engine reports
"yt-dlp ready", Rust .so loads, theme switching re-themes live and persists
across restart, all screens render. (A real YouTube download still depends on
the anti-bot/POT question — Phases 2-3, device-only.)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Luna 2026-07-03 17:03:26 -07:00
parent 1790fd1a22
commit 3a55ff5b8a
22 changed files with 1607 additions and 83 deletions

View file

@ -0,0 +1,100 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.catacomb.spike"
compileSdk = 34
defaultConfig {
applicationId = "com.catacomb.spike"
minSdk = 24
targetSdk = 34
versionCode = 2
versionName = "0.2-spike"
// youtubedl-android ships native Python for these ABIs; the Rust core
// (jniLibs/) ships arm64-v8a + x86_64. Restrict to the intersection so
// every ABI in the APK has both libraries.
ndk {
abiFilters += listOf("arm64-v8a", "x86_64")
}
}
signingConfigs {
// Reproducible debug key so `assembleDebug` produces an installable,
// stably-signed APK without Android Studio. Generated on first build
// by build-apk.sh if absent.
create("debugks") {
val ks = rootProject.file("debug.keystore")
if (ks.exists()) {
storeFile = ks
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
}
}
}
buildTypes {
getByName("debug") {
isMinifyEnabled = false
if (rootProject.file("debug.keystore").exists()) {
signingConfig = signingConfigs.getByName("debugks")
}
}
getByName("release") {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
// Matches Kotlin 1.9.24.
kotlinCompilerExtensionVersion = "1.5.14"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
// youtubedl-android extracts its bundled Python/ffmpeg/aria2c payloads
// (libpython.zip.so, …) from the native lib dir at first launch, so the
// .so files MUST be extracted to disk — i.e. legacy packaging on
// (extractNativeLibs=true). With them left compressed-in-APK the engine
// init fails.
jniLibs {
useLegacyPackaging = true
}
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
implementation(composeBom)
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.activity:activity-compose:1.9.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.2")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
// On-device yt-dlp engine (bundled Python + yt-dlp) + ffmpeg + aria2c.
implementation("io.github.junkfood02.youtubedl-android:library:0.18.1")
implementation("io.github.junkfood02.youtubedl-android:ffmpeg:0.18.1")
implementation("io.github.junkfood02.youtubedl-android:aria2c:0.18.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".CatacombApp"
android:allowBackup="false"
android:extractNativeLibs="true"
android:icon="@android:drawable/sym_def_app_icon"
android:label="Catacomb"
android:theme="@style/Theme.Catacomb">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Catacomb">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,11 @@
package com.catacomb.spike
import android.app.Application
/** Kicks off the bundled yt-dlp engine init as early as possible. */
class CatacombApp : Application() {
override fun onCreate() {
super.onCreate()
Engine.init(this)
}
}

View file

@ -0,0 +1,54 @@
package com.catacomb.spike
import android.content.Context
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.json.JSONObject
import java.io.File
/** App-private external download directory (no runtime permission needed). */
fun downloadDir(context: Context): File =
context.getExternalFilesDir("downloads") ?: File(context.filesDir, "downloads")
/** Safe extract of a string field from a small JSON object string. */
fun jsonField(json: String, key: String): String =
runCatching { JSONObject(json).optString(key, "") }.getOrDefault("")
/** A titled surface card used to group content into sections. */
@Composable
fun SectionCard(
title: String,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
) {
Column(Modifier.padding(16.dp)) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 10.dp),
)
content()
}
}
}
/** Standard screen content padding. */
val screenPadding = PaddingValues(16.dp)

View file

@ -0,0 +1,256 @@
package com.catacomb.spike
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
/** Download stage for the progress card. */
private sealed interface DlStage {
data object Idle : DlStage
data object Running : DlStage
data class Done(val ok: Boolean, val message: String) : DlStage
}
@Composable
fun DownloadScreen() {
val context = LocalContext.current
val prefs = remember { Prefs(context) }
val scope = rememberCoroutineScope()
var url by remember { mutableStateOf("") }
var stage by remember { mutableStateOf<DlStage>(DlStage.Idle) }
var progress by remember { mutableStateOf(0f) }
var etaSeconds by remember { mutableStateOf(0L) }
var statusLine by remember { mutableStateOf("") }
val log = remember { mutableStateOf("") }
// Live platform detection through the Rust core, as the URL is typed.
val platform = remember(url) {
if (url.isBlank()) null
else runCatching { RustCore.platformFromUrl(url) }.getOrNull()
}
val engineState = Engine.state
val engineReady = engineState is EngineState.Ready
val running = stage is DlStage.Running
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// ── Engine status banner ────────────────────────────────────────
EngineBanner(engineState)
// ── URL + platform chip ─────────────────────────────────────────
SectionCard("Source") {
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text("Video / playlist URL") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (platform != null) {
Spacer(Modifier.height(10.dp))
val name = jsonField(platform, "display_name").ifEmpty { "Unknown" }
val icon = jsonField(platform, "icon")
val dir = jsonField(platform, "dir_name")
AssistChip(
onClick = {},
label = { Text("$icon $name · $dir/") },
colors = AssistChipDefaults.assistChipColors(
labelColor = MaterialTheme.colorScheme.primary,
),
)
}
}
// ── Action ──────────────────────────────────────────────────────
val quality = Quality.byId(prefs.quality)
Button(
onClick = {
stage = DlStage.Running
progress = 0f
etaSeconds = 0L
statusLine = "Starting…"
log.value = ""
scope.launch {
val result = Engine.download(
url = url.trim(),
destDir = downloadDir(context),
quality = quality,
) { p, eta, line ->
progress = (p / 100f).coerceIn(0f, 1f)
etaSeconds = eta
if (line.isNotBlank()) {
statusLine = line
log.value = (log.value + line + "\n").takeLast(6000)
}
}
stage = DlStage.Done(result.ok, result.message)
if (result.ok) progress = 1f
}
},
enabled = engineReady && url.isNotBlank() && !running,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (running) "Downloading…" else "Download · ${quality.label}")
}
// ── Progress card ───────────────────────────────────────────────
AnimatedVisibility(visible = stage !is DlStage.Idle) {
ProgressCard(stage, progress, etaSeconds, statusLine)
}
// ── Log ─────────────────────────────────────────────────────────
if (log.value.isNotEmpty()) {
SectionCard("Log") {
Text(
log.value,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun EngineBanner(state: EngineState) {
when (state) {
is EngineState.Initializing -> SectionCard("Engine") {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.height(18.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.height(0.dp))
Text(
" Preparing yt-dlp engine (first launch extracts Python)…",
style = MaterialTheme.typography.bodyMedium,
)
}
}
is EngineState.Ready -> SectionCard("Engine") {
Text(
"yt-dlp ready · ${state.version}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
)
}
is EngineState.Failed -> SectionCard("Engine") {
Text(
"Engine failed to initialise: ${state.message}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.tertiary,
)
}
}
}
/** The improved determinate progress indicator: animated bar + %, ETA, status. */
@Composable
private fun ProgressCard(stage: DlStage, progress: Float, etaSeconds: Long, statusLine: String) {
val animated by animateFloatAsState(targetValue = progress, label = "dl-progress")
val done = stage as? DlStage.Done
SectionCard(if (done != null) "Result" else "Downloading") {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"${(animated * 100).toInt()}%",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary,
)
when {
done?.ok == true -> Icon(
Icons.Filled.CheckCircle, contentDescription = "done",
tint = MaterialTheme.colorScheme.secondary,
)
done != null -> Icon(
Icons.Filled.Error, contentDescription = "error",
tint = MaterialTheme.colorScheme.tertiary,
)
etaSeconds > 0 -> Text(
"ETA ${formatEta(etaSeconds)}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(10.dp))
LinearProgressIndicator(
progress = { animated },
modifier = Modifier
.fillMaxWidth()
.height(10.dp),
trackColor = MaterialTheme.colorScheme.surface,
strokeCap = androidx.compose.ui.graphics.StrokeCap.Round,
)
Spacer(Modifier.height(10.dp))
Text(
done?.message ?: statusLine.ifEmpty { "Working…" },
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = when {
done?.ok == true -> MaterialTheme.colorScheme.secondary
done != null -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurface
},
)
}
}
private fun formatEta(seconds: Long): String {
if (seconds <= 0) return ""
val m = seconds / 60
val s = seconds % 60
return if (m > 0) "${m}m ${s}s" else "${s}s"
}
// Suppress unused shape import guard.
@Suppress("unused")
private val cardShape = RoundedCornerShape(16.dp)

View file

@ -0,0 +1,91 @@
package com.catacomb.spike
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.yausername.ffmpeg.FFmpeg
import com.yausername.youtubedl_android.YoutubeDL
import com.yausername.youtubedl_android.YoutubeDLRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
/** Lifecycle of the bundled on-device yt-dlp engine. */
sealed interface EngineState {
data object Initializing : EngineState
data class Ready(val version: String) : EngineState
data class Failed(val message: String) : EngineState
}
/**
* Wraps the bundled youtubedl-android engine (Python + yt-dlp + ffmpeg).
*
* `init()` extracts the bundled Python environment on first launch (slow, so it
* runs off the main thread) and flips [state] to [EngineState.Ready]. The UI
* observes [state] to enable the Download button and show status.
*/
object Engine {
var state by mutableStateOf<EngineState>(EngineState.Initializing)
private set
private val io = CoroutineScope(Dispatchers.IO)
fun init(app: Application) {
io.launch {
state = try {
YoutubeDL.getInstance().init(app)
FFmpeg.getInstance().init(app)
val v = runCatching { YoutubeDL.getInstance().version(app) }.getOrNull() ?: "unknown"
android.util.Log.i("CatacombEngine", "yt-dlp ready: $v")
EngineState.Ready(v)
} catch (t: Throwable) {
android.util.Log.e("CatacombEngine", "init failed", t)
EngineState.Failed(t.message ?: t.javaClass.simpleName)
}
}
}
val isReady: Boolean get() = state is EngineState.Ready
/** Result of a download attempt. */
data class DownloadResult(val ok: Boolean, val message: String)
/**
* Run a download. Must be called from a coroutine; the blocking yt-dlp call
* is dispatched to IO. [onProgress] is invoked with (percent 0..100, eta
* seconds, latest output line) on the engine's callback thread.
*/
suspend fun download(
url: String,
destDir: File,
quality: Quality,
onProgress: (Float, Long, String) -> Unit,
): DownloadResult = withContext(Dispatchers.IO) {
if (state !is EngineState.Ready) {
return@withContext DownloadResult(false, "Engine not ready")
}
if (!destDir.exists()) destDir.mkdirs()
try {
val request = YoutubeDLRequest(url)
request.addOption("-o", "${destDir.absolutePath}/%(title).200s.%(ext)s")
request.addOption("-f", quality.format)
if (quality.audioOnly) {
request.addOption("-x")
request.addOption("--audio-format", "m4a")
}
// Keep runs bounded and quiet-ish for the log view.
request.addOption("--no-playlist")
request.addOption("--newline")
val processId = "catacomb-${System.currentTimeMillis()}"
YoutubeDL.getInstance().execute(request, processId) { progress, etaSeconds, line ->
onProgress(progress, etaSeconds, line)
}
DownloadResult(true, "Download complete")
} catch (t: Throwable) {
DownloadResult(false, t.message ?: t.javaClass.simpleName)
}
}
}

View file

@ -0,0 +1,96 @@
package com.catacomb.spike
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Locale
/** Lists files already downloaded into the app's download directory. */
@Composable
fun FilesScreen() {
val context = LocalContext.current
val dir = rememberDownloadDir(context)
// Re-list on each composition entry (cheap; dir is small).
val files by produceState(initialValue = emptyList<File>(), dir) {
value = withContext(Dispatchers.IO) {
dir.listFiles()?.sortedByDescending { it.lastModified() }?.toList() ?: emptyList()
}
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SectionCard("Downloads") {
Text(
dir.absolutePath,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 8.dp),
)
if (files.isEmpty()) {
Text(
"No downloads yet. Grab something from the Download tab.",
style = MaterialTheme.typography.bodyMedium,
)
} else {
files.forEachIndexed { i, f ->
if (i > 0) Divider(Modifier.padding(vertical = 6.dp))
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
f.name,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(end = 12.dp),
)
Text(
humanSize(f.length()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
@Composable
private fun rememberDownloadDir(context: android.content.Context): File = downloadDir(context)
private fun humanSize(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val units = listOf("KB", "MB", "GB", "TB")
var v = bytes.toDouble() / 1024
var i = 0
while (v >= 1024 && i < units.size - 1) { v /= 1024; i++ }
return String.format(Locale.US, "%.1f %s", v, units[i])
}

View file

@ -0,0 +1,99 @@
package com.catacomb.spike
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
/** Bottom-navigation destinations. */
private enum class Screen(val label: String, val icon: ImageVector) {
Download("Download", Icons.Filled.Download),
Files("Files", Icons.Filled.Folder),
Settings("Settings", Icons.Filled.Settings),
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { CatacombRoot() }
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CatacombRoot() {
val context = LocalContext.current
val prefs = remember { Prefs(context) }
// Hoisted app state — theme + current screen survive recomposition.
var themeId by remember { mutableStateOf(prefs.themeId) }
var current by remember { mutableStateOf(Screen.Download) }
val theme = themeById(themeId)
CatacombTheme(theme) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Catacomb · ${current.label}") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.primary,
),
)
},
bottomBar = {
NavigationBar(containerColor = MaterialTheme.colorScheme.surface) {
Screen.entries.forEach { screen ->
NavigationBarItem(
selected = current == screen,
onClick = { current = screen },
icon = { Icon(screen.icon, contentDescription = screen.label) },
label = { Text(screen.label) },
)
}
}
},
) { inner ->
Box(Modifier.fillMaxSize().padding(inner)) {
when (current) {
Screen.Download -> DownloadScreen()
Screen.Files -> FilesScreen()
Screen.Settings -> SettingsScreen(
currentThemeId = themeId,
currentQuality = prefs.quality,
onThemeSelected = { id ->
themeId = id
prefs.themeId = id
},
onQualitySelected = { q -> prefs.quality = q },
)
}
}
}
}
}

View file

@ -0,0 +1,28 @@
package com.catacomb.spike
import android.content.Context
/** Thin SharedPreferences wrapper for the handful of persisted settings. */
class Prefs(context: Context) {
private val sp = context.getSharedPreferences("catacomb", Context.MODE_PRIVATE)
var themeId: String
get() = sp.getString("theme", "dark") ?: "dark"
set(v) = sp.edit().putString("theme", v).apply()
var quality: String
get() = sp.getString("quality", "best") ?: "best"
set(v) = sp.edit().putString("quality", v).apply()
}
/** Download quality presets → yt-dlp format selectors. */
enum class Quality(val id: String, val label: String, val format: String, val audioOnly: Boolean) {
BEST("best", "Best available", "bestvideo*+bestaudio/best", false),
P1080("1080p", "1080p", "bestvideo[height<=1080]+bestaudio/best[height<=1080]", false),
P720("720p", "720p", "bestvideo[height<=720]+bestaudio/best[height<=720]", false),
AUDIO("audio", "Audio only (m4a)", "bestaudio/best", true);
companion object {
fun byId(id: String) = entries.firstOrNull { it.id == id } ?: BEST
}
}

View file

@ -0,0 +1,197 @@
package com.catacomb.spike
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.LazyRow
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
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.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
/**
* Settings section: theme picker (all desktop themes), default quality, engine
* info, and a live demo of the shared Rust core.
*/
@Composable
fun SettingsScreen(
currentThemeId: String,
currentQuality: String,
onThemeSelected: (String) -> Unit,
onQualitySelected: (String) -> Unit,
) {
var quality = currentQuality
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// ── Theme picker ────────────────────────────────────────────────
SectionCard("Appearance") {
Text(
"Theme",
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(bottom = 10.dp),
)
ThemeSwatchGrid(currentThemeId, onThemeSelected)
}
// ── Default quality ─────────────────────────────────────────────
SectionCard("Downloads") {
Text(
"Default quality",
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(bottom = 10.dp),
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(Quality.entries.size) { idx ->
val q = Quality.entries[idx]
FilterChip(
selected = quality == q.id,
onClick = {
quality = q.id
onQualitySelected(q.id)
},
label = { Text(q.label) },
leadingIcon = if (quality == q.id) {
{ Icon(Icons.Filled.Check, contentDescription = null, Modifier.size(18.dp)) }
} else null,
)
}
}
}
// ── Engine ──────────────────────────────────────────────────────
SectionCard("Engine") {
val s = Engine.state
val line = when (s) {
is EngineState.Ready -> "yt-dlp ${s.version} · bundled Python + ffmpeg"
is EngineState.Initializing -> "Initialising…"
is EngineState.Failed -> "Failed: ${s.message}"
}
Text(line, style = MaterialTheme.typography.bodyMedium)
}
// ── Rust core demo ──────────────────────────────────────────────
SectionCard("Rust core (JNI)") {
val probe = runCatching {
val p = RustCore.platformFromUrl("https://youtu.be/dQw4w9WgXcQ")
val cls = RustCore.classifyError("ERROR: HTTP Error 429: Too Many Requests")
val cues = RustCore.vttParse("WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nhi\n")
"platformFromUrl → $p\n\nclassifyError → $cls\n\nvttParse → $cues"
}.getOrElse { "Rust core error: ${it.message}" }
Text(
probe,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── About ───────────────────────────────────────────────────────
SectionCard("About") {
Text(
"Catacomb Android — Stage-1 prototype.\n" +
"Bundled on-device yt-dlp engine + shared Rust core over JNI.\n" +
"AGPL-3.0.",
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
/** A wrapping grid of theme swatches; tapping one applies it live. */
@Composable
private fun ThemeSwatchGrid(currentThemeId: String, onThemeSelected: (String) -> Unit) {
// Simple manual wrap: 3 columns.
val cols = 3
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
THEMES.chunked(cols).forEach { rowThemes ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
rowThemes.forEach { t ->
ThemeSwatch(
theme = t,
selected = t.id == currentThemeId,
onClick = { onThemeSelected(t.id) },
modifier = Modifier.weight(1f),
)
}
// pad the last row so weights stay even
repeat(cols - rowThemes.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
@Composable
private fun ThemeSwatch(
theme: CatTheme,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val borderColor =
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
Column(
modifier
.clip(RoundedCornerShape(12.dp))
.border(if (selected) 2.dp else 1.dp, borderColor, RoundedCornerShape(12.dp))
.background(theme.bg)
.clickable(onClick = onClick)
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
// Accent dots preview the theme's primary/secondary/tertiary.
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Dot(theme.primary)
Dot(theme.secondary)
Dot(theme.tertiary)
if (selected) {
Spacer(Modifier.width(2.dp))
Icon(
Icons.Filled.Check,
contentDescription = "selected",
tint = theme.primary,
modifier = Modifier.size(14.dp),
)
}
}
Text(
theme.label,
style = MaterialTheme.typography.bodySmall,
color = if (theme.dark) Color(0xFFF4F4F8) else Color(0xFF101014),
maxLines = 1,
)
}
}
@Composable
private fun Dot(color: Color) {
Box(Modifier.size(14.dp).clip(CircleShape).background(color))
}

View file

@ -0,0 +1,123 @@
package com.catacomb.spike
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
/**
* One selectable theme, ported 1:1 from the desktop `src/theme.rs` palettes so
* the Android build offers the same look. `bg`/`surface` come from egui's
* `panel_fill`/`window_fill`; `primary`/`secondary`/`tertiary` are the desktop
* `accents_for()` accent/success/warning colours.
*/
data class CatTheme(
val id: String,
val label: String,
val dark: Boolean,
val bg: Color,
val surface: Color,
val primary: Color,
val secondary: Color,
val tertiary: Color,
)
private fun c(hex: Long) = Color(0xFF000000 or hex)
/** All desktop themes, in the same order as `theme.rs::THEMES`. */
val THEMES: List<CatTheme> = listOf(
CatTheme("dark", "Dark", true, c(0x14141a), c(0x1a1a22), c(0x7aa2f7), c(0x9ece6a), c(0xbb9af7)),
CatTheme("light", "Light", false, c(0xf6f4ef), c(0xfefcf7), c(0x2a5db0), c(0x2e7d32), c(0x8e44ad)),
CatTheme("dracula", "Dracula", true, c(0x282a36), c(0x2f3142), c(0xbd93f9), c(0x50fa7b), c(0xff79c6)),
CatTheme("trans", "Trans", false, c(0xe8f7fd), c(0xfef0f4), c(0x2288cc), c(0x2e7d32), c(0xf7a8b8)),
CatTheme("emo-nocturnal", "Emo: Nocturnal", true, c(0x0a0a0a), c(0x0d0d0d), c(0xff0090), c(0x39ff14), c(0x00f5ff)),
CatTheme("emo-coffin", "Emo: Coffin", true, c(0x0d0009), c(0x110010), c(0xb01a1a), c(0x39ff14), c(0xcc2222)),
CatTheme("emo-scene-queen", "Emo: Scene Queen", true, c(0x080818), c(0x0a0a1e), c(0x39ff14), c(0xff00ff), c(0x00f5ff)),
CatTheme("cemetery-moss", "Cemetery Moss", true, c(0x1a1f1a), c(0x1e241e), c(0x7a8a6a), c(0x8faf6a), c(0x9a9a8a)),
CatTheme("vampire", "Vampire", true, c(0x0d0006), c(0x120008), c(0xc9a227), c(0xd11a2b), c(0x8c0a1e)),
CatTheme("witching-hour", "Witching Hour", true, c(0x0a0a1f), c(0x0e0e28), c(0x8a6aab), c(0xb0b8d0), c(0x5a5aaa)),
CatTheme("cyberpunk", "Cyberpunk", true, c(0x0a0a12), c(0x0e0e18), c(0x00fff5), c(0x39ff14), c(0xff003c)),
CatTheme("synthwave", "Synthwave '84", true, c(0x2b0a3d), c(0x330a48), c(0xff2a6d), c(0x05d9e8), c(0xf649c9)),
CatTheme("vaporwave", "Vaporwave", true, c(0x1a0033), c(0x200040), c(0x01cdfe), c(0x05ffa1), c(0xff71ce)),
CatTheme("nord", "Nord", true, c(0x2e3440), c(0x3b4252), c(0x88c0d0), c(0xa3be8c), c(0xebcb8b)),
CatTheme("gruvbox", "Gruvbox", true, c(0x282828), c(0x32302f), c(0xfe8019), c(0xb8bb26), c(0xd3869b)),
CatTheme("tokyo-night", "Tokyo Night", true, c(0x1a1b26), c(0x20202f), c(0x7aa2f7), c(0x9ece6a), c(0xbb9af7)),
CatTheme("paper", "Paper", false, c(0xf4ecd8), c(0xf8f0dc), c(0x8b6f3a), c(0x4a6b3a), c(0xc4a86a)),
CatTheme("honey", "Honey", false, c(0xfff4e0), c(0xfff9ec), c(0xd98a1e), c(0xb87420), c(0xc97b4a)),
CatTheme("candlelight", "Candlelight", false, c(0xf2e6d0), c(0xf6ecd9), c(0xa8703a), c(0x6b3f1e), c(0xc79a5a)),
)
fun themeById(id: String): CatTheme = THEMES.firstOrNull { it.id == id } ?: THEMES[0]
/** Pick black/white text for best contrast against [bg]. */
private fun onColor(bg: Color): Color {
val l = 0.299 * bg.red + 0.587 * bg.green + 0.114 * bg.blue
return if (l > 0.55) Color(0xFF101014) else Color(0xFFF4F4F8)
}
private fun elevate(base: Color, dark: Boolean): Color {
// A slightly lifted surface variant for cards/inputs.
val f = if (dark) 0.06f else -0.04f
fun ch(v: Float) = (v + f).coerceIn(0f, 1f)
return Color(ch(base.red), ch(base.green), ch(base.blue), 1f)
}
private val AppType = Typography(
headlineSmall = TextStyle(fontWeight = FontWeight.Bold, fontSize = 22.sp),
titleLarge = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 20.sp),
titleMedium = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 16.sp),
labelLarge = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 14.sp),
)
/** Wrap [content] in a Material3 theme built from the selected [theme]. */
@Composable
fun CatacombTheme(theme: CatTheme, content: @Composable () -> Unit) {
val onBg = onColor(theme.bg)
val onSurface = onColor(theme.surface)
val surfaceVariant = elevate(theme.surface, theme.dark)
val scheme = if (theme.dark) {
darkColorScheme(
primary = theme.primary,
onPrimary = onColor(theme.primary),
secondary = theme.secondary,
onSecondary = onColor(theme.secondary),
tertiary = theme.tertiary,
onTertiary = onColor(theme.tertiary),
background = theme.bg,
onBackground = onBg,
surface = theme.surface,
onSurface = onSurface,
surfaceVariant = surfaceVariant,
onSurfaceVariant = onSurface.copy(alpha = 0.85f),
outline = onSurface.copy(alpha = 0.35f),
)
} else {
lightColorScheme(
primary = theme.primary,
onPrimary = onColor(theme.primary),
secondary = theme.secondary,
onSecondary = onColor(theme.secondary),
tertiary = theme.tertiary,
onTertiary = onColor(theme.tertiary),
background = theme.bg,
onBackground = onBg,
surface = theme.surface,
onSurface = onSurface,
surfaceVariant = surfaceVariant,
onSurfaceVariant = onSurface.copy(alpha = 0.85f),
outline = onSurface.copy(alpha = 0.35f),
)
}
MaterialTheme(colorScheme = scheme, typography = AppType, content = content)
}
// Keep the unused-import guard away from isSystemInDarkTheme if a future
// "system" theme is added; referenced here so the import stays intentional.
@Suppress("unused")
private val darkFollowSystem: @Composable () -> Boolean = { isSystemInDarkTheme() }

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- ComponentActivity (activity-compose) doesn't need an AppCompat theme,
so use a platform Material NoActionBar theme; Compose paints the rest.
The dark window background avoids a white flash before first frame. -->
<style name="Theme.Catacomb" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>