diff --git a/.forgejo/workflows/android.yml b/.forgejo/workflows/android.yml new file mode 100644 index 00000000..cf8e28df --- /dev/null +++ b/.forgejo/workflows/android.yml @@ -0,0 +1,150 @@ +name: android + +# Native Android (Kotlin/Compose/Media3) — M8 rewrite of the Flutter client. +# Lives side-by-side with flutter_client/ until cutover; both APKs ship from +# their respective release artifacts (minstrel-.apk = Flutter, +# minstrel-android-.apk = native) during the transition. + +on: + push: + branches: [main, dev] + tags: ['v*'] + paths: + - 'android/**' + - '.forgejo/workflows/android.yml' + pull_request: + branches: [main] + paths: + - 'android/**' + +env: + # Silences the JDK 22+ "restricted method in java.lang.System has been + # called" warning that Gradle 9.1's bundled native-platform jar trips + # at launch (System.load for native primitives). Affects the LAUNCHER + # JVM, not the daemon — that's why org.gradle.jvmargs in + # gradle.properties isn't enough. Future-compat: required opt-in once + # JDK 25 promotes the warning to an error. + JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED" + +jobs: + build: + name: Build + lint + test + # Using flutter-ci runner label because it's the only proven-working + # label with docker that can pull our container.image. Switch to + # android-ci once the operator registers that runner label. + runs-on: flutter-ci + container: + image: git.fabledsword.com/bvandeusen/ci-android:36 + + defaults: + run: + working-directory: android + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Cache Gradle dirs + # Resolved deps + Gradle distribution + Kotlin daemon caches. + # Saves ~3 min per CI run after the first warm-up. + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + ~/.kotlin + key: gradle-${{ runner.os }}-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Gradle wrapper validation + run: ./gradlew --version + + - name: ktlint + run: ./gradlew ktlintCheck + + - name: detekt + run: ./gradlew detekt + + - name: Unit tests + run: ./gradlew testDebugUnitTest + + - name: Assemble debug + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: ./gradlew assembleDebug + + - name: Upload debug APK + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: forgejo/upload-artifact@v3 + with: + name: minstrel-android-debug-${{ github.sha }} + path: android/app/build/outputs/apk/debug/app-debug.apk + + release: + name: Build signed release APK + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: flutter-ci + container: + image: git.fabledsword.com/bvandeusen/ci-android:36 + + defaults: + run: + working-directory: android + + env: + ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Decode signing keystore + # Reuses the same keystore env-var contract as flutter.yml so the + # native APK is signed with the same key — Android will accept + # the upgrade in place at cutover without uninstall. + env: + ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} + shell: bash + run: | + if [ -z "${ANDROID_KEYSTORE_B64}" ]; then + echo "::error::ANDROID_KEYSTORE_B64 missing"; exit 1 + fi + KEYSTORE_PATH="${RUNNER_TEMP}/minstrel-release.keystore" + echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "${KEYSTORE_PATH}" + echo "ANDROID_KEYSTORE_PATH=${KEYSTORE_PATH}" >> "${GITHUB_ENV}" + + - name: Build release APK + run: ./gradlew assembleRelease + + - name: Attach APK to release + # During the side-by-side period the asset name is + # minstrel-android-.apk so it doesn't collide with the + # Flutter app's minstrel-.apk. At cutover, flip this name + # to minstrel-.apk and deactivate the flutter.yml release- + # attach step (M8 phase 14.4). + shell: bash + env: + CI_TOKEN: ${{ secrets.CI_TOKEN }} + run: | + TAG="${GITHUB_REF#refs/tags/}" + REPO="${GITHUB_REPOSITORY}" + RELEASE_ID=$(curl -fsSL \ + -H "Authorization: token ${CI_TOKEN}" \ + "https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}" \ + | grep -oP '"id":\s*\K[0-9]+' | head -1) + if [ -z "${RELEASE_ID}" ]; then + echo "::error::release for ${TAG} not found"; exit 1 + fi + curl -fsSL \ + -H "Authorization: token ${CI_TOKEN}" \ + -F "attachment=@app/build/outputs/apk/release/app-release.apk" \ + "https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-android-${TAG}.apk" diff --git a/.gitignore b/.gitignore index 1d68ff13..f0261d93 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,13 @@ flutter_client/android/app/build/ flutter_client/android/local.properties flutter_client/android/key.properties flutter_client/*.iml + +# Native Android (Kotlin/Compose) — M8 rewrite +android/.gradle/ +android/.kotlin/ +android/.idea/ +android/build/ +android/app/build/ +android/local.properties +android/*.iml +android/app/*.iml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..892e0558 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,71 @@ +# Minstrel — Claude working instructions + +## Porting discipline (HARD RULE — overrides default behavior) + +The Android native client (`android/`) is a **port of the Flutter client** +(`flutter_client/`). The Flutter client is the source of truth for behavior, +layout, copy, and data flow. When building or fixing any Android feature that +exists in Flutter: + +1. **Read the Flutter source FIRST.** Open the actual `flutter_client/lib/...` + file(s) for the feature before writing any Kotlin. Do not work from memory, + from a summary, or from an assumption about "the shape." If you have not + opened the Flutter file in this session, you have not earned the right to + write the Android version. + +2. **Replicate the user-visible behavior exactly; implement it idiomatically.** + Match what the user sees and feels: layout structure, section order, empty/ + loading/error states, copy strings, edge cases, and — critically — the + **responsiveness** (the app caches to make the UI feel instant; preserve + that). But you do NOT have to copy Flutter's *implementation*. Flutter uses + drift `watch()` + Riverpod `invalidate`; the well-supported Android idioms + are Room + Flow, Compose state, WorkManager, Media3. **Prefer the native + mechanism that delivers the same or better UX** over a literal transliteration + of the Dart. Exact on behavior + feel; idiomatic on structure. If a more + native approach genuinely improves the experience, do that (and note it in + the parity map as an intentional, better-supported divergence). + +3. **Never silently substitute a different design.** If the faithful port seems + hard, blocked, or impossible, STOP and verify by reading more of the Flutter + source + the Android data layer. The blocker is usually wrong — the data or + API you think is missing is often already there (e.g. `audio_cache_index` + already carried `lastPlayedAt`; the offline pool was never actually blocked). + If after reading it is genuinely blocked, **raise it as a question** rather + than shipping a scoped-down or alternative design. + +4. **Quote the Flutter file when you start a feature.** Lead the work with + "Here's `flutter_client/lib/` — here's what it does — here's the + Android port," so divergence is caught before code is written, not after. + +5. **Keep the parity map current.** `docs/superpowers/parity-map.md` maps every + feature → Flutter source path → Android target → status. Re-read the relevant + row before porting; update it after. This is the durable reference — rely on + it, not on session memory, because context gets compacted on long sessions. + +## Repo conventions (already in force) + +- **No GitHub — Forgejo only.** PR/issue ops via the forgejo MCP; CI runs under + `.forgejo/workflows/`. Never use `gh` or github.com URLs. +- **Git flow:** work on `dev` → PR to protected `main` → tag release. Never push + to `main`. +- **No in-task tests/builds.** Do not run flutter/gradle/npm `test`/`build`/ + `analyze` during implementation — CI verifies. Codegen scripts only. +- **CI is operator-side.** After `git push origin dev`, the operator reports the + result; don't poll Forgejo. +- **Specs/plans/audits/parity-map live local.** `docs/superpowers/` is + `.gitignored` — save there for operator review; never commit those. +- **detekt gates CI.** Watch the recurring ones: 60-line `LongMethod`, + 11-function-per-file `TooManyFunctions` (use `@file:Suppress` with a one-line + rationale for Compose-helper density), `ReturnCount` ≤ 2, `MagicNumber`, + `MatchingDeclarationName`. Keep lines ≤ 100 chars. + +## Android port shape (quick reference) + +- Screens: `*Screen.kt` with the `@HiltViewModel` inline at the top of the file. +- Repositories: `*/data/*Repository.kt`. Wire models: `models/wire/*Wire.kt`. + Domain models: `models/*.kt`. +- App-lifetime singletons start via the "construct-the-singleton trick" — an + `@Inject lateinit var` in `MinstrelApplication` whose `init {}` wires up. +- Server errors are `{"error":{"code":"...","message":"..."}}`; surface them + through `ErrorCopy.fromThrowable(e)`, never raw `e.message`. +- Cross-device reactivity: collect `EventsStream.events` filtered by `kind`. diff --git a/android/.idea/.gitignore b/android/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/android/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/android/.idea/.name b/android/.idea/.name new file mode 100644 index 00000000..81ab2e5c --- /dev/null +++ b/android/.idea/.name @@ -0,0 +1 @@ +Minstrel \ No newline at end of file diff --git a/android/.idea/AndroidProjectSystem.xml b/android/.idea/AndroidProjectSystem.xml new file mode 100644 index 00000000..4a53bee8 --- /dev/null +++ b/android/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/android/.idea/caches/deviceStreaming.xml b/android/.idea/caches/deviceStreaming.xml new file mode 100644 index 00000000..d469d46c --- /dev/null +++ b/android/.idea/caches/deviceStreaming.xml @@ -0,0 +1,1844 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/codeStyles/Project.xml b/android/.idea/codeStyles/Project.xml new file mode 100644 index 00000000..7643783a --- /dev/null +++ b/android/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/android/.idea/codeStyles/codeStyleConfig.xml b/android/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 00000000..79ee123c --- /dev/null +++ b/android/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/android/.idea/compiler.xml b/android/.idea/compiler.xml new file mode 100644 index 00000000..b86273d9 --- /dev/null +++ b/android/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/deploymentTargetSelector.xml b/android/.idea/deploymentTargetSelector.xml new file mode 100644 index 00000000..ca16a995 --- /dev/null +++ b/android/.idea/deploymentTargetSelector.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/.idea/deviceManager.xml b/android/.idea/deviceManager.xml new file mode 100644 index 00000000..91f95584 --- /dev/null +++ b/android/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/gradle.xml b/android/.idea/gradle.xml new file mode 100644 index 00000000..639c779c --- /dev/null +++ b/android/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/android/.idea/migrations.xml b/android/.idea/migrations.xml new file mode 100644 index 00000000..f8051a6f --- /dev/null +++ b/android/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml new file mode 100644 index 00000000..74dd639e --- /dev/null +++ b/android/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/.idea/runConfigurations.xml b/android/.idea/runConfigurations.xml new file mode 100644 index 00000000..16660f1d --- /dev/null +++ b/android/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/vcs.xml b/android/.idea/vcs.xml new file mode 100644 index 00000000..6c0b8635 --- /dev/null +++ b/android/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 00000000..2535edf2 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,195 @@ +plugins { + alias(libs.plugins.android.application) + // kotlin-android NOT applied: AGP 9 enables built-in Kotlin by default, + // and KSP 2.3.x supports it (PR #2674, merged Oct 2025). serialization + // + compose-compiler are language-level Kotlin compiler plugins and + // still need explicit application. + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.ksp) + alias(libs.plugins.androidx.room) + alias(libs.plugins.hilt) + alias(libs.plugins.ktlint) + alias(libs.plugins.detekt) +} + +android { + namespace = "com.fabledsword.minstrel" + compileSdk = 36 + + defaultConfig { + applicationId = "com.fabledsword.minstrel" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0-native" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { useSupportLibrary = true } + } + + // Room schema export is handled by the androidx.room Gradle plugin via + // the room {} block below — replaces the legacy + // `ksp { arg("room.schemaLocation", ...) }` pattern in Room 2.7+. + + signingConfigs { + create("release") { + val keystorePath: String? = System.getenv("ANDROID_KEYSTORE_PATH") + if (!keystorePath.isNullOrEmpty()) { + storeFile = file(keystorePath) + storePassword = System.getenv("ANDROID_STORE_PASSWORD") + keyAlias = System.getenv("ANDROID_KEY_ALIAS") + keyPassword = System.getenv("ANDROID_KEY_PASSWORD") + } + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + signingConfig = + if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) { + signingConfigs.getByName("debug") + } else { + signingConfigs.getByName("release") + } + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + buildConfig = true + } + + packaging { + resources.excludes += + setOf( + "/META-INF/{AL2.0,LGPL2.1}", + "META-INF/LICENSE.md", + "META-INF/LICENSE-notice.md", + ) + } +} + +// Kotlin 2.x: `kotlinOptions { ... }` inside `android { }` is gone; the +// modern shape is the top-level `kotlin { compilerOptions { ... } }` block, +// which works whether Kotlin comes from AGP 9's built-in path or an +// explicit plugin alias. +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + // Opt into the future Kotlin behavior: annotations on + // constructor parameters apply to both the param AND the + // generated property/field. Without this flag, Kotlin 2.x + // emits a deprecation warning at every @Inject / + // @ApplicationContext / @ApplicationScope constructor- + // parameter use. Setting it now matches what becomes the + // default in Kotlin 2.3 (KT-73255). + freeCompilerArgs.add("-Xannotation-default-target=param-property") + } +} + +// Room schema export — generated JSON dumps live under android/app/schemas/ +// for migration-test fixtures. Replaces the legacy +// `ksp { arg("room.schemaLocation", ...) }` arg-passing. +room { + schemaDirectory("$projectDir/schemas") +} + +detekt { + toolVersion = libs.versions.detekt.get() + config.setFrom(files("$rootDir/config/detekt.yml")) + buildUponDefaultConfig = true + parallel = true + // `autoCorrect` was dropped in detekt 2.0's DSL options list. +} + +// detekt 2.0 moved its task types to the `dev.detekt.gradle` package and +// flipped `jvmTarget` to the Property API. Pin to 17 to match our actual +// bytecode target (compileOptions.targetCompatibility + +// kotlin.compilerOptions.jvmTarget). +tasks.withType().configureEach { + jvmTarget.set("17") +} +tasks.withType().configureEach { + jvmTarget.set("17") +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.process) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.nav.compose) + implementation(libs.androidx.hilt.nav.compose) + implementation(libs.androidx.hilt.work) + ksp(libs.androidx.hilt.compiler) + implementation(libs.androidx.work.runtime.ktx) + + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.material3) + implementation(libs.compose.ui.text.google.fonts) + debugImplementation(libs.compose.ui.tooling) + implementation(libs.compose.ui.tooling.preview) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler) + + implementation(libs.retrofit) + implementation(libs.retrofit.kotlinx.serialization.converter) + implementation(libs.okhttp) + implementation(libs.okhttp.logging) + implementation(libs.okhttp.sse) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.datetime) + + implementation(libs.media3.exoplayer) + implementation(libs.media3.session) + implementation(libs.media3.datasource.okhttp) + + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) + implementation(libs.androidx.palette) + implementation(libs.icons.lucide) + + implementation(libs.timber) + + testImplementation(libs.junit.jupiter) + testImplementation(libs.turbine) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.okhttp.mockwebserver) + // kotlin.test for assertEquals/assertNull/etc. — version managed by + // the applied Kotlin plugin so no explicit version pin needed. + testImplementation(kotlin("test")) + // Gradle 9 no longer auto-injects the JUnit Platform launcher; must + // be declared explicitly on the runtime classpath for useJUnitPlatform() + // to discover tests. + testRuntimeOnly(libs.junit.platform.launcher) + + androidTestImplementation(platform(libs.compose.bom)) + androidTestImplementation(libs.compose.ui.test) + // androidTest dep parity with the unit-test side; needed once we have + // instrumented tests that consume the same APIs. + androidTestImplementation(kotlin("test")) + androidTestImplementation(libs.kotlinx.coroutines.test) + debugImplementation(libs.compose.ui.test.manifest) +} + +tasks.withType { useJUnitPlatform() } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 00000000..837ed7eb --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,19 @@ +# Keep the entry classes +-keep class com.fabledsword.minstrel.MainActivity { *; } +-keep class com.fabledsword.minstrel.MinstrelApplication { *; } + +# kotlinx.serialization (from the kotlinx.serialization README) +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.AnnotationsKt +-keepclassmembers class kotlinx.serialization.json.** { + *** Companion; +} +-keepclasseswithmembers class kotlinx.serialization.json.** { + kotlinx.serialization.KSerializer serializer(...); +} + +# Hilt +-keep class * extends androidx.lifecycle.ViewModel { *; } + +# Media3 — keep player/exo classes +-keep class androidx.media3.** { *; } diff --git a/android/app/schemas/com.fabledsword.minstrel.cache.db.AppDatabase/2.json b/android/app/schemas/com.fabledsword.minstrel.cache.db.AppDatabase/2.json new file mode 100644 index 00000000..042a06f6 --- /dev/null +++ b/android/app/schemas/com.fabledsword.minstrel.cache.db.AppDatabase/2.json @@ -0,0 +1,645 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "f78c0a5166450f1f31fce7c1d625f87d", + "entities": [ + { + "tableName": "sync_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cursor` INTEGER NOT NULL, `lastSyncAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cursor", + "columnName": "cursor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncAt", + "columnName": "lastSyncAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortName` TEXT NOT NULL, `mbid` TEXT, `artistThumbPath` TEXT, `artistFanartPath` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mbid", + "columnName": "mbid", + "affinity": "TEXT" + }, + { + "fieldPath": "artistThumbPath", + "columnName": "artistThumbPath", + "affinity": "TEXT" + }, + { + "fieldPath": "artistFanartPath", + "columnName": "artistFanartPath", + "affinity": "TEXT" + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_albums", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `sortTitle` TEXT NOT NULL, `releaseDate` TEXT, `coverPath` TEXT, `mbid` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortTitle", + "columnName": "sortTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseDate", + "columnName": "releaseDate", + "affinity": "TEXT" + }, + { + "fieldPath": "coverPath", + "columnName": "coverPath", + "affinity": "TEXT" + }, + { + "fieldPath": "mbid", + "columnName": "mbid", + "affinity": "TEXT" + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `trackNumber` INTEGER, `discNumber` INTEGER, `filePath` TEXT, `fileFormat` TEXT, `genre` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationMs", + "columnName": "durationMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackNumber", + "columnName": "trackNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "discNumber", + "columnName": "discNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "filePath", + "columnName": "filePath", + "affinity": "TEXT" + }, + { + "fieldPath": "fileFormat", + "columnName": "fileFormat", + "affinity": "TEXT" + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_likes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `likedAt` INTEGER NOT NULL, PRIMARY KEY(`userId`, `entityType`, `entityId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityType", + "columnName": "entityType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "entityType", + "entityId" + ] + } + }, + { + "tableName": "cached_playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userId` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `isPublic` INTEGER NOT NULL, `coverPath` TEXT, `trackCount` INTEGER NOT NULL, `durationSec` INTEGER NOT NULL, `systemVariant` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPublic", + "columnName": "isPublic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverPath", + "columnName": "coverPath", + "affinity": "TEXT" + }, + { + "fieldPath": "trackCount", + "columnName": "trackCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durationSec", + "columnName": "durationSec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "systemVariant", + "columnName": "systemVariant", + "affinity": "TEXT" + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_playlist_tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `trackId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlistId`, `trackId`))", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackId", + "columnName": "trackId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlistId", + "trackId" + ] + } + }, + { + "tableName": "cached_quarantine_mine", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `reason` TEXT NOT NULL, `notes` TEXT, `createdAt` TEXT NOT NULL, `trackTitle` TEXT NOT NULL, `trackDurationMs` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `albumTitle` TEXT NOT NULL, `albumCoverArtPath` TEXT, `artistId` TEXT NOT NULL, `artistName` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`trackId`))", + "fields": [ + { + "fieldPath": "trackId", + "columnName": "trackId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackTitle", + "columnName": "trackTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackDurationMs", + "columnName": "trackDurationMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumTitle", + "columnName": "albumTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumCoverArtPath", + "columnName": "albumCoverArtPath", + "affinity": "TEXT" + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistName", + "columnName": "artistName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "trackId" + ] + } + }, + { + "tableName": "audio_cache_index", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `path` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, `lastPlayedAt` INTEGER, `source` TEXT NOT NULL, PRIMARY KEY(`trackId`))", + "fields": [ + { + "fieldPath": "trackId", + "columnName": "trackId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cachedAt", + "columnName": "cachedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "lastPlayedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "trackId" + ] + } + }, + { + "tableName": "cached_mutations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAttemptAt` INTEGER, `attempts` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAt", + "columnName": "lastAttemptAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "attempts", + "columnName": "attempts", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_resume_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_home_index", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`section` TEXT NOT NULL, `position` INTEGER NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`section`, `position`))", + "fields": [ + { + "fieldPath": "section", + "columnName": "section", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entityType", + "columnName": "entityType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "section", + "position" + ] + } + }, + { + "tableName": "auth_session", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `sessionCookie` TEXT, `baseUrl` TEXT NOT NULL, `userJson` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionCookie", + "columnName": "sessionCookie", + "affinity": "TEXT" + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userJson", + "columnName": "userJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f78c0a5166450f1f31fce7c1d625f87d')" + ] + } +} \ No newline at end of file diff --git a/android/app/schemas/com.fabledsword.minstrel.cache.db.AppDatabase/3.json b/android/app/schemas/com.fabledsword.minstrel.cache.db.AppDatabase/3.json new file mode 100644 index 00000000..04fd4976 --- /dev/null +++ b/android/app/schemas/com.fabledsword.minstrel.cache.db.AppDatabase/3.json @@ -0,0 +1,650 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "4c6180b4cb157afbc109f26f39719439", + "entities": [ + { + "tableName": "sync_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cursor` INTEGER NOT NULL, `lastSyncAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cursor", + "columnName": "cursor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncAt", + "columnName": "lastSyncAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortName` TEXT NOT NULL, `mbid` TEXT, `artistThumbPath` TEXT, `artistFanartPath` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mbid", + "columnName": "mbid", + "affinity": "TEXT" + }, + { + "fieldPath": "artistThumbPath", + "columnName": "artistThumbPath", + "affinity": "TEXT" + }, + { + "fieldPath": "artistFanartPath", + "columnName": "artistFanartPath", + "affinity": "TEXT" + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_albums", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `sortTitle` TEXT NOT NULL, `releaseDate` TEXT, `coverPath` TEXT, `mbid` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortTitle", + "columnName": "sortTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseDate", + "columnName": "releaseDate", + "affinity": "TEXT" + }, + { + "fieldPath": "coverPath", + "columnName": "coverPath", + "affinity": "TEXT" + }, + { + "fieldPath": "mbid", + "columnName": "mbid", + "affinity": "TEXT" + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `trackNumber` INTEGER, `discNumber` INTEGER, `filePath` TEXT, `fileFormat` TEXT, `genre` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationMs", + "columnName": "durationMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackNumber", + "columnName": "trackNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "discNumber", + "columnName": "discNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "filePath", + "columnName": "filePath", + "affinity": "TEXT" + }, + { + "fieldPath": "fileFormat", + "columnName": "fileFormat", + "affinity": "TEXT" + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_likes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `likedAt` INTEGER NOT NULL, PRIMARY KEY(`userId`, `entityType`, `entityId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityType", + "columnName": "entityType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "entityType", + "entityId" + ] + } + }, + { + "tableName": "cached_playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userId` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `isPublic` INTEGER NOT NULL, `coverPath` TEXT, `trackCount` INTEGER NOT NULL, `durationSec` INTEGER NOT NULL, `systemVariant` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPublic", + "columnName": "isPublic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverPath", + "columnName": "coverPath", + "affinity": "TEXT" + }, + { + "fieldPath": "trackCount", + "columnName": "trackCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durationSec", + "columnName": "durationSec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "systemVariant", + "columnName": "systemVariant", + "affinity": "TEXT" + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_playlist_tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `trackId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlistId`, `trackId`))", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackId", + "columnName": "trackId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlistId", + "trackId" + ] + } + }, + { + "tableName": "cached_quarantine_mine", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `reason` TEXT NOT NULL, `notes` TEXT, `createdAt` TEXT NOT NULL, `trackTitle` TEXT NOT NULL, `trackDurationMs` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `albumTitle` TEXT NOT NULL, `albumCoverArtPath` TEXT, `artistId` TEXT NOT NULL, `artistName` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`trackId`))", + "fields": [ + { + "fieldPath": "trackId", + "columnName": "trackId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackTitle", + "columnName": "trackTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackDurationMs", + "columnName": "trackDurationMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumTitle", + "columnName": "albumTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumCoverArtPath", + "columnName": "albumCoverArtPath", + "affinity": "TEXT" + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistName", + "columnName": "artistName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "trackId" + ] + } + }, + { + "tableName": "audio_cache_index", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `path` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, `lastPlayedAt` INTEGER, `source` TEXT NOT NULL, PRIMARY KEY(`trackId`))", + "fields": [ + { + "fieldPath": "trackId", + "columnName": "trackId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cachedAt", + "columnName": "cachedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "lastPlayedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "trackId" + ] + } + }, + { + "tableName": "cached_mutations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAttemptAt` INTEGER, `attempts` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAt", + "columnName": "lastAttemptAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "attempts", + "columnName": "attempts", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_resume_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_home_index", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`section` TEXT NOT NULL, `position` INTEGER NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`section`, `position`))", + "fields": [ + { + "fieldPath": "section", + "columnName": "section", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entityType", + "columnName": "entityType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "section", + "position" + ] + } + }, + { + "tableName": "auth_session", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `sessionCookie` TEXT, `baseUrl` TEXT NOT NULL, `userJson` TEXT, `themeMode` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionCookie", + "columnName": "sessionCookie", + "affinity": "TEXT" + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userJson", + "columnName": "userJson", + "affinity": "TEXT" + }, + { + "fieldPath": "themeMode", + "columnName": "themeMode", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4c6180b4cb157afbc109f26f39719439')" + ] + } +} \ No newline at end of file diff --git a/android/app/schemas/com.fabledsword.minstrel.cache.db.AppDatabase/5.json b/android/app/schemas/com.fabledsword.minstrel.cache.db.AppDatabase/5.json new file mode 100644 index 00000000..3703bf99 --- /dev/null +++ b/android/app/schemas/com.fabledsword.minstrel.cache.db.AppDatabase/5.json @@ -0,0 +1,660 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "644920ed281564a77a383ecaea9b9fdc", + "entities": [ + { + "tableName": "sync_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cursor` INTEGER NOT NULL, `lastSyncAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cursor", + "columnName": "cursor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncAt", + "columnName": "lastSyncAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortName` TEXT NOT NULL, `mbid` TEXT, `artistThumbPath` TEXT, `artistFanartPath` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mbid", + "columnName": "mbid", + "affinity": "TEXT" + }, + { + "fieldPath": "artistThumbPath", + "columnName": "artistThumbPath", + "affinity": "TEXT" + }, + { + "fieldPath": "artistFanartPath", + "columnName": "artistFanartPath", + "affinity": "TEXT" + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_albums", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `sortTitle` TEXT NOT NULL, `releaseDate` TEXT, `coverPath` TEXT, `mbid` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortTitle", + "columnName": "sortTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseDate", + "columnName": "releaseDate", + "affinity": "TEXT" + }, + { + "fieldPath": "coverPath", + "columnName": "coverPath", + "affinity": "TEXT" + }, + { + "fieldPath": "mbid", + "columnName": "mbid", + "affinity": "TEXT" + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `trackNumber` INTEGER, `discNumber` INTEGER, `filePath` TEXT, `fileFormat` TEXT, `genre` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationMs", + "columnName": "durationMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackNumber", + "columnName": "trackNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "discNumber", + "columnName": "discNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "filePath", + "columnName": "filePath", + "affinity": "TEXT" + }, + { + "fieldPath": "fileFormat", + "columnName": "fileFormat", + "affinity": "TEXT" + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_likes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `likedAt` INTEGER NOT NULL, PRIMARY KEY(`userId`, `entityType`, `entityId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityType", + "columnName": "entityType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "entityType", + "entityId" + ] + } + }, + { + "tableName": "cached_playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userId` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `isPublic` INTEGER NOT NULL, `coverPath` TEXT, `trackCount` INTEGER NOT NULL, `durationSec` INTEGER NOT NULL, `systemVariant` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPublic", + "columnName": "isPublic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverPath", + "columnName": "coverPath", + "affinity": "TEXT" + }, + { + "fieldPath": "trackCount", + "columnName": "trackCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durationSec", + "columnName": "durationSec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "systemVariant", + "columnName": "systemVariant", + "affinity": "TEXT" + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_playlist_tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `trackId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlistId`, `trackId`))", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackId", + "columnName": "trackId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlistId", + "trackId" + ] + } + }, + { + "tableName": "cached_quarantine_mine", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `reason` TEXT NOT NULL, `notes` TEXT, `createdAt` TEXT NOT NULL, `trackTitle` TEXT NOT NULL, `trackDurationMs` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `albumTitle` TEXT NOT NULL, `albumCoverArtPath` TEXT, `artistId` TEXT NOT NULL, `artistName` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`trackId`))", + "fields": [ + { + "fieldPath": "trackId", + "columnName": "trackId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackTitle", + "columnName": "trackTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackDurationMs", + "columnName": "trackDurationMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumTitle", + "columnName": "albumTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumCoverArtPath", + "columnName": "albumCoverArtPath", + "affinity": "TEXT" + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistName", + "columnName": "artistName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "trackId" + ] + } + }, + { + "tableName": "audio_cache_index", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `path` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, `lastPlayedAt` INTEGER, `source` TEXT NOT NULL, PRIMARY KEY(`trackId`))", + "fields": [ + { + "fieldPath": "trackId", + "columnName": "trackId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cachedAt", + "columnName": "cachedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "lastPlayedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "trackId" + ] + } + }, + { + "tableName": "cached_mutations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAttemptAt` INTEGER, `attempts` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAt", + "columnName": "lastAttemptAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "attempts", + "columnName": "attempts", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_resume_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_home_index", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`section` TEXT NOT NULL, `position` INTEGER NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`section`, `position`))", + "fields": [ + { + "fieldPath": "section", + "columnName": "section", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entityType", + "columnName": "entityType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "section", + "position" + ] + } + }, + { + "tableName": "auth_session", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `sessionCookie` TEXT, `baseUrl` TEXT NOT NULL, `userJson` TEXT, `themeMode` TEXT, `clientId` TEXT, `cacheSettingsJson` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionCookie", + "columnName": "sessionCookie", + "affinity": "TEXT" + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userJson", + "columnName": "userJson", + "affinity": "TEXT" + }, + { + "fieldPath": "themeMode", + "columnName": "themeMode", + "affinity": "TEXT" + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT" + }, + { + "fieldPath": "cacheSettingsJson", + "columnName": "cacheSettingsJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '644920ed281564a77a383ecaea9b9fdc')" + ] + } +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..fec18bc4 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt new file mode 100644 index 00000000..cda6d5dc --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt @@ -0,0 +1,88 @@ +package com.fabledsword.minstrel + +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.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.rememberNavController +import com.fabledsword.minstrel.auth.ui.AuthGateViewModel +import com.fabledsword.minstrel.cache.CachedTrackIds +import com.fabledsword.minstrel.nav.DetailSeedCache +import com.fabledsword.minstrel.nav.LocalDetailSeedCache +import com.fabledsword.minstrel.nav.MinstrelNavGraph +import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds +import com.fabledsword.minstrel.theme.MinstrelTheme +import com.fabledsword.minstrel.theme.ThemePreferenceViewModel +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + @Inject lateinit var seedCache: DetailSeedCache + @Inject lateinit var cachedTrackIds: CachedTrackIds + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { App(seedCache = seedCache, cachedTrackIds = cachedTrackIds) } + } +} + +@Composable +private fun App( + seedCache: DetailSeedCache, + cachedTrackIds: CachedTrackIds, + themeVm: ThemePreferenceViewModel = hiltViewModel(), + gate: AuthGateViewModel = hiltViewModel(), +) { + val theme by themeVm.themeMode.collectAsStateWithLifecycle() + val cached by cachedTrackIds.ids.collectAsStateWithLifecycle() + MinstrelTheme(darkOverride = theme.toDarkOverride()) { + CompositionLocalProvider( + LocalDetailSeedCache provides seedCache, + LocalCachedTrackIds provides cached, + ) { + val startDestination by gate.startDestination.collectAsStateWithLifecycle() + val resolved = startDestination + if (resolved == null) { + BootSplash() + } else { + // No bottom nav, no drawer — navigation is per-screen via + // the AppBar actions row + kebab (`MainAppBarActions`). The + // MiniPlayer is included by each in-shell route's + // `ShellScaffold` wrap; full-screen routes (NowPlaying / + // Queue / unauthenticated) bypass the shell entirely. + val navController = rememberNavController() + MinstrelNavGraph( + navController = navController, + startDestination = resolved, + modifier = Modifier.fillMaxSize(), + ) + } + } + } +} + +@Composable +private fun BootSplash() { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt new file mode 100644 index 00000000..522f6e84 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -0,0 +1,172 @@ +package com.fabledsword.minstrel + +import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import coil3.ImageLoader +import coil3.SingletonImageLoader +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import com.fabledsword.minstrel.cache.CacheIndexer +import com.fabledsword.minstrel.cache.mutations.MutationReplayer +import com.fabledsword.minstrel.cache.sync.SyncController +import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.events.EventsStream +import com.fabledsword.minstrel.events.LiveEventsDispatcher +import com.fabledsword.minstrel.metadata.FreshnessSweeper +import com.fabledsword.minstrel.player.CoverPrefetcher +import com.fabledsword.minstrel.player.PlayEventsReporter +import com.fabledsword.minstrel.player.PlaybackErrorReporter +import com.fabledsword.minstrel.player.ResumeController +import com.fabledsword.minstrel.update.data.UpdateBannerController +import com.fabledsword.minstrel.update.data.VersionCheckController +import dagger.hilt.android.HiltAndroidApp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import timber.log.Timber +import javax.inject.Inject + +@HiltAndroidApp +class MinstrelApplication : + Application(), + Configuration.Provider, + SingletonImageLoader.Factory { + + @Inject lateinit var workerFactory: HiltWorkerFactory + + /** + * The single shared OkHttp client (with the auth-cookie interceptor) + * — also handed to Coil so cover-art requests carry the session + * cookie and reuse the same connection pool + TLS config. + */ + @Inject lateinit var okHttpClient: OkHttpClient + + /** + * Injecting ResumeController forces Hilt to construct the singleton + * so its init block (observe + persist on track change) wires up at + * app launch. We also fire `restore()` from onCreate so a torn-down + * player resumes the last queue without requiring UI interaction. + */ + @Inject lateinit var resumeController: ResumeController + + /** + * Same construct-the-singleton trick — SyncController's init block + * subscribes to AuthStore.sessionCookie and fires `/api/library/sync` + * whenever the cookie transitions to a value (fresh sign-in OR + * cold start with persisted cookie). Without this @Inject the + * singleton would never instantiate. + */ + @Suppress("unused") @Inject lateinit var syncController: SyncController + + /** + * Same construct-the-singleton trick — MutationReplayer's init + * block subscribes to AuthStore.sessionCookie and drains the + * offline write queue on every signed-in transition (sign-in OR + * cold start with persisted cookie). Without this @Inject the + * queue would only enqueue, never replay. + */ + @Suppress("unused") @Inject lateinit var mutationReplayer: MutationReplayer + + /** + * Same construct-the-singleton trick — PlayEventsReporter's init + * block subscribes to PlayerController.uiState and reports the + * play-event lifecycle (started/ended/skipped/offline) to the + * server. Without this @Inject the singleton would never + * instantiate and Android plays would never reach the server. + */ + @Suppress("unused") @Inject lateinit var playEventsReporter: PlayEventsReporter + + /** + * Same construct-the-singleton trick — PlaybackErrorReporter + * subscribes to PlayerController.playbackErrorEvents and emits + * coalesced "Couldn't play X" / "Skipped N tracks" messages on + * its own Flow, which ShellScaffold surfaces in the shared + * snackbar. Without this construct ExoPlayer skip-on-error is + * invisible to the user. + */ + @Suppress("unused") @Inject lateinit var playbackErrorReporter: PlaybackErrorReporter + + /** + * Same construct-the-singleton trick — CoverPrefetcher's init + * block subscribes to PlayerController.uiState and warms Coil's + * cache with the next track's cover so track changes don't flash + * a loading state. + */ + @Suppress("unused") @Inject lateinit var coverPrefetcher: CoverPrefetcher + + /** + * Same construct-the-singleton trick — CacheIndexer's init block + * subscribes to PlayerController.uiState and records each played + * track into `audio_cache_index`, giving the offline shuffle pools + * and the cached indicator their data source. Without this @Inject + * the index would stay empty and the offline pools would be inert. + */ + @Suppress("unused") @Inject lateinit var cacheIndexer: CacheIndexer + + /** + * Same construct-the-singleton trick — VersionCheckController's + * init block starts a 5-min poll loop against /healthz so the + * shell-level VersionTooOldBanner can surface min_client_version + * mismatches without waiting for the next user-driven request. + */ + @Suppress("unused") @Inject lateinit var versionCheckController: VersionCheckController + + /** + * Same construct-the-singleton trick — UpdateBannerController polls + * /api/client/version at launch + every 24h and drives the shell's + * soft "update available" banner. Without this @Inject the poll + * loop would never start and the banner would never surface. + */ + @Suppress("unused") @Inject lateinit var updateBannerController: UpdateBannerController + + /** + * Same construct-the-singleton trick — FreshnessSweeper's init + * block starts a 30-min sweep loop that re-fetches cached + * album/artist/track rows whose `fetchedAt` is older than 24h + * via MetadataProvider, so the cache stays warm without + * user-driven pull-to-refresh. + */ + @Suppress("unused") @Inject lateinit var freshnessSweeper: FreshnessSweeper + + /** + * Same construct-the-singleton trick — EventsStream's init block + * subscribes to AuthStore.sessionCookie and opens / closes the + * `/api/events/stream` SSE subscription on auth transitions. + * Without this @Inject the events SharedFlow would have no + * upstream and consumers would never see anything. + */ + @Suppress("unused") @Inject lateinit var eventsStream: EventsStream + + /** + * Same construct-the-singleton trick — LiveEventsDispatcher's + * init block subscribes to EventsStream.events + the process + * lifecycle and routes cross-screen state refreshes. + */ + @Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher + + @Inject @ApplicationScope lateinit var appScope: CoroutineScope + + override fun onCreate() { + super.onCreate() + if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) + appScope.launch { resumeController.restore() } + } + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() + + /** + * Builds the process-singleton Coil ImageLoader with our shared + * OkHttp client as the network fetcher. The `callFactory` lambda + * is invoked lazily so Hilt has time to inject `okHttpClient` + * before Coil makes its first request. + */ + override fun newImageLoader(context: android.content.Context): ImageLoader = + ImageLoader.Builder(context) + .components { + add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient })) + } + .build() +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminInvitesRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminInvitesRepository.kt new file mode 100644 index 00000000..761bcf83 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminInvitesRepository.kt @@ -0,0 +1,38 @@ +package com.fabledsword.minstrel.admin.data + +import com.fabledsword.minstrel.api.endpoints.AdminInvitesApi +import com.fabledsword.minstrel.api.endpoints.CreateInviteBody +import com.fabledsword.minstrel.models.Invite +import com.fabledsword.minstrel.models.wire.InviteWire +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Thin facade over `/api/admin/invites`. Same shape as the other + * admin repos — direct REST, no caching. + */ +@Singleton +class AdminInvitesRepository @Inject constructor(retrofit: Retrofit) { + private val api: AdminInvitesApi = retrofit.create() + + suspend fun list(): List = api.list().invites.map { it.toDomain() } + + suspend fun create(note: String?): Invite = + api.create(CreateInviteBody(note = note?.takeIf { it.isNotEmpty() })).toDomain() + + suspend fun revoke(token: String) { + api.revoke(token) + } +} + +private fun InviteWire.toDomain(): Invite = Invite( + token = token, + invitedBy = invitedBy, + note = note, + createdAt = createdAt, + expiresAt = expiresAt, + redeemedAt = redeemedAt, + redeemedBy = redeemedBy, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminQuarantineRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminQuarantineRepository.kt new file mode 100644 index 00000000..f038163c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminQuarantineRepository.kt @@ -0,0 +1,57 @@ +package com.fabledsword.minstrel.admin.data + +import com.fabledsword.minstrel.api.endpoints.AdminQuarantineApi +import com.fabledsword.minstrel.models.AdminQuarantineItemRef +import com.fabledsword.minstrel.models.AdminQuarantineReportRef +import com.fabledsword.minstrel.models.wire.AdminQuarantineItemWire +import com.fabledsword.minstrel.models.wire.AdminQuarantineReportWire +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Read-through accessor for the admin quarantine queue. Mirrors the + * Flutter `AdminQuarantineController`. No Room cache — same rationale + * as AdminRequests. Three resolution actions all delete the row from + * the queue once the server accepts them. + */ +@Singleton +class AdminQuarantineRepository @Inject constructor( + retrofit: Retrofit, +) { + private val api: AdminQuarantineApi = retrofit.create() + + suspend fun list(): List = api.list().map { it.toDomain() } + + suspend fun resolve(trackId: String) = api.resolve(trackId) + + suspend fun deleteFile(trackId: String) = api.deleteFile(trackId) + + suspend fun deleteViaLidarr(trackId: String) = api.deleteViaLidarr(trackId) +} + +// ── Mappers ── + +private fun AdminQuarantineReportWire.toDomain(): AdminQuarantineReportRef = + AdminQuarantineReportRef( + userId = userId, + username = username, + reason = reason, + notes = notes, + createdAt = createdAt, + ) + +private fun AdminQuarantineItemWire.toDomain(): AdminQuarantineItemRef = + AdminQuarantineItemRef( + trackId = trackId, + trackTitle = trackTitle, + artistName = artistName, + albumTitle = albumTitle, + albumId = albumId, + lidarrAlbumMbid = lidarrAlbumMbid, + reportCount = reportCount, + latestAt = latestAt, + reasonCounts = reasonCounts, + reports = reports.map { it.toDomain() }, + ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminRequestsRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminRequestsRepository.kt new file mode 100644 index 00000000..18587b41 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminRequestsRepository.kt @@ -0,0 +1,51 @@ +package com.fabledsword.minstrel.admin.data + +import com.fabledsword.minstrel.api.endpoints.AdminRequestsApi +import com.fabledsword.minstrel.models.RequestRef +import com.fabledsword.minstrel.models.RequestStatus +import com.fabledsword.minstrel.models.wire.RequestWire +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Read-through accessor for the admin cross-user requests queue. + * Mirrors `flutter_client/lib/admin/admin_providers.dart`'s + * AdminRequestsController. + * + * No Room caching — admin actions are infrequent and don't benefit + * from offline scrollback. `approve` and `reject` fire direct REST + * (no mutation queue) because operator confirmation is point-and-shoot; + * if the request fails the UI surfaces the error and rolls back. + */ +@Singleton +class AdminRequestsRepository @Inject constructor( + retrofit: Retrofit, +) { + private val api: AdminRequestsApi = retrofit.create() + + suspend fun list(): List = api.list().map { it.toDomain() } + + suspend fun approve(id: String) = api.approve(id) + + suspend fun reject(id: String) = api.reject(id) +} + +private fun RequestWire.toDomain(): RequestRef = RequestRef( + id = id, + userId = userId, + status = RequestStatus.fromWire(status), + kind = kind, + artistName = artistName, + albumTitle = albumTitle, + trackTitle = trackTitle, + requestedAt = requestedAt, + decidedAt = decidedAt, + notes = notes, + importedAlbumCount = importedAlbumCount, + importedTrackCount = importedTrackCount, + matchedTrackId = matchedTrackId, + matchedAlbumId = matchedAlbumId, + matchedArtistId = matchedArtistId, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminUsersRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminUsersRepository.kt new file mode 100644 index 00000000..02d609e7 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminUsersRepository.kt @@ -0,0 +1,46 @@ +package com.fabledsword.minstrel.admin.data + +import com.fabledsword.minstrel.api.endpoints.AdminUsersApi +import com.fabledsword.minstrel.api.endpoints.ResetPasswordBody +import com.fabledsword.minstrel.api.endpoints.SetAdminBody +import com.fabledsword.minstrel.api.endpoints.SetAutoApproveBody +import com.fabledsword.minstrel.models.AdminUserRef +import com.fabledsword.minstrel.models.wire.AdminUserWire +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Read-through accessor for the admin users list + the four per-user + * mutations. Mirrors Flutter's `AdminUsersController`. Same + * no-Room-cache, no-MutationQueue pattern as the other admin slices. + */ +@Singleton +class AdminUsersRepository @Inject constructor( + retrofit: Retrofit, +) { + private val api: AdminUsersApi = retrofit.create() + + suspend fun list(): List = api.list().users.map { it.toDomain() } + + suspend fun setAdmin(id: String, isAdmin: Boolean) = + api.setAdmin(id, SetAdminBody(isAdmin)) + + suspend fun setAutoApprove(id: String, autoApprove: Boolean) = + api.setAutoApprove(id, SetAutoApproveBody(autoApprove)) + + suspend fun resetPassword(id: String, newPassword: String) = + api.resetPassword(id, ResetPasswordBody(newPassword)) + + suspend fun delete(id: String) = api.delete(id) +} + +private fun AdminUserWire.toDomain(): AdminUserRef = AdminUserRef( + id = id, + username = username, + displayName = displayName, + isAdmin = isAdmin, + autoApproveRequests = autoApproveRequests, + createdAt = createdAt, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminInvitesViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminInvitesViewModel.kt new file mode 100644 index 00000000..5a70ae3a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminInvitesViewModel.kt @@ -0,0 +1,86 @@ +package com.fabledsword.minstrel.admin.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.admin.data.AdminInvitesRepository +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.models.Invite +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class AdminInvitesUiState( + val isLoading: Boolean = true, + val invites: List = emptyList(), + val message: String? = null, +) + +@HiltViewModel +class AdminInvitesViewModel @Inject constructor( + private val repository: AdminInvitesRepository, +) : ViewModel() { + + private val internal = MutableStateFlow(AdminInvitesUiState()) + val state: StateFlow = internal.asStateFlow() + + /** One-shot signal for screens to show the generated token + copy UI. */ + private val createdChannel = Channel(Channel.BUFFERED) + val created: Flow = createdChannel.receiveAsFlow() + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + internal.update { it.copy(isLoading = true, message = null) } + try { + val rows = repository.list() + internal.update { it.copy(isLoading = false, invites = rows) } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.update { + it.copy( + isLoading = false, + message = ErrorCopy.fromThrowable(e), + ) + } + } + } + } + + fun generate(note: String?) { + viewModelScope.launch { + try { + val invite = repository.create(note) + createdChannel.trySend(invite) + // Optimistically prepend; next refresh reconciles. + internal.update { it.copy(invites = listOf(invite) + it.invites) } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.update { + it.copy(message = ErrorCopy.fromThrowable(e)) + } + } + } + } + + fun revoke(token: String) { + val before = internal.value.invites + // Optimistic removal. + internal.update { it.copy(invites = before.filterNot { i -> i.token == token }) } + viewModelScope.launch { + runCatching { repository.revoke(token) } + .onFailure { refresh() } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt new file mode 100644 index 00000000..6f0b0a27 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt @@ -0,0 +1,219 @@ +package com.fabledsword.minstrel.admin.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavHostController +import com.composables.icons.lucide.Inbox +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.TriangleAlert +import com.composables.icons.lucide.Users +import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository +import com.fabledsword.minstrel.admin.data.AdminRequestsRepository +import com.fabledsword.minstrel.admin.data.AdminUsersRepository +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.nav.Admin +import com.fabledsword.minstrel.nav.AdminQuarantine +import com.fabledsword.minstrel.nav.AdminRequests +import com.fabledsword.minstrel.nav.AdminUsers +import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.LoadingCentered +import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +// ─── State ─────────────────────────────────────────────────────────── + +data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int) + +sealed interface AdminLandingUiState { + data object Loading : AdminLandingUiState + data class Success(val counts: AdminCounts) : AdminLandingUiState + data class Error(val message: String) : AdminLandingUiState +} + +// ─── ViewModel ─────────────────────────────────────────────────────── + +@HiltViewModel +class AdminLandingViewModel @Inject constructor( + private val requestsRepo: AdminRequestsRepository, + private val quarantineRepo: AdminQuarantineRepository, + private val usersRepo: AdminUsersRepository, +) : ViewModel() { + + private val internal = MutableStateFlow(AdminLandingUiState.Loading) + val uiState: StateFlow = internal.asStateFlow() + + init { + refresh() + } + + fun refresh(): Job = viewModelScope.launch { + internal.value = AdminLandingUiState.Loading + try { + val counts = coroutineScope { + val req = async { requestsRepo.list().size } + val qua = async { quarantineRepo.list().size } + val usr = async { usersRepo.list().size } + AdminCounts(req.await(), qua.await(), usr.await()) + } + internal.value = AdminLandingUiState.Success(counts) + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.value = AdminLandingUiState.Error( + ErrorCopy.fromThrowable(e), + ) + } + } +} + +// ─── Screen ────────────────────────────────────────────────────────── + +@Composable +fun AdminLandingScreen( + navController: NavHostController, + viewModel: AdminLandingViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + MinstrelTopAppBar( + title = "Admin", + navController = navController, + currentRouteName = Admin::class.qualifiedName, + onBack = { navController.popBackStack() }, + ) + }, + ) { inner -> + PullToRefreshScaffold( + onRefresh = { viewModel.refresh().join() }, + modifier = Modifier.fillMaxSize().padding(inner), + ) { + when (val s = state) { + AdminLandingUiState.Loading -> LoadingCentered() + is AdminLandingUiState.Error -> EmptyState( + title = "Couldn't load admin counts", + body = s.message, + ) + is AdminLandingUiState.Success -> SectionList( + counts = s.counts, + navController = navController, + ) + } + } + } +} + +@Composable +private fun SectionList(counts: AdminCounts, navController: NavHostController) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + SectionCard( + icon = Lucide.Inbox, + title = "Requests", + subtitle = "Approve or reject Lidarr requests", + count = counts.requests, + onClick = { navController.navigate(AdminRequests) }, + ) + } + item { + SectionCard( + icon = Lucide.TriangleAlert, + title = "Quarantine", + subtitle = "Resolve scan failures", + count = counts.quarantine, + onClick = { navController.navigate(AdminQuarantine) }, + ) + } + item { + SectionCard( + icon = Lucide.Users, + title = "Users", + subtitle = "Manage accounts and invites", + count = counts.users, + onClick = { navController.navigate(AdminUsers) }, + ) + } + } +} + +@Composable +private fun SectionCard( + icon: ImageVector, + title: String, + subtitle: String, + count: Int, + onClick: () -> Unit, +) { + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = "$count", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + ) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineScreen.kt new file mode 100644 index 00000000..db4f9c43 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineScreen.kt @@ -0,0 +1,173 @@ +package com.fabledsword.minstrel.admin.ui + +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.fabledsword.minstrel.models.AdminQuarantineItemRef +import com.fabledsword.minstrel.nav.AdminQuarantine +import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.LoadingCentered +import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdminQuarantineScreen( + navController: NavHostController, + viewModel: AdminQuarantineViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + MinstrelTopAppBar( + title = "Admin · Quarantine", + navController = navController, + currentRouteName = AdminQuarantine::class.qualifiedName, + onBack = { navController.popBackStack() }, + ) + }, + ) { inner -> + PullToRefreshScaffold( + onRefresh = { viewModel.refresh().join() }, + modifier = Modifier.fillMaxSize().padding(inner), + ) { + when (val s = state) { + AdminQuarantineUiState.Loading -> LoadingCentered() + AdminQuarantineUiState.Empty -> EmptyState( + title = "Queue is empty", + body = "When users flag tracks as bad rips, wrong tags, or " + + "duplicates, their reports get aggregated and surfaced here.", + ) + is AdminQuarantineUiState.Error -> EmptyState( + title = "Couldn't load queue", + body = s.message, + ) + is AdminQuarantineUiState.Success -> QueueList( + rows = s.rows, + onResolve = viewModel::resolve, + onDeleteFile = viewModel::deleteFile, + onDeleteViaLidarr = viewModel::deleteViaLidarr, + ) + } + } + } +} + +@Composable +private fun QueueList( + rows: List, + onResolve: (String) -> Unit, + onDeleteFile: (String) -> Unit, + onDeleteViaLidarr: (String) -> Unit, +) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(items = rows, key = { it.trackId }) { row -> + QuarantineRow( + row = row, + onResolve = { onResolve(row.trackId) }, + onDeleteFile = { onDeleteFile(row.trackId) }, + onDeleteViaLidarr = { onDeleteViaLidarr(row.trackId) }, + ) + HorizontalDivider() + } + } +} + +@Composable +private fun QuarantineRow( + row: AdminQuarantineItemRef, + onResolve: () -> Unit, + onDeleteFile: () -> Unit, + onDeleteViaLidarr: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = row.trackTitle, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${row.artistName} · ${row.albumTitle}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + AssistChip( + onClick = {}, + enabled = false, + label = { + Text( + text = "${row.reportCount} reports", + style = MaterialTheme.typography.labelSmall, + ) + }, + ) + if (row.topReasonSummary.isNotEmpty()) { + AssistChip( + onClick = {}, + enabled = false, + label = { + Text( + text = row.topReasonSummary, + style = MaterialTheme.typography.labelSmall, + ) + }, + ) + } + } + ActionButtons( + onResolve = onResolve, + onDeleteFile = onDeleteFile, + onDeleteViaLidarr = onDeleteViaLidarr, + ) + } +} + +@Composable +private fun ActionButtons( + onResolve: () -> Unit, + onDeleteFile: () -> Unit, + onDeleteViaLidarr: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = onResolve) { Text("Resolve") } + OutlinedButton(onClick = onDeleteFile) { Text("Delete file") } + Button(onClick = onDeleteViaLidarr) { Text("Delete + Lidarr") } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineViewModel.kt new file mode 100644 index 00000000..f894aed8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineViewModel.kt @@ -0,0 +1,85 @@ +package com.fabledsword.minstrel.admin.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.events.EventsStream +import com.fabledsword.minstrel.models.AdminQuarantineItemRef +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import javax.inject.Inject + +sealed interface AdminQuarantineUiState { + data object Loading : AdminQuarantineUiState + data object Empty : AdminQuarantineUiState + data class Success(val rows: List) : AdminQuarantineUiState + data class Error(val message: String) : AdminQuarantineUiState +} + +@HiltViewModel +class AdminQuarantineViewModel @Inject constructor( + private val repository: AdminQuarantineRepository, + private val eventsStream: EventsStream, +) : ViewModel() { + + private val internal = MutableStateFlow(AdminQuarantineUiState.Loading) + val uiState: StateFlow = internal.asStateFlow() + + init { + refresh() + viewModelScope.launch { + eventsStream.events + .filter { it.kind.startsWith("quarantine.") } + .collect { refresh() } + } + } + + fun refresh(): Job = viewModelScope.launch { + internal.value = AdminQuarantineUiState.Loading + try { + val rows = repository.list() + internal.value = if (rows.isEmpty()) { + AdminQuarantineUiState.Empty + } else { + AdminQuarantineUiState.Success(rows) + } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.value = AdminQuarantineUiState.Error( + ErrorCopy.fromThrowable(e), + ) + } + } + + fun resolve(trackId: String) = act(trackId) { repository.resolve(it) } + fun deleteFile(trackId: String) = act(trackId) { repository.deleteFile(it) } + fun deleteViaLidarr(trackId: String) = act(trackId) { repository.deleteViaLidarr(it) } + + private fun act(trackId: String, action: suspend (String) -> Unit) { + val before = internal.value + if (before is AdminQuarantineUiState.Success) { + val filtered = before.rows.filterNot { it.trackId == trackId } + internal.value = if (filtered.isEmpty()) { + AdminQuarantineUiState.Empty + } else { + AdminQuarantineUiState.Success(filtered) + } + } + viewModelScope.launch { + try { + action(trackId) + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + refresh() + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsScreen.kt new file mode 100644 index 00000000..ff166749 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsScreen.kt @@ -0,0 +1,161 @@ +package com.fabledsword.minstrel.admin.ui + +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.fabledsword.minstrel.models.RequestRef +import com.fabledsword.minstrel.nav.AdminRequests +import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.LoadingCentered +import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdminRequestsScreen( + navController: NavHostController, + viewModel: AdminRequestsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + MinstrelTopAppBar( + title = "Admin · Requests", + navController = navController, + currentRouteName = AdminRequests::class.qualifiedName, + onBack = { navController.popBackStack() }, + ) + }, + ) { inner -> + PullToRefreshScaffold( + onRefresh = { viewModel.refresh().join() }, + modifier = Modifier.fillMaxSize().padding(inner), + ) { + when (val s = state) { + AdminRequestsUiState.Loading -> LoadingCentered() + AdminRequestsUiState.Empty -> EmptyState( + title = "No requests waiting", + body = "When users ask Lidarr for new music, their pending " + + "requests show up here for approval.", + ) + is AdminRequestsUiState.Error -> EmptyState( + title = "Couldn't load requests", + body = s.message, + ) + is AdminRequestsUiState.Success -> RequestList( + rows = s.rows, + usernames = s.usernames, + onApprove = viewModel::approve, + onReject = viewModel::reject, + ) + } + } + } +} + +@Composable +private fun RequestList( + rows: List, + usernames: Map, + onApprove: (String) -> Unit, + onReject: (String) -> Unit, +) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(items = rows, key = { it.id }) { req -> + AdminRequestRow( + req = req, + requester = usernames[req.userId] + ?: req.userId.takeIf { it.isNotEmpty() }?.take(USER_ID_PREFIX_LEN) + ?: "unknown", + onApprove = { onApprove(req.id) }, + onReject = { onReject(req.id) }, + ) + HorizontalDivider() + } + } +} + +@Composable +private fun AdminRequestRow( + req: RequestRef, + requester: String, + onApprove: () -> Unit, + onReject: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = req.displayName, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "Requested by $requester", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + AssistChip( + onClick = {}, + enabled = false, + label = { Text(req.kind, style = MaterialTheme.typography.labelSmall) }, + ) + AssistChip( + onClick = {}, + enabled = false, + label = { Text(req.status.wire, style = MaterialTheme.typography.labelSmall) }, + ) + } + if (req.artistName.isNotEmpty() && req.artistName != req.displayName) { + Text( + text = "Artist: ${req.artistName}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + OutlinedButton(onClick = onReject) { Text("Reject") } + Button(onClick = onApprove) { Text("Approve") } + } + } +} + +// UUID prefix length used as a friendlier-than-raw-UUID fallback when +// the admin users list fetch fails and we can't resolve userId → name. +private const val USER_ID_PREFIX_LEN = 8 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsViewModel.kt new file mode 100644 index 00000000..6ac37758 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsViewModel.kt @@ -0,0 +1,110 @@ +package com.fabledsword.minstrel.admin.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.admin.data.AdminRequestsRepository +import com.fabledsword.minstrel.admin.data.AdminUsersRepository +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.events.EventsStream +import com.fabledsword.minstrel.models.RequestRef +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import javax.inject.Inject + +private val RELEVANT_EVENT_KINDS = setOf("request.status_changed") + +sealed interface AdminRequestsUiState { + data object Loading : AdminRequestsUiState + data object Empty : AdminRequestsUiState + data class Success( + val rows: List, + val usernames: Map, + ) : AdminRequestsUiState + data class Error(val message: String) : AdminRequestsUiState +} + +@HiltViewModel +class AdminRequestsViewModel @Inject constructor( + private val repository: AdminRequestsRepository, + private val usersRepository: AdminUsersRepository, + private val eventsStream: EventsStream, +) : ViewModel() { + + private val internal = MutableStateFlow(AdminRequestsUiState.Loading) + val uiState: StateFlow = internal.asStateFlow() + + init { + refresh() + viewModelScope.launch { + eventsStream.events + .filter { it.kind in RELEVANT_EVENT_KINDS } + .collect { refresh() } + } + } + + fun refresh(): Job = viewModelScope.launch { + internal.value = AdminRequestsUiState.Loading + try { + val (rows, usernames) = coroutineScope { + val requestsJob = async { repository.list() } + val usernamesJob = async { + runCatching { + usersRepository.list().associate { it.id to it.username } + }.getOrDefault(emptyMap()) + } + requestsJob.await() to usernamesJob.await() + } + internal.value = if (rows.isEmpty()) { + AdminRequestsUiState.Empty + } else { + AdminRequestsUiState.Success(rows = rows, usernames = usernames) + } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.value = AdminRequestsUiState.Error( + ErrorCopy.fromThrowable(e), + ) + } + } + + fun approve(id: String) = decide(id) { repository.approve(it) } + + fun reject(id: String) = decide(id) { repository.reject(it) } + + /** + * Optimistically removes the decided row from the visible list and + * fires the REST call. On failure, refetches so the UI matches the + * server's view rather than leaving a phantom hole. + */ + private fun decide(id: String, action: suspend (String) -> Unit) { + val before = internal.value + if (before is AdminRequestsUiState.Success) { + val filtered = before.rows.filterNot { it.id == id } + internal.value = if (filtered.isEmpty()) { + AdminRequestsUiState.Empty + } else { + AdminRequestsUiState.Success(rows = filtered, usernames = before.usernames) + } + } + viewModelScope.launch { + try { + action(id) + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + // Refetch reconciles whatever the server now says about + // this request; the error is intentionally swallowed + // because the refresh result IS the user-facing signal. + refresh() + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersScreen.kt new file mode 100644 index 00000000..1d4d5fff --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersScreen.kt @@ -0,0 +1,539 @@ +@file:Suppress("TooManyFunctions") // Compose screen + private helper composables and dialogs + +package com.fabledsword.minstrel.admin.ui + +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Copy +import com.composables.icons.lucide.KeyRound +import com.composables.icons.lucide.Plus +import com.composables.icons.lucide.Trash2 +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import android.content.ClipData +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import com.fabledsword.minstrel.models.Invite +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.fabledsword.minstrel.models.AdminUserRef +import com.fabledsword.minstrel.nav.AdminUsers +import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold +import kotlinx.coroutines.launch + +@Composable +fun AdminUsersScreen( + navController: NavHostController, + viewModel: AdminUsersViewModel = hiltViewModel(), + invitesViewModel: AdminInvitesViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val invitesState by invitesViewModel.state.collectAsStateWithLifecycle() + var resetPasswordFor by remember { mutableStateOf(null) } + var deleteCandidate by remember { mutableStateOf(null) } + var showGenerateInvite by remember { mutableStateOf(false) } + var generatedInvite by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + invitesViewModel.created.collect { invite -> + generatedInvite = invite + } + } + + AdminUsersScaffold( + navController = navController, + usersState = state, + invitesState = invitesState, + onRefresh = { + viewModel.refresh().join() + invitesViewModel.refresh() + }, + onSetAdmin = viewModel::setAdmin, + onSetAutoApprove = viewModel::setAutoApprove, + onAskResetPassword = { resetPasswordFor = it }, + onAskDelete = { deleteCandidate = it }, + onAskGenerateInvite = { showGenerateInvite = true }, + onRevokeInvite = invitesViewModel::revoke, + ) + + PendingDialogs( + resetPasswordFor = resetPasswordFor, + deleteCandidate = deleteCandidate, + onClearReset = { resetPasswordFor = null }, + onClearDelete = { deleteCandidate = null }, + onConfirmReset = viewModel::resetPassword, + onConfirmDelete = viewModel::delete, + ) + + if (showGenerateInvite) { + GenerateInviteDialog( + onDismiss = { showGenerateInvite = false }, + onConfirm = { note -> + invitesViewModel.generate(note) + showGenerateInvite = false + }, + ) + } + generatedInvite?.let { invite -> + GeneratedInviteDialog( + invite = invite, + onDismiss = { generatedInvite = null }, + ) + } +} + +@Composable +private fun AdminUsersScaffold( + navController: NavHostController, + usersState: AdminUsersUiState, + invitesState: AdminInvitesUiState, + onRefresh: suspend () -> Unit, + onSetAdmin: (String, Boolean) -> Unit, + onSetAutoApprove: (String, Boolean) -> Unit, + onAskResetPassword: (AdminUserRef) -> Unit, + onAskDelete: (AdminUserRef) -> Unit, + onAskGenerateInvite: () -> Unit, + onRevokeInvite: (String) -> Unit, +) { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + MinstrelTopAppBar( + title = "Admin · Users", + navController = navController, + currentRouteName = AdminUsers::class.qualifiedName, + onBack = { navController.popBackStack() }, + ) + }, + ) { inner -> + PullToRefreshScaffold( + onRefresh = onRefresh, + modifier = Modifier.fillMaxSize().padding(inner), + ) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { SectionHeader("Users") } + usersSection( + state = usersState, + onSetAdmin = onSetAdmin, + onSetAutoApprove = onSetAutoApprove, + onAskResetPassword = onAskResetPassword, + onAskDelete = onAskDelete, + ) + item { HorizontalDivider() } + item { + SectionHeader( + label = "Invites", + trailing = { + TextButton(onClick = onAskGenerateInvite) { + Icon( + Lucide.Plus, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Text("Generate") + } + }, + ) + } + invitesSection(state = invitesState, onRevoke = onRevokeInvite) + } + } + } +} + +@Composable +private fun SectionHeader(label: String, trailing: (@Composable () -> Unit)? = null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + if (trailing != null) trailing() + } +} + +private fun androidx.compose.foundation.lazy.LazyListScope.usersSection( + state: AdminUsersUiState, + onSetAdmin: (String, Boolean) -> Unit, + onSetAutoApprove: (String, Boolean) -> Unit, + onAskResetPassword: (AdminUserRef) -> Unit, + onAskDelete: (AdminUserRef) -> Unit, +) { + when (state) { + AdminUsersUiState.Loading -> item { CenteredHint("Loading users…") } + AdminUsersUiState.Empty -> item { CenteredHint("No registered users.") } + is AdminUsersUiState.Error -> item { CenteredHint(state.message) } + is AdminUsersUiState.Success -> items(items = state.users, key = { it.id }) { user -> + UserRow( + user = user, + onSetAdmin = { onSetAdmin(user.id, it) }, + onSetAutoApprove = { onSetAutoApprove(user.id, it) }, + onResetPassword = { onAskResetPassword(user) }, + onDelete = { onAskDelete(user) }, + ) + HorizontalDivider() + } + } +} + +private fun androidx.compose.foundation.lazy.LazyListScope.invitesSection( + state: AdminInvitesUiState, + onRevoke: (String) -> Unit, +) { + when { + state.isLoading -> item { CenteredHint("Loading invites…") } + state.invites.isEmpty() -> item { + CenteredHint("No active invites. Use Generate to create one.") + } + else -> items(items = state.invites, key = { it.token }) { invite -> + InviteRow(invite = invite, onRevoke = { onRevoke(invite.token) }) + HorizontalDivider() + } + } +} + +@Composable +private fun CenteredHint(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) +} + +@Composable +private fun InviteRow(invite: Invite, onRevoke: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = invite.token, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val subtitle = buildString { + if (!invite.note.isNullOrEmpty()) { + append(invite.note) + append(" · ") + } + append(if (invite.isRedeemed) "redeemed" else "expires ${invite.expiresAt}") + } + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (!invite.isRedeemed) { + IconButton(onClick = onRevoke) { + Icon( + Lucide.Trash2, + contentDescription = "Revoke invite", + tint = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +@Composable +private fun GenerateInviteDialog(onDismiss: () -> Unit, onConfirm: (String?) -> Unit) { + var note by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Generate invite") }, + text = { + Column { + Text( + text = "Token expires in 24 hours.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = note, + onValueChange = { note = it }, + label = { Text("Note (optional)") }, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + singleLine = true, + ) + } + }, + confirmButton = { + Button(onClick = { onConfirm(note.takeIf { it.isNotBlank() }) }) { + Text("Generate") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +@Composable +private fun GeneratedInviteDialog(invite: Invite, onDismiss: () -> Unit) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Invite created") }, + text = { + Column { + Text( + text = "Share this token. It expires in 24 hours.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = invite.token, + onValueChange = {}, + readOnly = true, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + ) + } + }, + confirmButton = { + Button( + onClick = { + val clip = ClipData.newPlainText("Minstrel invite", invite.token) + scope.launch { clipboard.setClipEntry(ClipEntry(clip)) } + onDismiss() + }, + ) { + Icon( + Lucide.Copy, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Text(" Copy", modifier = Modifier.padding(start = 4.dp)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Close") } + }, + ) +} + +@Composable +private fun PendingDialogs( + resetPasswordFor: AdminUserRef?, + deleteCandidate: AdminUserRef?, + onClearReset: () -> Unit, + onClearDelete: () -> Unit, + onConfirmReset: (String, String) -> Unit, + onConfirmDelete: (String) -> Unit, +) { + resetPasswordFor?.let { user -> + ResetPasswordDialog( + user = user, + onDismiss = onClearReset, + onConfirm = { newPassword -> + onConfirmReset(user.id, newPassword) + onClearReset() + }, + ) + } + deleteCandidate?.let { user -> + DeleteConfirmDialog( + user = user, + onDismiss = onClearDelete, + onConfirm = { + onConfirmDelete(user.id) + onClearDelete() + }, + ) + } +} + +@Composable +private fun UserRow( + user: AdminUserRef, + onSetAdmin: (Boolean) -> Unit, + onSetAutoApprove: (Boolean) -> Unit, + onResetPassword: () -> Unit, + onDelete: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = user.displayName?.takeIf { it.isNotEmpty() } ?: user.username, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "@${user.username}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + ToggleRow(label = "Admin", checked = user.isAdmin, onChange = onSetAdmin) + ToggleRow( + label = "Auto-approve requests", + checked = user.autoApproveRequests, + onChange = onSetAutoApprove, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onResetPassword) { + Icon( + Lucide.KeyRound, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + ) + Text("Reset password") + } + TextButton(onClick = onDelete) { + Icon( + Lucide.Trash2, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(end = 4.dp), + ) + Text("Delete", color = MaterialTheme.colorScheme.error) + } + } + } +} + +@Composable +private fun ToggleRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = label, style = MaterialTheme.typography.bodyMedium) + Switch(checked = checked, onCheckedChange = onChange) + } +} + +@Composable +private fun ResetPasswordDialog( + user: AdminUserRef, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var newPassword by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Reset password") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Set a new password for @${user.username}.") + OutlinedTextField( + value = newPassword, + onValueChange = { newPassword = it }, + label = { Text("New password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(newPassword) }, + enabled = newPassword.isNotEmpty(), + ) { Text("Reset") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +@Composable +private fun DeleteConfirmDialog( + user: AdminUserRef, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + var typed by remember { mutableStateOf("") } + val confirmed = typed.trim().equals(DELETE_CONFIRM_WORD, ignoreCase = true) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Delete user?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "Delete @${user.username}? This removes the account and all " + + "their data. Type $DELETE_CONFIRM_WORD to confirm.", + ) + OutlinedTextField( + value = typed, + onValueChange = { typed = it }, + label = { Text("Type $DELETE_CONFIRM_WORD") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton(onClick = onConfirm, enabled = confirmed) { + Text( + "Delete", + color = if (confirmed) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +private const val DELETE_CONFIRM_WORD = "DELETE" + diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersViewModel.kt new file mode 100644 index 00000000..bf17fab8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersViewModel.kt @@ -0,0 +1,110 @@ +package com.fabledsword.minstrel.admin.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.admin.data.AdminUsersRepository +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.models.AdminUserRef +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +sealed interface AdminUsersUiState { + data object Loading : AdminUsersUiState + data object Empty : AdminUsersUiState + data class Success(val users: List) : AdminUsersUiState + data class Error(val message: String) : AdminUsersUiState +} + +@HiltViewModel +class AdminUsersViewModel @Inject constructor( + private val repository: AdminUsersRepository, +) : ViewModel() { + + private val internal = MutableStateFlow(AdminUsersUiState.Loading) + val uiState: StateFlow = internal.asStateFlow() + + init { + refresh() + } + + fun refresh(): Job = viewModelScope.launch { + internal.value = AdminUsersUiState.Loading + try { + val users = repository.list() + internal.value = if (users.isEmpty()) { + AdminUsersUiState.Empty + } else { + AdminUsersUiState.Success(users) + } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.value = AdminUsersUiState.Error(ErrorCopy.fromThrowable(e)) + } + } + + fun setAdmin(id: String, isAdmin: Boolean) = patch(id, { it.copy(isAdmin = isAdmin) }) { + repository.setAdmin(it, isAdmin) + } + + fun setAutoApprove(id: String, autoApprove: Boolean) = + patch(id, { it.copy(autoApproveRequests = autoApprove) }) { + repository.setAutoApprove(it, autoApprove) + } + + fun resetPassword(id: String, newPassword: String) { + viewModelScope.launch { + runCatching { repository.resetPassword(id, newPassword) } + } + } + + fun delete(id: String) { + val before = internal.value + if (before is AdminUsersUiState.Success) { + val filtered = before.users.filterNot { it.id == id } + internal.value = if (filtered.isEmpty()) { + AdminUsersUiState.Empty + } else { + AdminUsersUiState.Success(filtered) + } + } + viewModelScope.launch { + try { + repository.delete(id) + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + refresh() + } + } + } + + private fun patch( + id: String, + mutate: (AdminUserRef) -> AdminUserRef, + action: suspend (String) -> Unit, + ) { + val before = internal.value + if (before is AdminUsersUiState.Success) { + internal.value = AdminUsersUiState.Success( + before.users.map { if (it.id == id) mutate(it) else it }, + ) + } + viewModelScope.launch { + try { + action(id) + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + // Server-side guard might have rejected the toggle (e.g. + // last-admin protection); refetch reconciles state. + refresh() + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/AuthCookieInterceptor.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/AuthCookieInterceptor.kt new file mode 100644 index 00000000..e1a87926 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/AuthCookieInterceptor.kt @@ -0,0 +1,45 @@ +package com.fabledsword.minstrel.api + +import com.fabledsword.minstrel.auth.AuthStore +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Attaches the session cookie from [AuthStore] to every request, + * captures Set-Cookie from successful responses (login flow), and + * clears the store on 401 so downstream code can react to logout. + * + * Mirrors the Flutter Dio interceptor pattern. + */ +@Singleton +class AuthCookieInterceptor @Inject constructor( + private val authStore: AuthStore, +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val cookie = authStore.sessionCookie.value + val request = chain.request().newBuilder().apply { + if (!cookie.isNullOrEmpty()) header("Cookie", cookie) + }.build() + + val response = chain.proceed(request) + + if (response.code == HTTP_UNAUTHORIZED) { + authStore.setSessionCookie(null) + } else if (response.isSuccessful) { + // Capture the first Set-Cookie pair on login; subsequent + // responses that include Set-Cookie keep the store fresh. + response.headers("Set-Cookie").firstOrNull()?.let { sc -> + val firstPair = sc.substringBefore(';') + if (firstPair.isNotBlank()) authStore.setSessionCookie(firstPair) + } + } + return response + } + + private companion object { + const val HTTP_UNAUTHORIZED = 401 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/BaseUrlInterceptor.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/BaseUrlInterceptor.kt new file mode 100644 index 00000000..79f838d4 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/BaseUrlInterceptor.kt @@ -0,0 +1,43 @@ +package com.fabledsword.minstrel.api + +import com.fabledsword.minstrel.auth.AuthStore +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Rewrites every outgoing request's scheme/host/port to match the + * current [AuthStore.baseUrl]. Retrofit's `.baseUrl(...)` is read + * once at Retrofit creation, but AuthStore.baseUrl loads from Room + * asynchronously — so at injection time the Retrofit instance is + * frozen pointing at the [AuthStore.DEFAULT_BASE_URL] placeholder. + * + * This interceptor closes the gap: Retrofit can stay built with the + * placeholder forever, and every actual request gets retargeted at + * the live AuthStore value. Lets the user change server URL in + * Settings without an app relaunch (Phase 11 wiring). + * + * No-op when the stored base URL is unparseable (falls through to + * whatever Retrofit had) — that case shows up as a transport-level + * error in the caller's normal error path. + */ +@Singleton +class BaseUrlInterceptor @Inject constructor( + private val authStore: AuthStore, +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val original = chain.request() + val baseUrl = authStore.baseUrl.value.toHttpUrlOrNull() + ?: return chain.proceed(original) + val rewritten: HttpUrl = original.url.newBuilder() + .scheme(baseUrl.scheme) + .host(baseUrl.host) + .port(baseUrl.port) + .build() + return chain.proceed(original.newBuilder().url(rewritten).build()) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt new file mode 100644 index 00000000..caabc851 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt @@ -0,0 +1,109 @@ +package com.fabledsword.minstrel.api + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import retrofit2.HttpException +import java.io.IOException + +/** + * Maps server error codes (and common transport failures) to + * friendly, sentence-case copy. Mirrors + * `flutter_client/assets/error-copy.json` + `error_copy.dart`. + * + * Server errors are `{"error":{"code":"...","message":"..."}}`. + * [fromThrowable] pulls the code out of a Retrofit [HttpException]'s + * error body and maps it; network failures (no connection / DNS / + * refused) map to `connection_refused`; anything unrecognised falls + * back to `unknown`. + * + * Replaces the `e.message ?: "unknown error"` pattern scattered + * across ViewModels with copy a user can actually act on. + */ +object ErrorCopy { + private val json = Json { ignoreUnknownKeys = true } + + @Serializable + private data class Envelope(val error: Body? = null) + + @Serializable + private data class Body(val code: String = "", val message: String = "") + + /** Friendly copy for a known error [code], or the `unknown` fallback. */ + fun messageFor(code: String): String = TABLE[code] ?: TABLE.getValue("unknown") + + /** + * Friendly copy for any caught throwable. Parses Retrofit + * HttpException bodies for the server code; treats IOExceptions + * as connection failures. + */ + fun fromThrowable(t: Throwable): String = when (t) { + is HttpException -> messageFor(codeFromHttp(t)) + is IOException -> messageFor("connection_refused") + else -> TABLE.getValue("unknown") + } + + private fun codeFromHttp(e: HttpException): String { + val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull() + ?: return "unknown" + val code = runCatching { json.decodeFromString(raw).error?.code } + .getOrNull() + .orEmpty() + return code.ifEmpty { "unknown" } + } + + private val TABLE: Map = mapOf( + "unknown" to "Something went wrong.", + "unauthenticated" to "Your session has ended. Please sign in again.", + "auth_required" to "You need to sign in to do that.", + "forbidden" to "You don't have permission to do that.", + "not_authorized" to "You don't have permission to do that.", + "invalid_credentials" to "Wrong username or password.", + "wrong_password" to "Current password is incorrect.", + "password_too_short" to "Password must be at least 8 characters.", + "username_invalid" to "That username isn't valid.", + "username_taken" to "That username is already taken.", + "email_invalid" to "Enter a valid email address.", + "email_taken" to "That email is already in use.", + "invalid_token" to "That link has expired or already been used. Request a new one.", + "invite_invalid" to "That invite has expired or already been used.", + "invite_required" to "An invite is required to register on this server.", + "last_admin" to "Can't demote or delete the last admin.", + "no_email_on_file" to "No email is associated with that account.", + "not_configured" to "This integration isn't set up yet.", + "validation" to "Some fields aren't valid. Check and try again.", + "missing_fields" to "Required fields are missing.", + "missing_query" to "Search needs at least one keyword.", + "invalid_id" to "Invalid identifier.", + "invalid_body" to "Couldn't read the request.", + "bad_request" to "Invalid request.", + "bad_body" to "Couldn't read the request.", + "bad_kind" to "Invalid request type.", + "bad_paging" to "Invalid page size or offset.", + "bad_reason" to "Invalid quarantine reason.", + "mbid_required" to "An MBID is required for this lookup.", + "system_playlist_readonly" to "System playlists can't be edited directly.", + "connection_refused" to "Couldn't reach the server. Check the URL and try again.", + "lidarr_unreachable" to + "Lidarr is unreachable right now. Try again, or check Admin → Integrations.", + "lidarr_disabled" to "Lidarr integration is not enabled.", + "lidarr_auth_failed" to "Lidarr authentication failed.", + "lidarr_defaults_incomplete" to + "Lidarr is missing a default quality profile or root folder. " + + "Set them in Admin → Integrations.", + "lidarr_server_error" to "Lidarr returned an error. Check Lidarr's logs for the cause.", + "lidarr_rejected" to + "Lidarr rejected the request. Check the server logs for the field-level reason.", + "lidarr_album_lookup_failed" to + "Lidarr doesn't recognize this album. Try Resolve or Delete file instead.", + "album_mbid_missing" to "This track has no Lidarr album to remove.", + "request_not_pending" to "This request is no longer pending.", + "request_not_found" to "That request no longer exists.", + "track_not_found" to "That track no longer exists.", + "album_not_found" to "That album no longer exists.", + "artist_not_found" to "That artist no longer exists.", + "playlist_not_found" to "That playlist no longer exists.", + "user_not_found" to "That user no longer exists.", + "not_found" to "That no longer exists.", + ) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/NetworkModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/NetworkModule.kt new file mode 100644 index 00000000..9f68df80 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/NetworkModule.kt @@ -0,0 +1,94 @@ +package com.fabledsword.minstrel.api + +import com.fabledsword.minstrel.BuildConfig +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +/** + * Provides a single shared OkHttp client + Retrofit instance for the + * whole app. Per-endpoint Retrofit interfaces (LibraryApi, PlaylistsApi, + * etc.) are provided by feature modules using + * `retrofit.create(EndpointApi::class.java)` in the relevant @Provides. + * + * Streaming audio HTTP (ExoPlayer) reuses this same OkHttp via + * `OkHttpDataSource.Factory` so the auth interceptor, connection pool, + * and TLS config are shared. + */ +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideHttpLogging(): HttpLoggingInterceptor = + HttpLoggingInterceptor().apply { + level = + if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BASIC + } else { + HttpLoggingInterceptor.Level.NONE + } + } + + @Provides + @Singleton + fun provideOkHttp( + baseUrl: BaseUrlInterceptor, + auth: AuthCookieInterceptor, + logging: HttpLoggingInterceptor, + ): OkHttpClient = + OkHttpClient.Builder() + // BaseUrlInterceptor first — it rewrites scheme/host/port + // to match the live AuthStore.baseUrl on every request. + // Auth then sees the final URL. + .addInterceptor(baseUrl) + .addInterceptor(auth) + .addInterceptor(logging) + .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + + @Provides + @Singleton + fun provideRetrofit( + okHttp: OkHttpClient, + json: Json, + ): Retrofit = + Retrofit.Builder() + // Placeholder base URL — BaseUrlInterceptor rewrites every + // outgoing request to the live AuthStore value, so this + // string only has to satisfy Retrofit's parser and is + // never reached as an actual host. This lets the user + // change server URL in Settings without an app relaunch. + .baseUrl("http://placeholder.invalid/") + .client(okHttp) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + + // Per-endpoint Retrofit interface @Provides intentionally NOT here. + // Feature repositories construct their interface via the shared + // Retrofit (Hilt-injected), e.g.: + // + // class LibraryRepository @Inject constructor(retrofit: Retrofit) { + // private val api = retrofit.create() + // ... + // } + // + // Fewer Hilt bindings + no KSP2 type-resolution sharp edges from + // Hilt processing a `@Provides` return type that's a hand-written + // Kotlin interface (Hilt's ModuleProcessingStep "could not be + // resolved" failure mode under KSP2 — google/dagger#4303). + + private const val CONNECT_TIMEOUT_SECONDS = 10L + private const val READ_TIMEOUT_SECONDS = 30L +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/ServerBaseUrl.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/ServerBaseUrl.kt new file mode 100644 index 00000000..889146be --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/ServerBaseUrl.kt @@ -0,0 +1,9 @@ +package com.fabledsword.minstrel.api + +/** + * Value-class wrapper around the server base URL string. Used for + * dependency-injection type safety so a raw String isn't ambiguous in + * the graph. + */ +@JvmInline +value class ServerBaseUrl(val value: String) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminInvitesApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminInvitesApi.kt new file mode 100644 index 00000000..04bd383c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminInvitesApi.kt @@ -0,0 +1,36 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.AdminInvitesListWire +import com.fabledsword.minstrel.models.wire.InviteWire +import kotlinx.serialization.Serializable +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path + +/** + * Retrofit interface for `/api/admin/invites`. Mirrors + * `flutter_client/lib/api/endpoints/admin_invites.dart`. + * + * Server TTL is hardcoded at 24h; the only configurable field is the + * optional `note` on create. + */ +interface AdminInvitesApi { + @GET("api/admin/invites") + suspend fun list(): AdminInvitesListWire + + @POST("api/admin/invites") + suspend fun create(@Body body: CreateInviteBody): InviteWire + + @DELETE("api/admin/invites/{token}") + suspend fun revoke(@Path("token") token: String) +} + +/** + * Body for `POST /api/admin/invites`. The Flutter client omits the + * field when note is empty; Kotlin/serialization just serializes + * null which the server treats identically. + */ +@Serializable +data class CreateInviteBody(val note: String? = null) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminQuarantineApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminQuarantineApi.kt new file mode 100644 index 00000000..4a483849 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminQuarantineApi.kt @@ -0,0 +1,29 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.AdminQuarantineItemWire +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path + +/** + * Retrofit interface for `/api/admin/quarantine`. Mirrors + * `flutter_client/lib/api/endpoints/admin_quarantine.dart`. + * + * Three resolution endpoints: + * - `resolve` → admin reviewed, no action taken (clears flags). + * - `delete-file` → drop the local file; Lidarr keeps the track. + * - `delete-via-lidarr` → ask Lidarr to remove the track upstream too. + */ +interface AdminQuarantineApi { + @GET("api/admin/quarantine") + suspend fun list(): List + + @POST("api/admin/quarantine/{trackId}/resolve") + suspend fun resolve(@Path("trackId") trackId: String) + + @POST("api/admin/quarantine/{trackId}/delete-file") + suspend fun deleteFile(@Path("trackId") trackId: String) + + @POST("api/admin/quarantine/{trackId}/delete-via-lidarr") + suspend fun deleteViaLidarr(@Path("trackId") trackId: String) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminRequestsApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminRequestsApi.kt new file mode 100644 index 00000000..ef5b86cd --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminRequestsApi.kt @@ -0,0 +1,25 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.RequestWire +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path + +/** + * Retrofit interface for `/api/admin/requests`. Mirrors + * `flutter_client/lib/api/endpoints/admin_requests.dart`. + * + * Server returns the same `requestView` shape as the user-side + * `/api/requests`, so RequestWire is reused. Different listing scope — + * admin sees all users' requests, not just the caller's. + */ +interface AdminRequestsApi { + @GET("api/admin/requests") + suspend fun list(): List + + @POST("api/admin/requests/{id}/approve") + suspend fun approve(@Path("id") id: String) + + @POST("api/admin/requests/{id}/reject") + suspend fun reject(@Path("id") id: String) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminUsersApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminUsersApi.kt new file mode 100644 index 00000000..3a0d4622 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminUsersApi.kt @@ -0,0 +1,49 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.AdminUsersListWire +import kotlinx.serialization.Serializable +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path + +/** + * Retrofit interface for `/api/admin/users`. Mirrors + * `flutter_client/lib/api/endpoints/admin_users.dart`. + * + * Note: the PUT-auto-approve body field is `auto_approve`, NOT + * `auto_approve_requests` — the request shape differs from the + * response field name. Verified against `internal/api/admin_users.go + * adminAutoApproveReq` (May 2026). + */ +interface AdminUsersApi { + @GET("api/admin/users") + suspend fun list(): AdminUsersListWire + + @PUT("api/admin/users/{id}/admin") + suspend fun setAdmin(@Path("id") id: String, @Body body: SetAdminBody) + + @PUT("api/admin/users/{id}/auto-approve") + suspend fun setAutoApprove(@Path("id") id: String, @Body body: SetAutoApproveBody) + + @POST("api/admin/users/{id}/reset-password") + suspend fun resetPassword(@Path("id") id: String, @Body body: ResetPasswordBody) + + @DELETE("api/admin/users/{id}") + suspend fun delete(@Path("id") id: String) +} + +@Serializable +data class SetAdminBody( + @kotlinx.serialization.SerialName("is_admin") val isAdmin: Boolean, +) + +@Serializable +data class SetAutoApproveBody( + @kotlinx.serialization.SerialName("auto_approve") val autoApprove: Boolean, +) + +@Serializable +data class ResetPasswordBody(val password: String) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AuthApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AuthApi.kt new file mode 100644 index 00000000..cb8cbebb --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AuthApi.kt @@ -0,0 +1,22 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.LoginRequestBody +import com.fabledsword.minstrel.models.wire.LoginResponseWire +import retrofit2.http.Body +import retrofit2.http.POST + +/** + * Retrofit interface for `/api/auth`. Mirrors + * `flutter_client/lib/api/endpoints/auth.dart`. + * + * The actual session-cookie capture happens in + * [com.fabledsword.minstrel.api.AuthCookieInterceptor]; we don't + * pass the response `token` around manually. + */ +interface AuthApi { + @POST("api/auth/login") + suspend fun login(@Body body: LoginRequestBody): LoginResponseWire + + @POST("api/auth/logout") + suspend fun logout() +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/DiscoverApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/DiscoverApi.kt new file mode 100644 index 00000000..9614eacf --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/DiscoverApi.kt @@ -0,0 +1,33 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire +import com.fabledsword.minstrel.models.wire.CreateRequestBody +import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Query + +/** + * Retrofit interface for Discover / Lidarr search / request creation. + * Mirrors `flutter_client/lib/api/endpoints/discover.dart`. + * + * `/api/lidarr/search` has a 60s LRU on the server so quick re-types + * of the same query are cheap. + * + * Cancel + my-requests-list endpoints live on RequestsApi (Phase 10 + * Commit B), not here. + */ +interface DiscoverApi { + @GET("api/discover/suggestions") + suspend fun listSuggestions(): List + + @GET("api/lidarr/search") + suspend fun search( + @Query("q") query: String, + @Query("kind") kind: String, + ): List + + @POST("api/requests") + suspend fun createRequest(@Body body: CreateRequestBody) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/EventsApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/EventsApi.kt new file mode 100644 index 00000000..dff15f50 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/EventsApi.kt @@ -0,0 +1,31 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.PlayEndedRequest +import com.fabledsword.minstrel.models.wire.PlayOfflineRequest +import com.fabledsword.minstrel.models.wire.PlaySkippedRequest +import com.fabledsword.minstrel.models.wire.PlayStartedRequest +import com.fabledsword.minstrel.models.wire.PlayStartedResponse +import retrofit2.http.Body +import retrofit2.http.POST + +/** + * Retrofit interface for `POST /api/events`. Mirrors the relevant + * slice of `flutter_client/lib/api/endpoints/events.dart`. All four + * variants share the same URL — the discriminator is in the request + * body's `type` field. Server contract is best-effort per spec; + * callers (the live path in PlayEventsReporter) swallow errors and + * fall back to the offline mutation queue on failure. + */ +interface EventsApi { + @POST("api/events") + suspend fun playStarted(@Body body: PlayStartedRequest): PlayStartedResponse + + @POST("api/events") + suspend fun playEnded(@Body body: PlayEndedRequest) + + @POST("api/events") + suspend fun playSkipped(@Body body: PlaySkippedRequest) + + @POST("api/events") + suspend fun playOffline(@Body body: PlayOfflineRequest) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HistoryApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HistoryApi.kt new file mode 100644 index 00000000..ee4dcbaf --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HistoryApi.kt @@ -0,0 +1,23 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.HistoryPageWire +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * Retrofit interface for `/api/me/history`. Mirrors the relevant + * subset of `flutter_client/lib/api/endpoints/me.dart` (only + * `history()`; profile / timezone / quarantine endpoints land with + * their respective phases). + */ +interface HistoryApi { + @GET("api/me/history") + suspend fun getHistory( + @Query("limit") limit: Int = DEFAULT_LIMIT, + @Query("offset") offset: Int = 0, + ): HistoryPageWire + + companion object { + const val DEFAULT_LIMIT: Int = 50 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HomeApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HomeApi.kt new file mode 100644 index 00000000..acfe8261 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HomeApi.kt @@ -0,0 +1,17 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.HomeIndexWire +import retrofit2.http.GET + +/** + * Retrofit interface for the Home discovery endpoint. Mirrors + * `flutter_client/lib/api/endpoints/home.dart` — just the ID-only + * `/api/home/index` variant. The Flutter port has a heavier + * `/api/home` (full embedded payload) too; we don't use it because + * the per-item hydration path (sync controller → Room → Flow) is + * the only one the native client needs. + */ +interface HomeApi { + @GET("api/home/index") + suspend fun getHomeIndex(): HomeIndexWire +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LibraryApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LibraryApi.kt new file mode 100644 index 00000000..e870150a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LibraryApi.kt @@ -0,0 +1,43 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.AlbumDetailWire +import com.fabledsword.minstrel.models.wire.ArtistDetailWire +import com.fabledsword.minstrel.models.wire.TrackWire +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Retrofit interface for the server's native `/api/...` library surface. + * Mirrors `flutter_client/lib/api/endpoints/library.dart` 1:1. + * + * Notes on shapes: + * - `GET /api/artists/{id}` returns ArtistDetailWire (ArtistRef fields + * plus embedded "albums" array) + * - `GET /api/artists/{id}/tracks` returns a bare JSON array, not an + * enveloped object — Retrofit's `List` return type + * handles that. + * - `GET /api/albums/{id}` returns AlbumDetailWire (AlbumRef fields + * plus embedded "tracks" array) + * - `GET /api/library/shuffle` returns a bare JSON array. + * + * Home endpoints (`/api/home`, `/api/home/index`) live in a separate + * `HomeApi` because they have their own wire types (HomePayload, + * HomeIndex) that are larger and only used by the Home screen. + */ +interface LibraryApi { + @GET("api/tracks/{id}") + suspend fun getTrack(@Path("id") id: String): TrackWire + + @GET("api/artists/{id}") + suspend fun getArtistDetail(@Path("id") id: String): ArtistDetailWire + + @GET("api/artists/{id}/tracks") + suspend fun getArtistTracks(@Path("id") id: String): List + + @GET("api/albums/{id}") + suspend fun getAlbumDetail(@Path("id") id: String): AlbumDetailWire + + @GET("api/library/shuffle") + suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LikesApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LikesApi.kt new file mode 100644 index 00000000..e3c914ce --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LikesApi.kt @@ -0,0 +1,31 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.LikedIdsWire +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path + +/** + * Retrofit interface for `/api/likes`. Mirrors + * `flutter_client/lib/api/endpoints/likes.dart`. + * + * Path segment `kind` is one of "artists" | "albums" | "tracks" + * (plural, matching the server route). The Repository hides that + * pluralization mapping behind a typed enum so callers pass + * "artist" / "album" / "track" everywhere else. + * + * Paged list endpoints (`/api/likes/{kind}?limit&offset`) are + * intentionally omitted — the Liked tab renders straight from + * `cached_likes`, hydrated against the per-entity caches. + */ +interface LikesApi { + @POST("api/likes/{kind}/{id}") + suspend fun like(@Path("kind") kind: String, @Path("id") id: String) + + @DELETE("api/likes/{kind}/{id}") + suspend fun unlike(@Path("kind") kind: String, @Path("id") id: String) + + @GET("api/likes/ids") + suspend fun ids(): LikedIdsWire +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/MeApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/MeApi.kt new file mode 100644 index 00000000..99dc9c7c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/MeApi.kt @@ -0,0 +1,95 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire +import com.fabledsword.minstrel.models.wire.MyProfileWire +import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.PUT + +/** + * Retrofit interface for the `/api/me` endpoints — caller-scoped account endpoints. + * Mirrors the relevant slice of `flutter_client/lib/api/endpoints/settings.dart`. + * + * History + timezone + system-playlists-status live under /api/me too + * but are handled by their respective feature repositories; this + * interface only carries the profile + password slice. + */ +interface MeApi { + @GET("api/me") + suspend fun getProfile(): MyProfileWire + + /** + * Pass only the fields you want to change; server merges. Returns + * the canonical post-update row so the caller can refresh local + * state without an extra GET. + */ + @PUT("api/me/profile") + suspend fun updateProfile(@Body body: UpdateProfileRequest): MyProfileWire + + /** + * Change the caller's password. Server validates `current_password` + * before accepting `new_password`. No body on success (204). + */ + @PUT("api/me/password") + suspend fun changePassword(@Body body: ChangePasswordRequest) + + /** + * Caller's ListenBrainz state — never returns the token itself, + * only whether one is stored, whether scrobbling is enabled, and + * the last successful scrobble timestamp (RFC3339 string or null). + */ + @GET("api/me/listenbrainz") + suspend fun getListenBrainz(): ListenBrainzStatusWire + + /** + * Caller's most recent system-playlist build state. Drives the + * Home placeholder cards (building / failed / pending / seed-needed) + * for system playlists that haven't generated yet. + */ + @GET("api/me/system-playlists-status") + suspend fun getSystemPlaylistsStatus(): SystemPlaylistsStatusWire + + /** + * PUT against the same `/api/me/listenbrainz` endpoint with one + * of two shapes — `{token: ...}` stashes a new token, + * `{enabled: ...}` flips the scrobble switch. Server returns the + * post-write status so callers refresh without a separate GET. + */ + @PUT("api/me/listenbrainz") + suspend fun setListenBrainz(@Body body: ListenBrainzPutBody): ListenBrainzStatusWire +} + +/** + * Body for `PUT /api/me/profile`. Pass only the fields you want to + * change; the server merges — empty strings clear, nulls leave + * existing values alone. + */ +@Serializable +data class UpdateProfileRequest( + @SerialName("display_name") val displayName: String? = null, + val email: String? = null, +) + +/** + * Body for `PUT /api/me/password`. Both fields required. + */ +@Serializable +data class ChangePasswordRequest( + @SerialName("current_password") val currentPassword: String, + @SerialName("new_password") val newPassword: String, +) + +/** + * Body for `PUT /api/me/listenbrainz`. Either `token` or `enabled` + * is set; the server treats other fields as untouched. Mirrors + * Flutter's `setListenBrainzToken` / `setListenBrainzEnabled` split + * over a single endpoint shape. + */ +@Serializable +data class ListenBrainzPutBody( + val token: String? = null, + val enabled: Boolean? = null, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt new file mode 100644 index 00000000..76aa94b0 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt @@ -0,0 +1,70 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.PlaylistDetailWire +import com.fabledsword.minstrel.models.wire.PlaylistsListWire +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Retrofit interface for `/api/playlists`. Mirrors + * `flutter_client/lib/api/endpoints/playlists.dart`. + */ +interface PlaylistsApi { + /** + * `kind` = "user" | "system" | "all". Server returns + * `{"owned": [...], "public": [...]}` — `owned` is the caller's + * playlists filtered by kind, `public` is other users' shared + * playlists (not kind-filtered). + */ + @GET("api/playlists") + suspend fun list(@Query("kind") kind: String = "all"): PlaylistsListWire + + @GET("api/playlists/{id}") + suspend fun get(@Path("id") id: String): PlaylistDetailWire + + /** + * POST /api/playlists/{id}/tracks — append the given track IDs to + * the owner's playlist. Server is idempotent at the (playlist_id, + * track_id) pair level; re-firing the same payload yields no + * duplicates. + */ + @POST("api/playlists/{id}/tracks") + suspend fun appendTracks( + @Path("id") playlistId: String, + @Body body: AppendTracksRequest, + ) + + /** + * POST /api/playlists/system/{kind}/refresh — rebuild the caller's + * system playlist of the given variant ("for_you" / "discover" / + * etc.). Returns the new playlist UUID (the rebuild rotates the + * id); playlistId is null when the library is empty / build can't + * pick seed tracks. The kind is the raw system_variant. + */ + @POST("api/playlists/system/{kind}/refresh") + suspend fun refreshSystem(@Path("kind") variant: String): RefreshSystemResponse +} + +/** + * POST /api/playlists/{id}/tracks body. Server expects snake_case + * `track_ids` per the Flutter wire shape. + */ +@Serializable +data class AppendTracksRequest( + @SerialName("track_ids") val trackIds: List, +) + +/** + * Response body for the system-refresh endpoint. `playlistId` is the + * uuid of the rebuilt playlist, or null when the build had nothing + * to seed from. + */ +@Serializable +data class RefreshSystemResponse( + @SerialName("playlist_id") val playlistId: String? = null, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/QuarantineApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/QuarantineApi.kt new file mode 100644 index 00000000..574b299c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/QuarantineApi.kt @@ -0,0 +1,39 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.QuarantineMineWire +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path + +/** + * Retrofit interface for `/api/quarantine`. Mirrors the relevant + * parts of `flutter_client/lib/api/endpoints/quarantine.dart` (flag + * and unflag) plus the `/api/quarantine/mine` endpoint from `me.dart`. + * + * Both flag and unflag are user-scoped — callers act on their own + * quarantine entries. The cross-user admin surface is a separate + * `/api/admin/quarantine` route (Phase 10 Commit D). + */ +interface QuarantineApi { + @GET("api/quarantine/mine") + suspend fun listMine(): List + + @POST("api/quarantine") + suspend fun flag(@Body body: FlagRequest) + + @DELETE("api/quarantine/{trackId}") + suspend fun unflag(@Path("trackId") trackId: String) +} + +/** + * POST /api/quarantine body. `reason` is one of bad_rip / wrong_file + * / wrong_tags / duplicate / other; `notes` is the free-text comment. + */ +@kotlinx.serialization.Serializable +data class FlagRequest( + @kotlinx.serialization.SerialName("track_id") val trackId: String, + val reason: String, + val notes: String = "", +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/RadioApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/RadioApi.kt new file mode 100644 index 00000000..14ea0eda --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/RadioApi.kt @@ -0,0 +1,19 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.RadioResponseWire +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * Retrofit interface for `/api/radio`. Mirrors the relevant slice of + * `flutter_client/lib/api/endpoints/radio.dart` (a single GET that + * returns the seeded queue). The server picks a fresh shuffle each + * invocation — clients call this once per radio start. + */ +interface RadioApi { + @GET("api/radio") + suspend fun seedTrack( + @Query("seed_track") trackId: String, + @Query("limit") limit: Int? = null, + ): RadioResponseWire +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/RequestsApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/RequestsApi.kt new file mode 100644 index 00000000..cc9ed2db --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/RequestsApi.kt @@ -0,0 +1,25 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.RequestWire +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.Path + +/** + * Retrofit interface for the user-side `/api/requests`. Mirrors + * `flutter_client/lib/api/endpoints/requests.dart`. + * + * Server scopes results to the caller — admins see only their own + * requests through this endpoint. The cross-user admin view lives on + * a separate `/api/admin/requests` route (Phase 10 Commit D). + * + * The DELETE endpoint returns the cancelled row (not 204) so the UI + * can patch local state without a refetch. + */ +interface RequestsApi { + @GET("api/requests") + suspend fun listMine(): List + + @DELETE("api/requests/{id}") + suspend fun cancel(@Path("id") id: String): RequestWire +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/SearchApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/SearchApi.kt new file mode 100644 index 00000000..70fd52fd --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/SearchApi.kt @@ -0,0 +1,24 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.SearchResponseWire +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * Retrofit interface for `GET /api/search`. Mirrors + * `flutter_client/lib/api/endpoints/search.dart`. Server returns 400 + * on empty/whitespace-only `q` — the caller is responsible for + * guarding. + */ +interface SearchApi { + @GET("api/search") + suspend fun search( + @Query("q") query: String, + @Query("limit") limit: Int = DEFAULT_LIMIT, + @Query("offset") offset: Int = 0, + ): SearchResponseWire + + companion object { + const val DEFAULT_LIMIT: Int = 20 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/SyncApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/SyncApi.kt new file mode 100644 index 00000000..4baf1783 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/SyncApi.kt @@ -0,0 +1,23 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.SyncResponseWire +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * Retrofit interface for `/api/library/sync` (#357). The endpoint + * returns three possible status codes that the SyncController + * branches on: + * - 200: parsed body with cursor + upserts + deletes + * - 204: no changes since the cursor; advance lastSyncAt only + * - 410: cursor is too old; wipe + retry with cursor=0 + * + * The Retrofit return type is `Response` so the + * controller can read `code()` directly instead of having to wrap + * a successful no-content body or catch HttpException for 410. + */ +interface SyncApi { + @GET("api/library/sync") + suspend fun sync(@Query("since") since: Long): Response +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt new file mode 100644 index 00000000..419a815c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt @@ -0,0 +1,93 @@ +package com.fabledsword.minstrel.auth + +import com.fabledsword.minstrel.api.endpoints.AuthApi +import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.models.UserRef +import com.fabledsword.minstrel.models.wire.LoginRequestBody +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Singleton facade over the auth state machine. Mirrors Flutter's + * `AuthController` from `auth_provider.dart`. + * + * Cookie persistence is handled by [AuthCookieInterceptor] capturing + * Set-Cookie on the login response; the user identity itself + * persists via AuthStore.userJson so the username + isAdmin survive + * cold restarts. On construction the controller subscribes to + * AuthStore.userJson and reflects any persisted value into + * `currentUser` — that's what makes the Settings screen show the + * username after a fresh app launch without re-prompting. + */ +@Singleton +class AuthController @Inject constructor( + private val authStore: AuthStore, + private val json: Json, + @ApplicationScope private val scope: CoroutineScope, + retrofit: Retrofit, +) { + private val api: AuthApi = retrofit.create() + + private val currentUserState = MutableStateFlow(null) + val currentUser: StateFlow = currentUserState.asStateFlow() + + val sessionCookie: StateFlow get() = authStore.sessionCookie + + val isSignedIn: Boolean + get() = !authStore.sessionCookie.value.isNullOrEmpty() + + init { + // Rehydrate currentUser whenever the persisted userJson emits + // (cold start with a saved cookie OR explicit sign-in/out + // writing through AuthStore). Decode failures collapse to null + // so a malformed row can't crash the app — worst case is a + // blank username until the next sign-in writes a valid value. + scope.launch { + authStore.userJson.collect { raw -> + currentUserState.value = raw?.let { decodeUser(it) } + } + } + } + + /** + * Submit credentials. On success the AuthCookieInterceptor will + * have captured the Set-Cookie before this method returns; the + * caller can safely navigate to the in-shell graph. Throws on + * any non-2xx response (caller surfaces in UI). + */ + suspend fun signIn(username: String, password: String): UserRef { + val response = api.login(LoginRequestBody(username = username, password = password)) + val user = UserRef( + id = response.user.id, + username = response.user.username, + isAdmin = response.user.isAdmin, + ) + currentUserState.value = user + authStore.setUserJson(json.encodeToString(UserRef.serializer(), user)) + return user + } + + /** + * Clears the local session immediately and best-effort hits + * `/api/auth/logout` so the server can drop its session row. + * Network errors are swallowed — the user is already locally + * signed out and a stale server session times out on its own. + */ + suspend fun signOut() { + currentUserState.value = null + authStore.setSessionCookie(null) + authStore.setUserJson(null) + runCatching { api.logout() } + } + + private fun decodeUser(raw: String): UserRef? = + runCatching { json.decodeFromString(UserRef.serializer(), raw) }.getOrNull() +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt new file mode 100644 index 00000000..c4f493f8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt @@ -0,0 +1,180 @@ +package com.fabledsword.minstrel.auth + +import com.fabledsword.minstrel.cache.audiocache.CacheSettings +import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao +import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity +import com.fabledsword.minstrel.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Holds the user's session cookie and configured server base URL. + * + * **Hybrid storage**: a `MutableStateFlow` is the primary read source so + * interceptor-thread reads stay synchronous, and writes are persisted + * write-through to a Room `auth_session` single-row table for + * process-death persistence. + * + * Reads on init() rehydrate the in-memory state from the DAO, then a + * collection on `dao.observe()` keeps the in-memory state in sync with + * any external writes (e.g. multi-process pokes, future). On mutate the + * in-memory state changes synchronously so the next interceptor read + * sees the new value immediately; the DAO write coroutine catches up + * shortly after. + */ +// AuthStore is the single-row facade over auth_session (de-facto +// app_preferences — see entity comment). It legitimately owns one +// getter + one setter + one persist helper per pref column; the +// function count scales with the pref count, not with feature +// complexity. Splitting into per-pref stores would scatter the +// shared dao + scope + json plumbing for no real gain. Suppress +// the cap rather than fragment. +@Suppress("TooManyFunctions") +@Singleton +class AuthStore @Inject constructor( + private val dao: AuthSessionDao, + @ApplicationScope private val scope: CoroutineScope, +) { + private val sessionCookieState = MutableStateFlow(null) + val sessionCookie: StateFlow = sessionCookieState.asStateFlow() + + private val baseUrlState = MutableStateFlow(DEFAULT_BASE_URL) + val baseUrl: StateFlow = baseUrlState.asStateFlow() + + private val userJsonState = MutableStateFlow(null) + val userJson: StateFlow = userJsonState.asStateFlow() + + private val themeModeState = MutableStateFlow(null) + val themeMode: StateFlow = themeModeState.asStateFlow() + + private val clientIdState = MutableStateFlow(null) + val clientId: StateFlow = clientIdState.asStateFlow() + + private val cacheSettingsState = MutableStateFlow(CacheSettings.DEFAULT) + val cacheSettings: StateFlow = cacheSettingsState.asStateFlow() + + private val json = Json { ignoreUnknownKeys = true } + + init { + scope.launch { + dao.observe().collect { row -> + sessionCookieState.value = row?.sessionCookie + baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL + userJsonState.value = row?.userJson + themeModeState.value = row?.themeMode + clientIdState.value = row?.clientId + cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson) + } + } + } + + private fun decodeCacheSettings(raw: String?): CacheSettings { + if (raw.isNullOrEmpty()) return CacheSettings.DEFAULT + return runCatching { + json.decodeFromString(CacheSettings.serializer(), raw) + }.getOrDefault(CacheSettings.DEFAULT) + } + + fun setSessionCookie(value: String?) { + sessionCookieState.value = value + scope.launch { persistCookie(value) } + } + + fun setBaseUrl(value: String) { + baseUrlState.value = value + scope.launch { persistBaseUrl(value) } + } + + fun setUserJson(value: String?) { + userJsonState.value = value + scope.launch { persistUserJson(value) } + } + + fun setThemeMode(value: String?) { + themeModeState.value = value + scope.launch { persistThemeMode(value) } + } + + fun setClientId(value: String?) { + clientIdState.value = value + scope.launch { persistClientId(value) } + } + + fun setCacheSettings(value: CacheSettings) { + cacheSettingsState.value = value + val encoded = json.encodeToString(CacheSettings.serializer(), value) + scope.launch { persistCacheSettings(encoded) } + } + + private suspend fun persistCookie(value: String?) { + if (dao.get() == null) { + dao.upsert(currentEntity().copy(sessionCookie = value)) + } else { + dao.setSessionCookie(value) + } + } + + private suspend fun persistBaseUrl(value: String) { + if (dao.get() == null) { + dao.upsert(currentEntity().copy(baseUrl = value)) + } else { + dao.setBaseUrl(value) + } + } + + private suspend fun persistUserJson(value: String?) { + if (dao.get() == null) { + dao.upsert(currentEntity().copy(userJson = value)) + } else { + dao.setUserJson(value) + } + } + + private suspend fun persistThemeMode(value: String?) { + if (dao.get() == null) { + dao.upsert(currentEntity().copy(themeMode = value)) + } else { + dao.setThemeMode(value) + } + } + + private suspend fun persistClientId(value: String?) { + if (dao.get() == null) { + dao.upsert(currentEntity().copy(clientId = value)) + } else { + dao.setClientId(value) + } + } + + private suspend fun persistCacheSettings(json: String) { + if (dao.get() == null) { + dao.upsert(currentEntity().copy(cacheSettingsJson = json)) + } else { + dao.setCacheSettingsJson(json) + } + } + + private fun currentEntity(): AuthSessionEntity = AuthSessionEntity( + id = ROW_ID, + sessionCookie = sessionCookieState.value, + baseUrl = baseUrlState.value, + userJson = userJsonState.value, + themeMode = themeModeState.value, + clientId = clientIdState.value, + cacheSettingsJson = json.encodeToString( + CacheSettings.serializer(), + cacheSettingsState.value, + ), + ) + + companion object { + const val DEFAULT_BASE_URL: String = "http://localhost:8080" + private const val ROW_ID = 0 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/AuthGateViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/AuthGateViewModel.kt new file mode 100644 index 00000000..f4bd2fa8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/AuthGateViewModel.kt @@ -0,0 +1,51 @@ +package com.fabledsword.minstrel.auth.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.auth.AuthStore +import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao +import com.fabledsword.minstrel.nav.Home +import com.fabledsword.minstrel.nav.Login +import com.fabledsword.minstrel.nav.ServerUrl +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Computes the initial startDestination for the root NavHost based on + * persisted auth state. Sits on top of AuthSessionDao directly rather + * than AuthStore's StateFlow because the StateFlow defaults to null + * until Room's first async emission — we need a definitive answer + * before drawing any nav graph. + * + * - no row at all → ServerUrl (first launch) + * - row with baseUrl, no cookie → Login (URL configured, not yet signed in) + * - row with cookie → Home (signed in) + * + * `startDestination` emits null while resolving (UI shows a spinner) + * and then exactly one resolved value. + */ +@HiltViewModel +class AuthGateViewModel @Inject constructor( + private val dao: AuthSessionDao, +) : ViewModel() { + + private val internal = MutableStateFlow(null) + val startDestination: StateFlow = internal.asStateFlow() + + init { + viewModelScope.launch { + val row = dao.get() + internal.value = when { + row == null -> ServerUrl + row.baseUrl == AuthStore.DEFAULT_BASE_URL && row.sessionCookie.isNullOrEmpty() -> + ServerUrl + row.sessionCookie.isNullOrEmpty() -> Login + else -> Home + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/LoginScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/LoginScreen.kt new file mode 100644 index 00000000..3f3770a2 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/LoginScreen.kt @@ -0,0 +1,147 @@ +package com.fabledsword.minstrel.auth.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.fabledsword.minstrel.nav.Home +import com.fabledsword.minstrel.nav.Login +import com.fabledsword.minstrel.nav.ServerUrl + +@Composable +fun LoginScreen( + navController: NavHostController, + viewModel: LoginViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(state.signedIn) { + if (state.signedIn) { + navController.navigate(Home) { + popUpTo(Login) { inclusive = true } + } + viewModel.consumeSignedIn() + } + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + ) { + LoginForm( + state = state, + onUsernameChange = viewModel::setUsername, + onPasswordChange = viewModel::setPassword, + onSubmit = viewModel::submit, + onChangeServerUrl = { navController.navigate(ServerUrl) }, + ) + } +} + +@Composable +private fun LoginForm( + state: LoginFormState, + onUsernameChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onSubmit: () -> Unit, + onChangeServerUrl: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Sign in", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + OutlinedTextField( + value = state.username, + onValueChange = onUsernameChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("Username") }, + singleLine = true, + enabled = !state.isSubmitting, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ), + ) + OutlinedTextField( + value = state.password, + onValueChange = onPasswordChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("Password") }, + singleLine = true, + enabled = !state.isSubmitting, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { onSubmit() }), + ) + if (state.errorMessage != null) { + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + SubmitButton(state = state, onSubmit = onSubmit) + TextButton( + onClick = onChangeServerUrl, + enabled = !state.isSubmitting, + ) { + Text("Change server URL") + } + } +} + +@Composable +private fun SubmitButton(state: LoginFormState, onSubmit: () -> Unit) { + Button( + onClick = onSubmit, + enabled = !state.isSubmitting && + state.username.isNotBlank() && + state.password.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + if (state.isSubmitting) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } else { + Text("Sign in") + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/LoginViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/LoginViewModel.kt new file mode 100644 index 00000000..d7a01786 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/LoginViewModel.kt @@ -0,0 +1,64 @@ +package com.fabledsword.minstrel.auth.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.auth.AuthController +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class LoginFormState( + val username: String = "", + val password: String = "", + val isSubmitting: Boolean = false, + val errorMessage: String? = null, + val signedIn: Boolean = false, +) + +@HiltViewModel +class LoginViewModel @Inject constructor( + private val auth: AuthController, +) : ViewModel() { + + private val internal = MutableStateFlow(LoginFormState()) + val state: StateFlow = internal.asStateFlow() + + fun setUsername(value: String) { + internal.update { it.copy(username = value, errorMessage = null) } + } + + fun setPassword(value: String) { + internal.update { it.copy(password = value, errorMessage = null) } + } + + fun submit() { + val s = internal.value + if (s.username.isBlank() || s.password.isBlank() || s.isSubmitting) return + viewModelScope.launch { + internal.update { it.copy(isSubmitting = true, errorMessage = null) } + try { + auth.signIn(s.username, s.password) + internal.update { it.copy(isSubmitting = false, signedIn = true) } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.update { + it.copy( + isSubmitting = false, + errorMessage = ErrorCopy.fromThrowable(e), + ) + } + } + } + } + + /** Called after the navigation away from /login completes. */ + fun consumeSignedIn() { + internal.update { it.copy(signedIn = false) } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/ServerUrlScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/ServerUrlScreen.kt new file mode 100644 index 00000000..04d90763 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/ServerUrlScreen.kt @@ -0,0 +1,177 @@ +package com.fabledsword.minstrel.auth.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavHostController +import com.fabledsword.minstrel.auth.AuthStore +import com.fabledsword.minstrel.nav.Login +import com.fabledsword.minstrel.nav.ServerUrl +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +// ─── State + VM ─────────────────────────────────────────────────────── + +data class ServerUrlFormState( + val url: String = "", + val saving: Boolean = false, + val saved: Boolean = false, + val errorMessage: String? = null, +) + +@HiltViewModel +class ServerUrlViewModel @Inject constructor( + private val authStore: AuthStore, +) : ViewModel() { + + private val internal = MutableStateFlow( + // Pre-fill ONLY if the user has previously saved a real URL — + // the DEFAULT_BASE_URL placeholder (`http://localhost:8080`) is + // an internal HTTP-client fallback, not something a user + // typed. Leaving the field empty lets the OutlinedTextField's + // placeholder ("https://minstrel.example.com") show through + // so the user can just type without first having to clear. + ServerUrlFormState( + url = authStore.baseUrl.value + .takeIf { it != AuthStore.DEFAULT_BASE_URL } + .orEmpty(), + ), + ) + val state: StateFlow = internal.asStateFlow() + + fun setUrl(value: String) { + internal.update { it.copy(url = value, errorMessage = null) } + } + + fun submit() { + val raw = internal.value.url.trim().trimEnd('/') + if (raw.isEmpty()) { + internal.update { it.copy(errorMessage = "Enter a server URL.") } + return + } + if (!raw.startsWith("http://") && !raw.startsWith("https://")) { + internal.update { + it.copy(errorMessage = "URL must start with http:// or https://") + } + return + } + viewModelScope.launch { + internal.update { it.copy(saving = true, errorMessage = null) } + authStore.setBaseUrl(raw) + internal.update { it.copy(saving = false, saved = true) } + } + } + + fun consumeSaved() { + internal.update { it.copy(saved = false) } + } +} + +// ─── Screen ─────────────────────────────────────────────────────────── + +@Composable +fun ServerUrlScreen( + navController: NavHostController, + viewModel: ServerUrlViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(state.saved) { + if (state.saved) { + navController.navigate(Login) { + popUpTo(ServerUrl) { inclusive = true } + } + viewModel.consumeSaved() + } + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + ) { + ServerUrlForm( + state = state, + onUrlChange = viewModel::setUrl, + onSubmit = viewModel::submit, + ) + } +} + +@Composable +private fun ServerUrlForm( + state: ServerUrlFormState, + onUrlChange: (String) -> Unit, + onSubmit: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Connect to your Minstrel", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = "Enter the URL of your Minstrel server.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = state.url, + onValueChange = onUrlChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("Server URL") }, + placeholder = { Text("https://minstrel.example.com") }, + singleLine = true, + enabled = !state.saving, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { onSubmit() }), + ) + if (state.errorMessage != null) { + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + Button( + onClick = onSubmit, + enabled = !state.saving && state.url.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { Text("Continue") } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/CacheIndexer.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/CacheIndexer.kt new file mode 100644 index 00000000..f8168f2a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/CacheIndexer.kt @@ -0,0 +1,66 @@ +package com.fabledsword.minstrel.cache + +import com.fabledsword.minstrel.cache.db.CacheSource +import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao +import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity +import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.player.PlayerController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Populates `audio_cache_index` from playback so the offline pools + * ([ShuffleSource]) and the cached indicator have a data source. + * + * Mirrors the user-visible behavior of Flutter's `AudioCacheManager` + * (`registerStreamCache` + `touch`): a track you play gets cached as a + * side effect of streaming, and playing it bumps its recency. The + * Android idiom differs intentionally — Media3's `SimpleCache` is the + * real byte store (span-based, keyed by trackId via the MediaItem's + * custom cache key), so this index is a lightweight *play-recency* + * record (source = INCIDENTAL) rather than Flutter's file-per-track + * table. SimpleCache stays the source of truth for "is it actually on + * disk"; this index orders "recently played" for the offline pools. + * + * State observed against [PlayerController.uiState], deduped by track + * id so each entry into the player records once. Eager-constructed via + * the construct-the-singleton trick in `MinstrelApplication`. + */ +@Singleton +class CacheIndexer @Inject constructor( + playerController: PlayerController, + private val dao: AudioCacheIndexDao, + @ApplicationScope private val scope: CoroutineScope, +) { + init { + scope.launch { + playerController.uiState + .map { it.currentTrack } + .distinctUntilChanged { a, b -> a?.id == b?.id } + .collect { track -> if (track != null) record(track) } + } + } + + private suspend fun record(track: TrackRef) { + val now = Clock.System.now() + if (dao.get(track.id) != null) { + dao.touchLastPlayed(track.id, now) + } else { + dao.upsert( + AudioCacheIndexEntity( + trackId = track.id, + path = track.streamUrl, + sizeBytes = 0, + lastPlayedAt = now, + source = CacheSource.INCIDENTAL, + ), + ) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/CachedTrackIds.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/CachedTrackIds.kt new file mode 100644 index 00000000..d5940efc --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/CachedTrackIds.kt @@ -0,0 +1,60 @@ +package com.fabledsword.minstrel.cache + +import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.player.PlayerFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Reactive set of track ids that have audio bytes in the Media3 + * [SimpleCache][PlayerFactory.simpleCache], used to drive the cached + * indicator dot on track rows. + * + * The SimpleCache (keyed per track via the MediaItem custom cache key) + * is the source of truth for true on-disk residency — querying it + * directly, rather than trusting an index row, means an evicted track + * loses its dot. The key set is read on the IO dispatcher (it scans + * cache metadata) and re-read on every track change, since playback is + * what grows the incidental cache. + * + * The persisted SimpleCache survives process death, so the eager + * initial read paints dots for previously-cached tracks even when the + * library is opened offline with no active playback. + */ +@Singleton +class CachedTrackIds @Inject constructor( + playerController: PlayerController, + private val playerFactory: PlayerFactory, + @ApplicationScope scope: CoroutineScope, +) { + private val refresh = MutableStateFlow(0L) + + val ids: StateFlow> = + refresh + .map { readKeys() } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptySet()) + + init { + scope.launch { + playerController.uiState + .map { it.currentTrack?.id } + .distinctUntilChanged() + .collect { refresh.value += 1 } + } + } + + private fun readKeys(): Set = + runCatching { playerFactory.simpleCache.keys.toSet() }.getOrDefault(emptySet()) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/ShuffleSource.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/ShuffleSource.kt new file mode 100644 index 00000000..6cf6db84 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/ShuffleSource.kt @@ -0,0 +1,63 @@ +package com.fabledsword.minstrel.cache + +import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao +import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao +import com.fabledsword.minstrel.library.data.toDomain +import com.fabledsword.minstrel.likes.data.LikesRepository +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.player.PlayerFactory +import javax.inject.Inject +import javax.inject.Singleton + +private const val POOL_LIMIT = 100 + +/** + * Offline play sources over the local audio-cache index. Mirrors + * `flutter_client/lib/cache/shuffle_source.dart`. + * + * Both pools are UNIONs over the cache regardless of storage bucket + * (liked AND recently-played both included). The two-bucket split is + * storage/eviction-only and never filters playback — exactly what + * this relies on. + * + * Divergence note: Flutter's `audioCacheIndex` only holds fully-cached + * tracks, so its pools are inherently resident. Android's index records + * plays (see `CacheIndexer`), so the recency list is intersected with + * true Media3 SimpleCache residency here — the pool never offers a + * played-then-evicted track that would fail to play offline. + * + * Surfaced on Home's Playlists row only when offline; tap shuffles + + * plays the pool from the cache. + */ +@Singleton +class ShuffleSource @Inject constructor( + private val cacheIndexDao: AudioCacheIndexDao, + private val trackDao: CachedTrackDao, + private val likes: LikesRepository, + private val playerFactory: PlayerFactory, +) { + /** Cached tracks, most-recently-played first (liked included). */ + suspend fun recentlyPlayed(limit: Int = POOL_LIMIT): List = + materialize(residentIdsByRecency()).take(limit) + + /** Cached tracks that are in the user's liked set, recency-ordered. */ + suspend fun liked(limit: Int = POOL_LIMIT): List { + val likedSet = likes.likedTrackIds() + val ids = residentIdsByRecency().filter { it in likedSet } + return materialize(ids).take(limit) + } + + /** Recency-ordered ids whose audio is actually resident in the cache. */ + private suspend fun residentIdsByRecency(): List { + val resident = runCatching { playerFactory.simpleCache.keys.toSet() } + .getOrDefault(emptySet()) + return cacheIndexDao.trackIdsByRecency().filter { it in resident } + } + + /** Materialize ordered ids into TrackRefs from cached metadata, preserving order. */ + private suspend fun materialize(orderedIds: List): List { + if (orderedIds.isEmpty()) return emptyList() + val byId = trackDao.getByIds(orderedIds).associateBy { it.id } + return orderedIds.mapNotNull { byId[it]?.toDomain() } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheConfig.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheConfig.kt new file mode 100644 index 00000000..dc0453c2 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheConfig.kt @@ -0,0 +1,23 @@ +package com.fabledsword.minstrel.cache.audiocache + +/** + * Defaults for the 2-bucket audio cache. Matches the Flutter client. + * + * - `likedCapBytes`: cap for the protected bucket — cached files for + * tracks the user has liked. Evicted only after the rolling bucket + * is fully drained (almost never under normal use). + * - `rollingCapBytes`: cap for the unprotected bucket — incidental / + * auto-prefetch downloads. LRU within this bucket. + * + * Phase 11 (Settings) will allow user overrides; the values are kept + * generous so first-time installs work well without configuration. + */ +data class CacheConfig( + val likedCapBytes: Long = DEFAULT_LIKED_CAP_BYTES, + val rollingCapBytes: Long = DEFAULT_ROLLING_CAP_BYTES, +) { + companion object { + const val DEFAULT_LIKED_CAP_BYTES: Long = 200L * 1024 * 1024 // 200 MiB + const val DEFAULT_ROLLING_CAP_BYTES: Long = 150L * 1024 * 1024 // 150 MiB + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheSettings.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheSettings.kt new file mode 100644 index 00000000..246ac2a0 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheSettings.kt @@ -0,0 +1,37 @@ +package com.fabledsword.minstrel.cache.audiocache + +import kotlinx.serialization.Serializable + +private const val FIVE_GIB_BYTES = 5L * 1024 * 1024 * 1024 +private const val DEFAULT_PREFETCH_WINDOW = 5 + +/** + * User-tunable audio cache settings. Mirrors Flutter's `CacheSettings` + * (cache_settings_provider.dart) field-for-field. Persisted as a JSON + * blob on the auth_session single-row table via [AuthStore]. + * + * - [likedCapBytes]: budget for cached files of liked tracks. 0 means + * unlimited. Effective at next app start (SimpleCache is built once + * per process). + * - [rollingCapBytes]: budget for incidental / auto-prefetch downloads. + * Same 0=unlimited / restart-to-apply caveat. + * - [prefetchWindow]: 1..10. Number of next-tracks the prefetcher + * pre-downloads. Has no effect until the prefetcher lands (separate + * audit follow-up) — persisted now so the user can pre-configure. + * - [cacheLikedTracks]: when true, liking a track should also pin its + * audio to the liked-cache bucket. Has no effect until pin-on-like + * lands (separate follow-up). + * + * Defaults match Flutter: 5 GiB per bucket, prefetch=5, cache-liked=true. + */ +@Serializable +data class CacheSettings( + val likedCapBytes: Long = FIVE_GIB_BYTES, + val rollingCapBytes: Long = FIVE_GIB_BYTES, + val prefetchWindow: Int = DEFAULT_PREFETCH_WINDOW, + val cacheLikedTracks: Boolean = true, +) { + companion object { + val DEFAULT: CacheSettings = CacheSettings() + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt new file mode 100644 index 00000000..8e2d4d83 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt @@ -0,0 +1,84 @@ +package com.fabledsword.minstrel.cache.db + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao +import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao +import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao +import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao +import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao +import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao +import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao +import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao +import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao +import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao +import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao +import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao +import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao +import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao +import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity +import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity +import com.fabledsword.minstrel.cache.db.entities.CachedAlbumEntity +import com.fabledsword.minstrel.cache.db.entities.CachedArtistEntity +import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity +import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity +import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity +import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity +import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity +import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity +import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity +import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity +import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity +import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity + +/** + * Single Room database for the whole app. Mirrors the Flutter client's + * Drift `AppDb` — same logical schema, one entity-family per @Entity. + * + * Native v1 starts at schema version 1 (the Flutter Drift schemaVersion 11 + * is an internal client detail; the server is the source of truth and the + * sync controller refills on first launch). Future schema bumps land per + * change with explicit `Migration` entries; `fallbackToDestructiveMigration` + * in `DatabaseModule` is the safety net while we're pre-v1. + * + * Entity families land in Task 4.2 slices. The set will grow until the + * whole Drift schema is mirrored. + */ +@Database( + entities = [ + SyncMetadataEntity::class, + CachedArtistEntity::class, + CachedAlbumEntity::class, + CachedTrackEntity::class, + CachedLikeEntity::class, + CachedPlaylistEntity::class, + CachedPlaylistTrackEntity::class, + CachedQuarantineEntity::class, + AudioCacheIndexEntity::class, + CachedMutationEntity::class, + CachedResumeStateEntity::class, + CachedHomeIndexEntity::class, + CachedHistorySnapshotEntity::class, + AuthSessionEntity::class, + ], + version = 6, + exportSchema = true, +) +@TypeConverters(MinstrelTypeConverters::class) +abstract class AppDatabase : RoomDatabase() { + abstract fun syncMetadataDao(): SyncMetadataDao + abstract fun cachedArtistDao(): CachedArtistDao + abstract fun cachedAlbumDao(): CachedAlbumDao + abstract fun cachedTrackDao(): CachedTrackDao + abstract fun cachedLikeDao(): CachedLikeDao + abstract fun cachedPlaylistDao(): CachedPlaylistDao + abstract fun cachedPlaylistTrackDao(): CachedPlaylistTrackDao + abstract fun cachedQuarantineDao(): CachedQuarantineDao + abstract fun audioCacheIndexDao(): AudioCacheIndexDao + abstract fun cachedMutationDao(): CachedMutationDao + abstract fun cachedResumeStateDao(): CachedResumeStateDao + abstract fun cachedHomeIndexDao(): CachedHomeIndexDao + abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao + abstract fun authSessionDao(): AuthSessionDao +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt new file mode 100644 index 00000000..ece05791 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt @@ -0,0 +1,109 @@ +package com.fabledsword.minstrel.cache.db + +import android.content.Context +import androidx.room.Room +import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao +import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao +import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao +import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao +import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao +import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao +import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao +import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao +import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao +import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao +import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao +import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao +import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao +import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +@Suppress("TooManyFunctions") // Hilt @Provides bridges per DAO; one per family. +object DatabaseModule { + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): AppDatabase = + Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME) + // Pre-v1 safety net: rebuild on any schema mismatch. The sync + // controller refills the cache from the server on first + // launch, so users lose only the unsynced mutation queue + // (acceptable while we're iterating). Replace with explicit + // Migration entries before the first tagged release. + .fallbackToDestructiveMigration(dropAllTables = true) + .build() + + // DAO @Provides — Hilt can't inject AppDatabase abstract accessors + // directly. Each consumer-needed DAO gets a thin @Provides bridge. + @Provides + @Singleton + fun provideAuthSessionDao(db: AppDatabase): AuthSessionDao = db.authSessionDao() + + @Provides + @Singleton + fun provideCachedArtistDao(db: AppDatabase): CachedArtistDao = db.cachedArtistDao() + + @Provides + @Singleton + fun provideCachedAlbumDao(db: AppDatabase): CachedAlbumDao = db.cachedAlbumDao() + + @Provides + @Singleton + fun provideCachedTrackDao(db: AppDatabase): CachedTrackDao = db.cachedTrackDao() + + @Provides + @Singleton + fun provideCachedResumeStateDao(db: AppDatabase): CachedResumeStateDao = + db.cachedResumeStateDao() + + @Provides + @Singleton + fun provideCachedHomeIndexDao(db: AppDatabase): CachedHomeIndexDao = + db.cachedHomeIndexDao() + + @Provides + @Singleton + fun provideCachedHistorySnapshotDao(db: AppDatabase): CachedHistorySnapshotDao = + db.cachedHistorySnapshotDao() + + @Provides + @Singleton + fun provideCachedQuarantineDao(db: AppDatabase): CachedQuarantineDao = + db.cachedQuarantineDao() + + @Provides + @Singleton + fun provideCachedPlaylistDao(db: AppDatabase): CachedPlaylistDao = + db.cachedPlaylistDao() + + @Provides + @Singleton + fun provideCachedPlaylistTrackDao(db: AppDatabase): CachedPlaylistTrackDao = + db.cachedPlaylistTrackDao() + + @Provides + @Singleton + fun provideCachedLikeDao(db: AppDatabase): CachedLikeDao = db.cachedLikeDao() + + @Provides + @Singleton + fun provideCachedMutationDao(db: AppDatabase): CachedMutationDao = + db.cachedMutationDao() + + @Provides + @Singleton + fun provideSyncMetadataDao(db: AppDatabase): SyncMetadataDao = db.syncMetadataDao() + + @Provides + @Singleton + fun provideAudioCacheIndexDao(db: AppDatabase): AudioCacheIndexDao = + db.audioCacheIndexDao() + + private const val DATABASE_NAME = "minstrel.db" +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/TypeConverters.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/TypeConverters.kt new file mode 100644 index 00000000..2ac1e6a7 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/TypeConverters.kt @@ -0,0 +1,40 @@ +package com.fabledsword.minstrel.cache.db + +import androidx.room.TypeConverter +import kotlinx.datetime.Instant + +/** + * Source category for a cached audio file. Mirrors the Drift `CacheSource` + * enum used by the Flutter client's `audio_cache_index` table 1:1. + * + * - `MANUAL`: user explicitly pinned (e.g. "save for offline") + * - `AUTO_LIKED`: auto-cached because the track is liked + * - `AUTO_PLAYLIST`: auto-cached because it's in a pinned playlist + * - `AUTO_PREFETCH`: pre-warmed by background prefetch heuristics + * - `INCIDENTAL`: cached as a side effect of playback streaming + * + * Eviction priority order (first to evict): INCIDENTAL → AUTO_PREFETCH + * → AUTO_PLAYLIST → AUTO_LIKED → MANUAL. + */ +enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCIDENTAL } + +/** + * Cross-cutting Room type converters. Registered on `@Database` via + * `@TypeConverters(MinstrelTypeConverters::class)`. + * + * `kotlinx.datetime.Instant` ↔ `Long` epoch millis: the same convention the + * Flutter client uses (Drift `text()` column with ISO-8601 strings would + * also work but Long is leaner; we don't need human-readable rows in the + * SQLite file). + */ +class MinstrelTypeConverters { + @TypeConverter fun instantToLong(i: Instant?): Long? = i?.toEpochMilliseconds() + + @TypeConverter fun longToInstant(l: Long?): Instant? = + l?.let { Instant.fromEpochMilliseconds(it) } + + @TypeConverter fun sourceToName(s: CacheSource?): String? = s?.name + + @TypeConverter fun nameToSource(n: String?): CacheSource? = + n?.let { CacheSource.valueOf(it) } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AudioCacheIndexDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AudioCacheIndexDao.kt new file mode 100644 index 00000000..3991f50a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AudioCacheIndexDao.kt @@ -0,0 +1,68 @@ +package com.fabledsword.minstrel.cache.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.fabledsword.minstrel.cache.db.CacheSource +import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface AudioCacheIndexDao { + /** Used to render the "Downloaded" / "Offline" listing. */ + @Query("SELECT * FROM audio_cache_index ORDER BY lastPlayedAt DESC NULLS LAST") + fun observeAll(): Flow> + + @Query("SELECT * FROM audio_cache_index WHERE trackId = :trackId") + suspend fun get(trackId: String): AudioCacheIndexEntity? + + @Query("SELECT * FROM audio_cache_index WHERE trackId IN (:trackIds)") + suspend fun getByTrackIds(trackIds: List): List + + /** All cached track IDs — bucket-membership lookup for eviction. */ + @Query("SELECT trackId FROM audio_cache_index") + suspend fun allCachedTrackIds(): List + + /** + * Cached track IDs most-recently-played first (nulls last so + * never-played downloads sort after real plays). Backs the + * offline-pool shuffle sources. + */ + @Query("SELECT trackId FROM audio_cache_index ORDER BY lastPlayedAt DESC NULLS LAST") + suspend fun trackIdsByRecency(): List + + /** Eviction-candidate ordering — oldest lastPlayedAt first within a source. */ + @Query( + "SELECT * FROM audio_cache_index " + + "WHERE source = :source " + + "ORDER BY lastPlayedAt ASC NULLS FIRST, cachedAt ASC", + ) + suspend fun evictionCandidates(source: CacheSource): List + + /** Sum of bytes across rows; the rolling/liked bucket caps compare against this. */ + @Query("SELECT COALESCE(SUM(sizeBytes), 0) FROM audio_cache_index") + suspend fun totalBytes(): Long + + @Query( + "SELECT COALESCE(SUM(sizeBytes), 0) FROM audio_cache_index WHERE source = :source", + ) + suspend fun bytesBySource(source: CacheSource): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(row: AudioCacheIndexEntity) + + @Query( + "UPDATE audio_cache_index SET lastPlayedAt = :at WHERE trackId = :trackId", + ) + suspend fun touchLastPlayed(trackId: String, at: kotlinx.datetime.Instant) + + @Query("DELETE FROM audio_cache_index WHERE trackId = :trackId") + suspend fun deleteByTrackId(trackId: String) + + @Query("DELETE FROM audio_cache_index WHERE trackId IN (:trackIds)") + suspend fun deleteByTrackIds(trackIds: List) + + @Query("DELETE FROM audio_cache_index") + suspend fun clear() +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt new file mode 100644 index 00000000..5bb2ccc9 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt @@ -0,0 +1,45 @@ +package com.fabledsword.minstrel.cache.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface AuthSessionDao { + @Query("SELECT * FROM auth_session WHERE id = 0") + fun observe(): Flow + + @Query("SELECT * FROM auth_session WHERE id = 0") + suspend fun get(): AuthSessionEntity? + + /** Upsert with both fields at once — used for first-run init. */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(row: AuthSessionEntity) + + /** Partial update: change only the session cookie. */ + @Query("UPDATE auth_session SET sessionCookie = :cookie WHERE id = 0") + suspend fun setSessionCookie(cookie: String?) + + /** Partial update: change only the base URL. */ + @Query("UPDATE auth_session SET baseUrl = :baseUrl WHERE id = 0") + suspend fun setBaseUrl(baseUrl: String) + + /** Partial update: change only the serialized user identity JSON. */ + @Query("UPDATE auth_session SET userJson = :userJson WHERE id = 0") + suspend fun setUserJson(userJson: String?) + + /** Partial update: change only the theme override ("light"/"dark"/null=system). */ + @Query("UPDATE auth_session SET themeMode = :themeMode WHERE id = 0") + suspend fun setThemeMode(themeMode: String?) + + /** Partial update: change only the stable client install id. */ + @Query("UPDATE auth_session SET clientId = :clientId WHERE id = 0") + suspend fun setClientId(clientId: String?) + + /** Partial update: change only the serialized cache settings. */ + @Query("UPDATE auth_session SET cacheSettingsJson = :json WHERE id = 0") + suspend fun setCacheSettingsJson(json: String?) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedAlbumDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedAlbumDao.kt new file mode 100644 index 00000000..267b25d8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedAlbumDao.kt @@ -0,0 +1,35 @@ +package com.fabledsword.minstrel.cache.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.fabledsword.minstrel.cache.db.entities.CachedAlbumEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface CachedAlbumDao { + @Query("SELECT * FROM cached_albums ORDER BY sortTitle COLLATE NOCASE ASC") + fun observeAll(): Flow> + + @Query( + "SELECT * FROM cached_albums " + + "WHERE artistId = :artistId ORDER BY sortTitle COLLATE NOCASE ASC", + ) + fun observeByArtist(artistId: String): Flow> + + @Query("SELECT * FROM cached_albums WHERE id = :id") + suspend fun getById(id: String): CachedAlbumEntity? + + @Query("SELECT * FROM cached_albums WHERE id = :id") + fun observeById(id: String): Flow + + @Query("SELECT id FROM cached_albums WHERE fetchedAt < :before LIMIT :limit") + suspend fun idsStaleBefore(before: Long, limit: Int): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(rows: List) + + @Query("DELETE FROM cached_albums WHERE id IN (:ids)") + suspend fun deleteByIds(ids: List) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedArtistDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedArtistDao.kt new file mode 100644 index 00000000..450053b1 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedArtistDao.kt @@ -0,0 +1,29 @@ +package com.fabledsword.minstrel.cache.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.fabledsword.minstrel.cache.db.entities.CachedArtistEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface CachedArtistDao { + @Query("SELECT * FROM cached_artists ORDER BY sortName COLLATE NOCASE ASC") + fun observeAll(): Flow> + + @Query("SELECT * FROM cached_artists WHERE id = :id") + suspend fun getById(id: String): CachedArtistEntity? + + @Query("SELECT * FROM cached_artists WHERE id = :id") + fun observeById(id: String): Flow + + @Query("SELECT id FROM cached_artists WHERE fetchedAt < :before LIMIT :limit") + suspend fun idsStaleBefore(before: Long, limit: Int): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(rows: List) + + @Query("DELETE FROM cached_artists WHERE id IN (:ids)") + suspend fun deleteByIds(ids: List) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedHistorySnapshotDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedHistorySnapshotDao.kt new file mode 100644 index 00000000..737a2729 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedHistorySnapshotDao.kt @@ -0,0 +1,18 @@ +package com.fabledsword.minstrel.cache.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface CachedHistorySnapshotDao { + /** Observes the single snapshot row (null until the first fetch lands). */ + @Query("SELECT * FROM cached_history_snapshot WHERE id = 1") + fun observe(): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(row: CachedHistorySnapshotEntity) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedHomeIndexDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedHomeIndexDao.kt new file mode 100644 index 00000000..aea08ff3 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedHomeIndexDao.kt @@ -0,0 +1,33 @@ +package com.fabledsword.minstrel.cache.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface CachedHomeIndexDao { + @Query( + "SELECT * FROM cached_home_index " + + "WHERE section = :section ORDER BY position ASC", + ) + fun observeBySection(section: String): Flow> + + @Query( + "SELECT * FROM cached_home_index " + + "WHERE section = :section ORDER BY position ASC", + ) + suspend fun getBySection(section: String): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(rows: List) + + /** Replace-all pattern; sync wipes a section then re-inserts. */ + @Query("DELETE FROM cached_home_index WHERE section = :section") + suspend fun deleteBySection(section: String) + + @Query("DELETE FROM cached_home_index") + suspend fun clear() +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedLikeDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedLikeDao.kt new file mode 100644 index 00000000..21b3d56c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedLikeDao.kt @@ -0,0 +1,39 @@ +package com.fabledsword.minstrel.cache.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface CachedLikeDao { + /** All track IDs the user has liked. Audio-cache eviction uses this set. */ + @Query( + "SELECT entityId FROM cached_likes " + + "WHERE userId = :userId AND entityType = 'track'", + ) + fun observeLikedTrackIds(userId: String): Flow> + + @Query( + "SELECT entityId FROM cached_likes " + + "WHERE userId = :userId AND entityType = :entityType", + ) + fun observeLikedIdsOfType(userId: String, entityType: String): Flow> + + @Query( + "SELECT EXISTS(SELECT 1 FROM cached_likes " + + "WHERE userId = :userId AND entityType = :entityType AND entityId = :entityId)", + ) + fun observeIsLiked(userId: String, entityType: String, entityId: String): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(rows: List) + + @Query( + "DELETE FROM cached_likes " + + "WHERE userId = :userId AND entityType = :entityType AND entityId = :entityId", + ) + suspend fun delete(userId: String, entityType: String, entityId: String) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedMutationDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedMutationDao.kt new file mode 100644 index 00000000..60fd62d9 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedMutationDao.kt @@ -0,0 +1,34 @@ +package com.fabledsword.minstrel.cache.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.datetime.Instant + +@Dao +interface CachedMutationDao { + /** Pending count for the offline-indicator badge. */ + @Query("SELECT COUNT(*) FROM cached_mutations") + fun observePendingCount(): Flow + + /** FIFO drain order so the replayer processes oldest first. */ + @Query("SELECT * FROM cached_mutations ORDER BY id ASC") + suspend fun getAll(): List + + @Insert + suspend fun insert(row: CachedMutationEntity): Long + + @Query( + "UPDATE cached_mutations SET attempts = attempts + 1, lastAttemptAt = :at " + + "WHERE id = :id", + ) + suspend fun recordAttempt(id: Long, at: Instant) + + @Query("DELETE FROM cached_mutations WHERE id = :id") + suspend fun delete(id: Long) + + @Query("DELETE FROM cached_mutations") + suspend fun clear() +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistDao.kt new file mode 100644 index 00000000..bb3276da --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistDao.kt @@ -0,0 +1,70 @@ +package com.fabledsword.minstrel.cache.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface CachedPlaylistDao { + /** All playlists, user + system, sorted by name. */ + @Query("SELECT * FROM cached_playlists ORDER BY name COLLATE NOCASE ASC") + fun observeAll(): Flow> + + /** User playlists only (systemVariant IS NULL) — for the add-to-playlist sheet. */ + @Query( + "SELECT * FROM cached_playlists " + + "WHERE systemVariant IS NULL ORDER BY name COLLATE NOCASE ASC", + ) + fun observeUserPlaylists(): Flow> + + /** System playlists for the home screen. */ + @Query( + "SELECT * FROM cached_playlists " + + "WHERE systemVariant IS NOT NULL ORDER BY name COLLATE NOCASE ASC", + ) + fun observeSystemPlaylists(): Flow> + + @Query("SELECT * FROM cached_playlists WHERE id = :id") + suspend fun getById(id: String): CachedPlaylistEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(rows: List) + + @Query("DELETE FROM cached_playlists WHERE id IN (:ids)") + suspend fun deleteByIds(ids: List) + + @Query("DELETE FROM cached_playlists WHERE userId = :userId") + suspend fun deleteAllOwned(userId: String) + + @Query("DELETE FROM cached_playlists WHERE userId = :userId AND id NOT IN (:keepIds)") + suspend fun deleteOwnedNotIn(userId: String, keepIds: List) + + /** + * Atomically reconciles the cache against the fresh list response. + * Mirrors `flutter_client/lib/playlists/playlists_provider.dart:54` — + * `BuildSystemPlaylists` rotates system-playlist UUIDs every + * rebuild, so upsert alone leaves stale rows whose detail fetch + * 404s. Delete any of the user's rows not in [freshOwnedIds] (this + * catches both deleted owned playlists AND old system-playlist + * UUIDs, since system playlists are user-scoped and appear in + * [all] under their new id rather than in [freshOwnedIds]), then + * upsert the fresh set. + */ + @Transaction + suspend fun replaceList( + userId: String, + freshOwnedIds: List, + all: List, + ) { + if (freshOwnedIds.isEmpty()) { + deleteAllOwned(userId) + } else { + deleteOwnedNotIn(userId, freshOwnedIds) + } + upsertAll(all) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistTrackDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistTrackDao.kt new file mode 100644 index 00000000..02ff83b0 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistTrackDao.kt @@ -0,0 +1,47 @@ +package com.fabledsword.minstrel.cache.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface CachedPlaylistTrackDao { + @Query( + "SELECT * FROM cached_playlist_tracks " + + "WHERE playlistId = :playlistId ORDER BY position ASC", + ) + fun observeByPlaylist(playlistId: String): Flow> + + @Query( + "SELECT * FROM cached_playlist_tracks " + + "WHERE playlistId = :playlistId ORDER BY position ASC", + ) + suspend fun getByPlaylist(playlistId: String): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(rows: List) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertOrIgnore(row: CachedPlaylistTrackEntity) + + /** + * Highest existing position for [playlistId], or null if the + * playlist has no rows. Used by appendTrack to assign the next + * position before the optimistic Room write. + */ + @Query("SELECT MAX(position) FROM cached_playlist_tracks WHERE playlistId = :playlistId") + suspend fun maxPosition(playlistId: String): Int? + + /** Replace-all pattern for a playlist; called after a sync delta lands. */ + @Query("DELETE FROM cached_playlist_tracks WHERE playlistId = :playlistId") + suspend fun deleteByPlaylist(playlistId: String) + + @Query( + "DELETE FROM cached_playlist_tracks " + + "WHERE playlistId = :playlistId AND trackId IN (:trackIds)", + ) + suspend fun deleteTracksFromPlaylist(playlistId: String, trackIds: List) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedQuarantineDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedQuarantineDao.kt new file mode 100644 index 00000000..302c97f9 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedQuarantineDao.kt @@ -0,0 +1,52 @@ +package com.fabledsword.minstrel.cache.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface CachedQuarantineDao { + /** Newest-first listing for the Quarantine screen. */ + @Query("SELECT * FROM cached_quarantine_mine ORDER BY createdAt DESC") + fun observeAll(): Flow> + + /** Just the IDs — feed-filtering code reads this to hide tracks. */ + @Query("SELECT trackId FROM cached_quarantine_mine") + fun observeFlaggedTrackIds(): Flow> + + @Query( + "SELECT EXISTS(SELECT 1 FROM cached_quarantine_mine WHERE trackId = :trackId)", + ) + fun observeIsHidden(trackId: String): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(row: CachedQuarantineEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(rows: List) + + @Query("DELETE FROM cached_quarantine_mine WHERE trackId = :trackId") + suspend fun deleteByTrackId(trackId: String) + + @Query("DELETE FROM cached_quarantine_mine WHERE trackId IN (:trackIds)") + suspend fun deleteByTrackIds(trackIds: List) + + @Query("DELETE FROM cached_quarantine_mine") + suspend fun clear() + + /** + * Atomic full-replace from the server snapshot — one transaction so + * the `observeAll()` watcher sees a single merged emission, not a + * delete-then-insert flicker. Mirrors Flutter's MyQuarantineController + * `_refreshFromServer` transaction. + */ + @Transaction + suspend fun replaceAll(rows: List) { + clear() + upsertAll(rows) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedResumeStateDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedResumeStateDao.kt new file mode 100644 index 00000000..48931e28 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedResumeStateDao.kt @@ -0,0 +1,23 @@ +package com.fabledsword.minstrel.cache.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface CachedResumeStateDao { + @Query("SELECT * FROM cached_resume_state WHERE id = 1") + suspend fun get(): CachedResumeStateEntity? + + @Query("SELECT * FROM cached_resume_state WHERE id = 1") + fun observe(): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(row: CachedResumeStateEntity) + + @Query("DELETE FROM cached_resume_state") + suspend fun clear() +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt new file mode 100644 index 00000000..b07095a6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt @@ -0,0 +1,35 @@ +package com.fabledsword.minstrel.cache.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface CachedTrackDao { + @Query( + "SELECT * FROM cached_tracks " + + "WHERE albumId = :albumId ORDER BY discNumber ASC, trackNumber ASC", + ) + fun observeByAlbum(albumId: String): Flow> + + @Query("SELECT * FROM cached_tracks WHERE id = :id") + suspend fun getById(id: String): CachedTrackEntity? + + @Query("SELECT * FROM cached_tracks WHERE id = :id") + fun observeById(id: String): Flow + + @Query("SELECT * FROM cached_tracks WHERE id IN (:ids)") + suspend fun getByIds(ids: List): List + + @Query("SELECT id FROM cached_tracks WHERE fetchedAt < :before LIMIT :limit") + suspend fun idsStaleBefore(before: Long, limit: Int): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(rows: List) + + @Query("DELETE FROM cached_tracks WHERE id IN (:ids)") + suspend fun deleteByIds(ids: List) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/SyncMetadataDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/SyncMetadataDao.kt new file mode 100644 index 00000000..f9a71df0 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/SyncMetadataDao.kt @@ -0,0 +1,20 @@ +package com.fabledsword.minstrel.cache.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface SyncMetadataDao { + @Query("SELECT * FROM sync_metadata WHERE id = 0") + fun observe(): Flow + + @Query("SELECT * FROM sync_metadata WHERE id = 0") + suspend fun get(): SyncMetadataEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(row: SyncMetadataEntity) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AudioCacheIndexEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AudioCacheIndexEntity.kt new file mode 100644 index 00000000..efdc9a87 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AudioCacheIndexEntity.kt @@ -0,0 +1,31 @@ +package com.fabledsword.minstrel.cache.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.fabledsword.minstrel.cache.db.CacheSource +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +/** + * One row per fully-downloaded audio file. Mirrors + * `flutter_client/lib/cache/db.dart`'s `AudioCacheIndex` Drift table. + * + * Drives the 2-bucket LRU eviction (Phase 12 AudioCacheEvictionWorker): + * - `incidental` files (streamed-and-cached side effect) evict first + * - `autoPrefetch` second + * - `manual` (user explicitly pinned) evict last + * + * `cachedAt` = when we downloaded. `lastPlayedAt` = when the user last + * played it; drives the offline "recently played" ordering and is the + * tiebreaker for LRU eviction within a source bucket. Nullable on + * brand-new rows until the first post-cache play touches them. + */ +@Entity(tableName = "audio_cache_index") +data class AudioCacheIndexEntity( + @PrimaryKey val trackId: String, + val path: String, + val sizeBytes: Int, + val cachedAt: Instant = Clock.System.now(), + val lastPlayedAt: Instant? = null, + val source: CacheSource, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt new file mode 100644 index 00000000..c36cfa34 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt @@ -0,0 +1,39 @@ +package com.fabledsword.minstrel.cache.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Single-row table holding the user's session cookie, configured + * server base URL, the serialized current user identity, and the + * theme-override preference. + * + * `id = 0` is the only valid row. `sessionCookie` is null when the + * user is logged out (or has never logged in). `baseUrl` always has + * a value — `AuthStore.DEFAULT_BASE_URL` is used until the user + * configures one in Settings. `userJson` is the JSON-encoded + * UserRef captured at sign-in so the username + isAdmin survive + * a cold restart (the cookie alone doesn't carry identity). + * `themeMode` is "system" / "light" / "dark" (or null = system). + * + * The table is the de-facto single-row app-prefs row at this point, + * not strictly auth-only. Kept under the auth_session name to avoid + * a schema-rename migration; a future cleanup could rename to + * `app_preferences`. + */ +@Entity(tableName = "auth_session") +data class AuthSessionEntity( + @PrimaryKey val id: Int = 0, + val sessionCookie: String? = null, + val baseUrl: String, + val userJson: String? = null, + val themeMode: String? = null, + val clientId: String? = null, + /** + * JSON-encoded CacheSettings (cache/audiocache/CacheSettings.kt). + * Null = use defaults. Stored as a JSON blob rather than four + * individual columns to keep schema changes cheap when the + * CacheSettings shape evolves. + */ + val cacheSettingsJson: String? = null, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedAlbumEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedAlbumEntity.kt new file mode 100644 index 00000000..6255a20b --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedAlbumEntity.kt @@ -0,0 +1,22 @@ +package com.fabledsword.minstrel.cache.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +/** + * Cache row for one album. Mirrors `flutter_client/lib/cache/db.dart`'s + * `CachedAlbums` Drift table. + */ +@Entity(tableName = "cached_albums") +data class CachedAlbumEntity( + @PrimaryKey val id: String, + val artistId: String, + val title: String, + val sortTitle: String, + val releaseDate: String? = null, + val coverPath: String? = null, + val mbid: String? = null, + val fetchedAt: Instant = Clock.System.now(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedArtistEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedArtistEntity.kt new file mode 100644 index 00000000..8601805c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedArtistEntity.kt @@ -0,0 +1,26 @@ +package com.fabledsword.minstrel.cache.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +/** + * Cache row for one artist. Mirrors `flutter_client/lib/cache/db.dart`'s + * `CachedArtists` Drift table. + * + * Column names follow Kotlin idiom (camelCase) rather than Drift's + * snake_case — the schema is purely internal to the native client; the + * sync controller exchanges snake_case JSON with the server and our + * mappers translate at the boundary. + */ +@Entity(tableName = "cached_artists") +data class CachedArtistEntity( + @PrimaryKey val id: String, + val name: String, + val sortName: String, + val mbid: String? = null, + val artistThumbPath: String? = null, + val artistFanartPath: String? = null, + val fetchedAt: Instant = Clock.System.now(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedHistorySnapshotEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedHistorySnapshotEntity.kt new file mode 100644 index 00000000..f6d68747 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedHistorySnapshotEntity.kt @@ -0,0 +1,26 @@ +package com.fabledsword.minstrel.cache.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +/** + * Single-row snapshot of the `/api/me/history` response, stored as the + * raw wire JSON so the History tab paints instantly from disk on cold + * open and survives connectivity loss. Mirrors the Flutter Drift + * `CachedHistorySnapshot` table (id defaults to 1; one row total). + * + * Cache-first + SWR: the tab observes this row and a background refresh + * overwrites it, so the freshest plays land without a blocking spinner. + */ +@Entity(tableName = "cached_history_snapshot") +data class CachedHistorySnapshotEntity( + @PrimaryKey val id: Int = SINGLETON_ID, + val json: String, + val updatedAt: Instant = Clock.System.now(), +) { + companion object { + const val SINGLETON_ID = 1 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedHomeIndexEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedHomeIndexEntity.kt new file mode 100644 index 00000000..70d5e11c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedHomeIndexEntity.kt @@ -0,0 +1,34 @@ +package com.fabledsword.minstrel.cache.db.entities + +import androidx.room.Entity +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +/** + * Per-item row driving the Home screen sections. Mirrors + * `flutter_client/lib/cache/db.dart`'s `CachedHomeIndex` Drift table. + * + * `section` is one of (matching /api/home keys): + * - "recently_added_albums" + * - "rediscover_albums" + * - "rediscover_artists" + * - "most_played_tracks" + * - "last_played_artists" + * + * `entityType` is "album" | "artist" | "track" — dispatches hydration + * to the right per-entity endpoint when a tile is rendered. + * + * The composite PK (section, position) means there's exactly one row + * per slot per section; sync replaces in-place by upsert. + */ +@Entity( + tableName = "cached_home_index", + primaryKeys = ["section", "position"], +) +data class CachedHomeIndexEntity( + val section: String, + val position: Int, + val entityType: String, + val entityId: String, + val fetchedAt: Instant = Clock.System.now(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedLikeEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedLikeEntity.kt new file mode 100644 index 00000000..6f3af1e2 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedLikeEntity.kt @@ -0,0 +1,26 @@ +package com.fabledsword.minstrel.cache.db.entities + +import androidx.room.Entity +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +/** + * Like membership row. Mirrors `flutter_client/lib/cache/db.dart`'s + * `CachedLikes` Drift table. Composite primary key — one user may + * independently like a track AND its album AND its artist; rows are + * disambiguated by the (userId, entityType, entityId) triple. + * + * `entityType` is one of "track" | "album" | "artist". Stored as a + * plain string for parity with the Drift schema and the server's wire + * format; an enum doesn't earn its keep here. + */ +@Entity( + tableName = "cached_likes", + primaryKeys = ["userId", "entityType", "entityId"], +) +data class CachedLikeEntity( + val userId: String, + val entityType: String, + val entityId: String, + val likedAt: Instant = Clock.System.now(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedMutationEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedMutationEntity.kt new file mode 100644 index 00000000..bbc979bb --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedMutationEntity.kt @@ -0,0 +1,32 @@ +package com.fabledsword.minstrel.cache.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +/** + * One row per pending offline-write. Mirrors + * `flutter_client/lib/cache/db.dart`'s `CachedMutations` Drift table. + * + * MutationQueue.enqueue() inserts a row when a server-write fails with + * an IOException; MutationReplayer.drain() pops and re-attempts each + * row when connectivity returns (Phase 12.2). + * + * `kind` is a stable string registered in `MutationKind` — the replayer + * looks up the handler in a kind→suspend-function map. Unknown kinds + * are dropped so a malformed row can't wedge the queue forever. + * + * `payload` is the JSON-serialized args needed to replay the REST call. + * Shape is kind-specific; the kind enum doubles as a tag for which + * decoder to apply at drain time. + */ +@Entity(tableName = "cached_mutations") +data class CachedMutationEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val kind: String, + val payload: String, + val createdAt: Instant = Clock.System.now(), + val lastAttemptAt: Instant? = null, + val attempts: Int = 0, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedPlaylistEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedPlaylistEntity.kt new file mode 100644 index 00000000..8b0bb20a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedPlaylistEntity.kt @@ -0,0 +1,29 @@ +package com.fabledsword.minstrel.cache.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +/** + * Cache row for one playlist (user or system). Mirrors + * `flutter_client/lib/cache/db.dart`'s `CachedPlaylists` Drift table. + * + * `systemVariant` is null for user playlists and one of + * "for_you" / "songs_like_artist" / "discover" / "todays_mix" / etc. + * for system-generated mixes. Lets the add-to-playlist sheet filter + * system playlists locally without a REST round-trip. + */ +@Entity(tableName = "cached_playlists") +data class CachedPlaylistEntity( + @PrimaryKey val id: String, + val userId: String, + val name: String, + val description: String = "", + val isPublic: Boolean = false, + val coverPath: String? = null, + val trackCount: Int = 0, + val durationSec: Int = 0, + val systemVariant: String? = null, + val fetchedAt: Instant = Clock.System.now(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedPlaylistTrackEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedPlaylistTrackEntity.kt new file mode 100644 index 00000000..ef29db40 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedPlaylistTrackEntity.kt @@ -0,0 +1,19 @@ +package com.fabledsword.minstrel.cache.db.entities + +import androidx.room.Entity + +/** + * Ordered membership of tracks within a playlist. Mirrors + * `flutter_client/lib/cache/db.dart`'s `CachedPlaylistTracks` Drift table. + * Composite PK so the same track can only appear once per playlist; + * `position` carries the ordering. + */ +@Entity( + tableName = "cached_playlist_tracks", + primaryKeys = ["playlistId", "trackId"], +) +data class CachedPlaylistTrackEntity( + val playlistId: String, + val trackId: String, + val position: Int = 0, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedQuarantineEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedQuarantineEntity.kt new file mode 100644 index 00000000..5d50238f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedQuarantineEntity.kt @@ -0,0 +1,36 @@ +package com.fabledsword.minstrel.cache.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +/** + * The current user's quarantine flag for one track. Mirrors + * `flutter_client/lib/cache/db.dart`'s `CachedQuarantineMine` Drift + * table. + * + * The flat denormalized track/album/artist columns let the Quarantine + * screen render off this single table without a join — the server + * returns the full snapshot on /api/me/quarantine so we mirror that + * shape locally. + * + * `createdAt` is the server-issued timestamp preserved as an opaque + * ISO-8601 string (it's the canonical "when this was flagged" value); + * `fetchedAt` is our local sync-time marker. + */ +@Entity(tableName = "cached_quarantine_mine") +data class CachedQuarantineEntity( + @PrimaryKey val trackId: String, + val reason: String, + val notes: String? = null, + val createdAt: String, + val trackTitle: String, + val trackDurationMs: Int = 0, + val albumId: String, + val albumTitle: String, + val albumCoverArtPath: String? = null, + val artistId: String, + val artistName: String, + val fetchedAt: Instant = Clock.System.now(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedResumeStateEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedResumeStateEntity.kt new file mode 100644 index 00000000..1066006d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedResumeStateEntity.kt @@ -0,0 +1,28 @@ +package com.fabledsword.minstrel.cache.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +/** + * Single-row snapshot of the last playback session — queue (as JSON), + * current index, position, and source tag. Mirrors + * `flutter_client/lib/cache/db.dart`'s `CachedResumeState` Drift table. + * + * Lets a torn-down session (the player's idle/dismissed teardown) + * resume on next launch; without it the headset / lock-screen play + * button has nothing to act on. The Flutter codebase persists this on + * every queue change and reads on cold start. + * + * The `json` field carries the entire ResumeState shape (queue trackrefs, + * currentIndex, positionMs, sourceTag). Kotlin-side deserialization + * happens in the ResumeController (Phase 6.5) so the schema stays + * stable across resume-shape changes. + */ +@Entity(tableName = "cached_resume_state") +data class CachedResumeStateEntity( + @PrimaryKey val id: Int = 1, + val json: String, + val updatedAt: Instant = Clock.System.now(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedTrackEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedTrackEntity.kt new file mode 100644 index 00000000..8653a60a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedTrackEntity.kt @@ -0,0 +1,25 @@ +package com.fabledsword.minstrel.cache.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +/** + * Cache row for one track. Mirrors `flutter_client/lib/cache/db.dart`'s + * `CachedTracks` Drift table. + */ +@Entity(tableName = "cached_tracks") +data class CachedTrackEntity( + @PrimaryKey val id: String, + val albumId: String, + val artistId: String, + val title: String, + val durationMs: Int = 0, + val trackNumber: Int? = null, + val discNumber: Int? = null, + val filePath: String? = null, + val fileFormat: String? = null, + val genre: String? = null, + val fetchedAt: Instant = Clock.System.now(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/SyncMetadataEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/SyncMetadataEntity.kt new file mode 100644 index 00000000..6cb775d7 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/SyncMetadataEntity.kt @@ -0,0 +1,24 @@ +package com.fabledsword.minstrel.cache.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.datetime.Instant + +/** + * Single-row table that tracks where the SyncController left off. + * + * Mirrors the Flutter Drift `sync_metadata` table. There is only ever + * one row at `id = 0`; the SyncController upserts on every successful + * `/api/sync` call. + * + * Pulled into Task 4.1 to give Room a compileable @Database (it needs + * at least one entity); the SyncController itself lands in Phase 12.4. + */ +@Entity(tableName = "sync_metadata") +data class SyncMetadataEntity( + @PrimaryKey val id: Int = 0, + /** Server-issued cursor used as `cursor` query param on the next sync. */ + val cursor: Long = 0, + /** Wall-clock time of the last successful sync — diagnostic only. */ + val lastSyncAt: Instant? = null, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt new file mode 100644 index 00000000..f08f8354 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt @@ -0,0 +1,205 @@ +package com.fabledsword.minstrel.cache.mutations + +import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao +import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Stable mutation kinds the queue knows how to replay. Strings are + * persisted in `cached_mutations.kind` so renaming a variant breaks + * the on-disk queue — only ever add new kinds, never rename. + */ +object MutationKind { + const val LIKE_TOGGLE: String = "like_toggle" + const val REQUEST_CREATE: String = "request_create" + const val QUARANTINE_UNFLAG: String = "quarantine_unflag" + const val PLAYLIST_APPEND: String = "playlist_append" + const val QUARANTINE_FLAG: String = "quarantine_flag" + const val PLAY_OFFLINE: String = "play_offline" + const val REQUEST_CANCEL: String = "request_cancel" +} + +/** + * Persisted payload for `MutationKind.LIKE_TOGGLE`. `entityType` is + * one of "artist" | "album" | "track" matching the server's like-path + * segment; `desiredState` is the *target* (true = liked, false = not + * liked) so a single replay slot can collapse repeated toggles. + */ +@Serializable +data class LikeTogglePayload( + val entityType: String, + val entityId: String, + val desiredState: Boolean, +) + +/** + * Persisted payload for `MutationKind.REQUEST_CREATE` (Discover → + * Lidarr request that landed during a connectivity hiccup). Optional + * fields stay null when not applicable; the replayer rebuilds the + * `CreateRequestBody` from these. + */ +@Serializable +data class RequestCreatePayload( + val kind: String, + val artistMbid: String, + val artistName: String, + val albumMbid: String? = null, + val albumTitle: String? = null, + val trackMbid: String? = null, + val trackTitle: String? = null, +) + +/** + * Minimal write-side of the offline mutation queue. Phase 8 v1 only + * enqueues — the connectivity-aware replayer that drains the queue + * lands with Phase 12.2's SyncController. + * + * Callers (LikesRepository et al.) follow the optimistic-write + * pattern: mutate Room first so the UI reflects the change + * immediately, fire the REST call as a best-effort attempt, and on + * failure enqueue here so the replayer eventually pushes it through. + * This matches `feedback_offline_first_for_server_writes` — writes + * never go fire-and-forget. + */ +@Singleton +class MutationQueue @Inject constructor( + private val dao: CachedMutationDao, + private val json: Json, +) { + suspend fun enqueueLikeToggle( + entityType: String, + entityId: String, + desiredState: Boolean, + ): Long = dao.insert( + CachedMutationEntity( + kind = MutationKind.LIKE_TOGGLE, + payload = json.encodeToString( + LikeTogglePayload.serializer(), + LikeTogglePayload(entityType, entityId, desiredState), + ), + ), + ) + + suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = dao.insert( + CachedMutationEntity( + kind = MutationKind.REQUEST_CREATE, + payload = json.encodeToString(RequestCreatePayload.serializer(), payload), + ), + ) + + suspend fun enqueueQuarantineUnflag(trackId: String): Long = dao.insert( + CachedMutationEntity( + kind = MutationKind.QUARANTINE_UNFLAG, + payload = json.encodeToString( + QuarantineUnflagPayload.serializer(), + QuarantineUnflagPayload(trackId), + ), + ), + ) + + suspend fun enqueuePlaylistAppend( + playlistId: String, + trackIds: List, + ): Long = dao.insert( + CachedMutationEntity( + kind = MutationKind.PLAYLIST_APPEND, + payload = json.encodeToString( + PlaylistAppendPayload.serializer(), + PlaylistAppendPayload(playlistId, trackIds), + ), + ), + ) + + suspend fun enqueueQuarantineFlag( + trackId: String, + reason: String, + notes: String, + ): Long = dao.insert( + CachedMutationEntity( + kind = MutationKind.QUARANTINE_FLAG, + payload = json.encodeToString( + QuarantineFlagPayload.serializer(), + QuarantineFlagPayload(trackId, reason, notes), + ), + ), + ) + + suspend fun enqueuePlayOffline(payload: PlayOfflinePayload): Long = dao.insert( + CachedMutationEntity( + kind = MutationKind.PLAY_OFFLINE, + payload = json.encodeToString(PlayOfflinePayload.serializer(), payload), + ), + ) + + suspend fun enqueueRequestCancel(requestId: String): Long = dao.insert( + CachedMutationEntity( + kind = MutationKind.REQUEST_CANCEL, + payload = json.encodeToString( + RequestCancelPayload.serializer(), + RequestCancelPayload(requestId), + ), + ), + ) +} + +/** + * Persisted payload for `MutationKind.QUARANTINE_UNFLAG` — the + * `DELETE /api/quarantine/{trackId}` call lost during a connectivity + * hiccup. The replayer just re-fires the DELETE; idempotent server-side. + */ +@Serializable +data class QuarantineUnflagPayload(val trackId: String) + +/** + * Persisted payload for `MutationKind.PLAYLIST_APPEND`. Mirrors the + * Flutter wire shape: list of track IDs to append to a specific + * playlist. Server is idempotent at the (playlist_id, track_id) pair + * level — re-firing the same payload yields no duplicates. + */ +@Serializable +data class PlaylistAppendPayload( + val playlistId: String, + val trackIds: List, +) + +/** + * Persisted payload for `MutationKind.QUARANTINE_FLAG`. The replayer + * re-fires `POST /api/quarantine` with this body when the original + * call failed; server-side, re-flagging an already-flagged (user, + * track) pair is idempotent. + */ +@Serializable +data class QuarantineFlagPayload( + val trackId: String, + val reason: String, + val notes: String, +) + +/** + * Persisted payload for `MutationKind.PLAY_OFFLINE`. The replayer + * re-fires `POST /api/events` with type=play_offline; the server + * records start+end from `atIso` (the original play-start time) + * + `durationPlayedMs`, applies the canonical skip rule, and + * advances #415 rotation when source is a system playlist. + */ +@Serializable +data class PlayOfflinePayload( + val trackId: String, + val clientId: String, + val atIso: String, + val durationPlayedMs: Long, + val source: String? = null, +) + +/** + * Persisted payload for `MutationKind.REQUEST_CANCEL` — the + * `POST /api/requests/{id}/cancel` call lost during a connectivity + * hiccup. Server is idempotent on re-cancel (already-cancelled + * request stays cancelled). + */ +@Serializable +data class RequestCancelPayload(val requestId: String) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationReplayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationReplayer.kt new file mode 100644 index 00000000..06fb336e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationReplayer.kt @@ -0,0 +1,261 @@ +package com.fabledsword.minstrel.cache.mutations + +import com.fabledsword.minstrel.api.endpoints.AppendTracksRequest +import com.fabledsword.minstrel.api.endpoints.DiscoverApi +import com.fabledsword.minstrel.api.endpoints.EventsApi +import com.fabledsword.minstrel.api.endpoints.FlagRequest +import com.fabledsword.minstrel.api.endpoints.LikesApi +import com.fabledsword.minstrel.api.endpoints.PlaylistsApi +import com.fabledsword.minstrel.api.endpoints.QuarantineApi +import com.fabledsword.minstrel.api.endpoints.RequestsApi +import com.fabledsword.minstrel.models.wire.PlayOfflineRequest +import com.fabledsword.minstrel.auth.AuthStore +import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao +import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity +import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.likes.data.LikesRepository +import com.fabledsword.minstrel.models.wire.CreateRequestBody +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.datetime.Clock +import kotlinx.serialization.json.Json +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Drains the offline MutationQueue. Reads pending rows in FIFO order, + * re-fires each call against the raw Retrofit API (NOT the high-level + * Repository, which would re-enqueue on failure → infinite loop), and + * deletes the row on 2xx success. + * + * Triggered by: + * - app start (MinstrelApplication @Inject forces the singleton's + * init block to subscribe) + * - every time AuthStore.sessionCookie transitions to a non-null + * value (sign-in, app launch with persisted cookie) + * + * Single in-flight via the `mutex` — back-to-back triggers collapse + * into one drain pass. A proper connectivity-listener / WorkManager + * job that re-tries on Wi-Fi-came-back is a follow-up; for MVP the + * sign-in + app-open triggers cover the common offline → online + * transition (operator unlocks phone, opens app). + * + * Failed rows stay in the queue with their `attempts` and + * `lastAttemptAt` updated. They get another chance on the next + * trigger; no exponential backoff or max-attempts yet — the rows are + * small (low single-digit count for typical use) so retry-forever is + * cheap. + */ +@Singleton +class MutationReplayer @Inject constructor( + private val dao: CachedMutationDao, + private val authStore: AuthStore, + private val json: Json, + @ApplicationScope private val scope: CoroutineScope, + retrofit: Retrofit, +) { + private val likesApi: LikesApi = retrofit.create() + private val discoverApi: DiscoverApi = retrofit.create() + private val quarantineApi: QuarantineApi = retrofit.create() + private val playlistsApi: PlaylistsApi = retrofit.create() + private val eventsApi: EventsApi = retrofit.create() + private val requestsApi: RequestsApi = retrofit.create() + + private val mutex = Mutex() + + init { + scope.launch { + authStore.sessionCookie + .filterNotNull() + .distinctUntilChanged() + .collect { drainSafe() } + } + } + + /** Public entry-point; coalesces concurrent triggers via a Mutex. */ + suspend fun drainSafe() { + if (!mutex.tryLock()) return + try { + drain() + } finally { + mutex.unlock() + } + } + + private suspend fun drain() { + val rows = dao.getAll() + for (row in rows) { + val sent = runCatching { dispatch(row) }.getOrDefault(false) + if (sent) { + dao.delete(row.id) + } else { + dao.recordAttempt(row.id, Clock.System.now()) + } + } + } + + /** + * Returns true if the server accepted the call, false on any + * failure (transport, server error, decode). The replayer treats + * false as "leave the row for next pass" — it doesn't try to + * distinguish permanent rejections from transient ones; a row + * that consistently fails will accumulate attempt count and + * lastAttemptAt for diagnostics. + */ + private suspend fun dispatch(row: CachedMutationEntity): Boolean = when (row.kind) { + MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload) + MutationKind.REQUEST_CREATE -> dispatchRequestCreate(row.payload) + MutationKind.QUARANTINE_UNFLAG -> dispatchQuarantineUnflag(row.payload) + MutationKind.PLAYLIST_APPEND -> dispatchPlaylistAppend(row.payload) + MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload) + MutationKind.PLAY_OFFLINE -> dispatchPlayOffline(row.payload) + MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload) + else -> { + // Unknown kind — drop the row by claiming success so a + // stale schema entry can't wedge the queue forever. + true + } + } + + private suspend fun dispatchLikeToggle(payload: String): Boolean { + val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload) + val kindPath = when (decoded.entityType) { + LikesRepository.ENTITY_ARTIST -> "artists" + LikesRepository.ENTITY_ALBUM -> "albums" + LikesRepository.ENTITY_TRACK -> "tracks" + else -> return true // unknown variant — drop. + } + return try { + if (decoded.desiredState) { + likesApi.like(kindPath, decoded.entityId) + } else { + likesApi.unlike(kindPath, decoded.entityId) + } + true + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + // Intentional swallow: the row stays in the queue with + // attempts + lastAttemptAt incremented; next drain pass + // retries. Per-attempt diagnostic logging lands when we + // wire up Timber at the replayer level. + false + } + } + + private suspend fun dispatchRequestCreate(payload: String): Boolean { + val decoded = json.decodeFromString(RequestCreatePayload.serializer(), payload) + val body = CreateRequestBody( + kind = decoded.kind, + artistMbid = decoded.artistMbid, + artistName = decoded.artistName, + albumMbid = decoded.albumMbid, + albumTitle = decoded.albumTitle, + trackMbid = decoded.trackMbid, + trackTitle = decoded.trackTitle, + ) + return try { + discoverApi.createRequest(body) + true + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + // Intentional swallow: the row stays in the queue with + // attempts + lastAttemptAt incremented; next drain pass + // retries. Per-attempt diagnostic logging lands when we + // wire up Timber at the replayer level. + false + } + } + + private suspend fun dispatchQuarantineUnflag(payload: String): Boolean { + val decoded = json.decodeFromString(QuarantineUnflagPayload.serializer(), payload) + return try { + quarantineApi.unflag(decoded.trackId) + true + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + // Intentional swallow: the row stays in the queue with + // attempts + lastAttemptAt incremented; next drain pass + // retries. Per-attempt diagnostic logging lands when we + // wire up Timber at the replayer level. + false + } + } + + private suspend fun dispatchPlaylistAppend(payload: String): Boolean { + val decoded = json.decodeFromString(PlaylistAppendPayload.serializer(), payload) + return try { + playlistsApi.appendTracks( + playlistId = decoded.playlistId, + body = AppendTracksRequest(trackIds = decoded.trackIds), + ) + true + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + // Intentional swallow: row stays queued; next drain pass retries. + false + } + } + + private suspend fun dispatchQuarantineFlag(payload: String): Boolean { + val decoded = json.decodeFromString(QuarantineFlagPayload.serializer(), payload) + return try { + quarantineApi.flag( + FlagRequest( + trackId = decoded.trackId, + reason = decoded.reason, + notes = decoded.notes, + ), + ) + true + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + // Intentional swallow: row stays queued; next drain pass retries. + false + } + } + + private suspend fun dispatchPlayOffline(payload: String): Boolean { + val decoded = json.decodeFromString(PlayOfflinePayload.serializer(), payload) + return try { + eventsApi.playOffline( + PlayOfflineRequest( + trackId = decoded.trackId, + clientId = decoded.clientId, + at = decoded.atIso, + durationPlayedMs = decoded.durationPlayedMs, + source = decoded.source, + ), + ) + true + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + // Intentional swallow: row stays queued; next drain pass retries. + false + } + } + + private suspend fun dispatchRequestCancel(payload: String): Boolean { + val decoded = json.decodeFromString(RequestCancelPayload.serializer(), payload) + return try { + requestsApi.cancel(decoded.requestId) + true + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + // Intentional swallow: row stays queued; next drain pass retries. + false + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt new file mode 100644 index 00000000..d50fac19 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt @@ -0,0 +1,181 @@ +package com.fabledsword.minstrel.cache.sync + +import com.fabledsword.minstrel.api.endpoints.SyncApi +import com.fabledsword.minstrel.auth.AuthStore +import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao +import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao +import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao +import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao +import com.fabledsword.minstrel.cache.db.entities.CachedAlbumEntity +import com.fabledsword.minstrel.cache.db.entities.CachedArtistEntity +import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity +import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity +import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.models.wire.SyncAlbumWire +import com.fabledsword.minstrel.models.wire.SyncArtistWire +import com.fabledsword.minstrel.models.wire.SyncTrackWire +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Drives the delta sync against `/api/library/sync` (#357). Mirrors + * the Flutter `SyncController`, scoped to the entity types the + * native client cares about (artist/album/track). Likes and + * playlists get their own dedicated refresh paths so the sync + * controller doesn't need to track them. + * + * Self-starting: on construction, observes + * AuthStore.sessionCookie and runs a sync any time the cookie + * transitions from null to a value (fresh sign-in OR cold start + * with a persisted cookie). Re-running on every cookie change is + * what makes the Library tabs populate within seconds of sign-in. + * + * Wipes-on-410: when the server's compaction window has passed the + * stored cursor, the response is 410 Gone. The controller drops the + * three local caches and recurses with cursor=0 — same wipe behavior + * as the Flutter port. + */ +@Singleton +class SyncController @Inject constructor( + private val syncMetadataDao: SyncMetadataDao, + private val artistDao: CachedArtistDao, + private val albumDao: CachedAlbumDao, + private val trackDao: CachedTrackDao, + private val authStore: AuthStore, + @ApplicationScope private val scope: CoroutineScope, + retrofit: Retrofit, +) { + private val api: SyncApi = retrofit.create() + + init { + scope.launch { + authStore.sessionCookie + .filterNotNull() + .distinctUntilChanged() + .collect { syncSafe() } + } + } + + /** Public entry-point for "Sync now" affordances. Swallows errors. */ + suspend fun syncSafe() { + runCatching { sync() } + } + + private suspend fun sync() { + val meta = syncMetadataDao.get() + val cursor = meta?.cursor ?: 0L + val response = api.sync(cursor) + when (response.code()) { + HTTP_NO_CONTENT -> { + touchLastSyncAt(cursor) + } + HTTP_GONE -> { + wipeAndRetry() + } + HTTP_OK -> { + val body = response.body() ?: return + applyDeltas(body.upserts.artist, body.upserts.album, body.upserts.track) + applyDeletes(body.deletes.artist, body.deletes.album, body.deletes.track) + syncMetadataDao.upsert( + SyncMetadataEntity( + id = 0, + cursor = body.cursor, + lastSyncAt = Clock.System.now(), + ), + ) + } + else -> { + // Unhandled status — treat as a no-op for v1; the next + // app start retries from the same cursor. + } + } + } + + private suspend fun applyDeltas( + artists: List, + albums: List, + tracks: List, + ) { + if (artists.isNotEmpty()) artistDao.upsertAll(artists.map { it.toEntity() }) + if (albums.isNotEmpty()) albumDao.upsertAll(albums.map { it.toEntity() }) + if (tracks.isNotEmpty()) trackDao.upsertAll(tracks.map { it.toEntity() }) + } + + private suspend fun applyDeletes( + artistIds: List, + albumIds: List, + trackIds: List, + ) { + if (artistIds.isNotEmpty()) artistDao.deleteByIds(artistIds) + if (albumIds.isNotEmpty()) albumDao.deleteByIds(albumIds) + if (trackIds.isNotEmpty()) trackDao.deleteByIds(trackIds) + } + + private suspend fun touchLastSyncAt(cursor: Long) { + syncMetadataDao.upsert( + SyncMetadataEntity(id = 0, cursor = cursor, lastSyncAt = Clock.System.now()), + ) + } + + private suspend fun wipeAndRetry() { + // The Library DAOs only expose deleteByIds; for the rare 410 + // path we re-issue the same request with cursor=0 after + // resetting our cursor. The next response carries the full + // entity set, which upsertAll overwrites the existing rows + // with — old rows for entities the server no longer knows + // about will linger until a later 410 or until the user clears + // app data. Acceptable for v1; a future "wipe all" DAO method + // tightens this up. + syncMetadataDao.upsert( + SyncMetadataEntity(id = 0, cursor = 0, lastSyncAt = null), + ) + sync() + } + + private companion object { + const val HTTP_OK = 200 + const val HTTP_NO_CONTENT = 204 + const val HTTP_GONE = 410 + } +} + +// ── Mappers (file-internal — sync wire types stay out of UI) ── + +private fun SyncArtistWire.toEntity(): CachedArtistEntity = CachedArtistEntity( + id = id, + name = name, + sortName = sortName, + mbid = mbid, + artistThumbPath = artistThumbPath, + artistFanartPath = artistFanartPath, +) + +private fun SyncAlbumWire.toEntity(): CachedAlbumEntity = CachedAlbumEntity( + id = id, + artistId = artistId, + title = title, + sortTitle = sortTitle, + releaseDate = releaseDate, + coverPath = coverArtPath, + mbid = mbid, +) + +private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity( + id = id, + albumId = albumId, + artistId = artistId, + title = title, + durationMs = durationMs, + trackNumber = trackNumber, + discNumber = discNumber, + filePath = filePath, + fileFormat = fileFormat, + genre = genre, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ConnectivityObserver.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ConnectivityObserver.kt new file mode 100644 index 00000000..49470361 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ConnectivityObserver.kt @@ -0,0 +1,70 @@ +package com.fabledsword.minstrel.connectivity + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Single source of truth for the device's "is the internet usable + * right now" signal — wraps [ConnectivityManager] and exposes a hot + * cold-startable Flow that emits `false` while the active network + * lacks INTERNET + VALIDATED capabilities (airplane mode, no carrier, + * captive portal, etc.) and `true` once a usable network appears. + * + * Used by the shell-level ConnectionErrorBanner; downstream + * repositories can also collect this to gate retry loops. + */ +@Singleton +class ConnectivityObserver @Inject constructor( + @ApplicationContext context: Context, +) { + private val cm = context.getSystemService(ConnectivityManager::class.java) + + val online: Flow = callbackFlow { + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(hasUsableInternet()) + } + override fun onLost(network: Network) { + trySend(hasUsableInternet()) + } + override fun onCapabilitiesChanged( + network: Network, + capabilities: NetworkCapabilities, + ) { + trySend( + capabilities.hasCapability( + NetworkCapabilities.NET_CAPABILITY_INTERNET, + ) && + capabilities.hasCapability( + NetworkCapabilities.NET_CAPABILITY_VALIDATED, + ), + ) + } + } + cm.registerNetworkCallback(request, callback) + // Seed the initial value so the banner doesn't flash before the + // first capability callback fires. + trySend(hasUsableInternet()) + awaitClose { cm.unregisterNetworkCallback(callback) } + }.distinctUntilChanged() + + private fun hasUsableInternet(): Boolean { + val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) } + return caps != null && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt new file mode 100644 index 00000000..3dbb33c4 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt @@ -0,0 +1,90 @@ +package com.fabledsword.minstrel.connectivity.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.composables.icons.lucide.CloudOff +import com.composables.icons.lucide.Lucide +import com.fabledsword.minstrel.connectivity.ConnectivityObserver +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +private const val ONLINE_SHARE_STOP_TIMEOUT_MS = 5_000L + +/** + * Tiny VM that just lifts the [ConnectivityObserver] singleton's + * Flow into a StateFlow with the standard sharing strategy. Keeps + * the banner composable pure-presentation. + */ +@HiltViewModel +class ConnectivityBannerViewModel @Inject constructor( + observer: ConnectivityObserver, + @Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle, +) : ViewModel() { + val online: StateFlow = observer.online.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(ONLINE_SHARE_STOP_TIMEOUT_MS), + initialValue = true, + ) +} + +/** + * Banner shown at the top of the shell when the device has no usable + * internet. Mirrors Flutter's ConnectionErrorBanner: red-tinted error + * surface, CloudOff icon, "No connection — check Wi-Fi or mobile + * data" copy. Auto-hides via slide+fade when connectivity returns. + */ +@Composable +fun ConnectionErrorBanner( + viewModel: ConnectivityBannerViewModel = hiltViewModel(), +) { + val online by viewModel.online.collectAsStateWithLifecycle() + AnimatedVisibility( + visible = !online, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.errorContainer) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Lucide.CloudOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = "No connection — check Wi-Fi or mobile data.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/di/AppModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/di/AppModule.kt new file mode 100644 index 00000000..1add2f76 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/di/AppModule.kt @@ -0,0 +1,33 @@ +package com.fabledsword.minstrel.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.serialization.json.Json +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ApplicationScope + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + @Provides + @Singleton + fun provideJson(): Json = Json { + ignoreUnknownKeys = true + explicitNulls = false + coerceInputValues = true + } + + @Provides + @Singleton + @ApplicationScope + fun provideAppScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/discover/data/DiscoverRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/discover/data/DiscoverRepository.kt new file mode 100644 index 00000000..2d561fd4 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/discover/data/DiscoverRepository.kt @@ -0,0 +1,123 @@ +package com.fabledsword.minstrel.discover.data + +import com.fabledsword.minstrel.api.endpoints.DiscoverApi +import com.fabledsword.minstrel.cache.mutations.MutationQueue +import com.fabledsword.minstrel.cache.mutations.RequestCreatePayload +import com.fabledsword.minstrel.models.ArtistSuggestionRef +import com.fabledsword.minstrel.models.LidarrRequestKind +import com.fabledsword.minstrel.models.LidarrSearchResultRef +import com.fabledsword.minstrel.models.SeedContributionRef +import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire +import com.fabledsword.minstrel.models.wire.CreateRequestBody +import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire +import com.fabledsword.minstrel.models.wire.SeedContributionWire +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Outcome of a `createRequest` call — discriminates "server accepted" + * from "network down, queued for later replay". The UI uses this to + * pick between "Requested" and "Queued" snackbars. + */ +enum class RequestOutcome { ACCEPTED, QUEUED } + +/** + * Read-through Discover surface: Lidarr search, suggestion feed, and + * the request-creation write path. Mirrors the Flutter + * `DiscoverApi` + `mutationQueueProvider` integration — + * `feedback_offline_first_for_server_writes` says writes never go + * fire-and-forget, so `createRequest` falls back to the + * MutationQueue on transport failure. + * + * Reads are not cached locally yet — discovery results are inherently + * transient (60s server-side LRU); the cache wins are minor and + * unblock no UX. Phase 13's offline pass adds an offline scrollback + * for the suggestion feed only. + */ +@Singleton +class DiscoverRepository @Inject constructor( + private val mutationQueue: MutationQueue, + retrofit: Retrofit, +) { + private val api: DiscoverApi = retrofit.create() + + suspend fun listSuggestions(): List = + api.listSuggestions().map { it.toDomain() } + + suspend fun search(query: String, kind: LidarrRequestKind): List = + api.search(query = query, kind = kind.wire).map { it.toDomain() } + + /** + * Fires `POST /api/requests`; on transport failure enqueues a + * `RequestCreatePayload` for the Phase-12.2 replayer to drain. + * Returns [RequestOutcome.ACCEPTED] when the server responded + * 2xx, [RequestOutcome.QUEUED] when the call was buffered. + */ + suspend fun createRequest( + kind: LidarrRequestKind, + artistMbid: String, + artistName: String, + albumMbid: String? = null, + albumTitle: String? = null, + trackMbid: String? = null, + trackTitle: String? = null, + ): RequestOutcome { + val payload = RequestCreatePayload( + kind = kind.wire, + artistMbid = artistMbid, + artistName = artistName, + albumMbid = albumMbid?.ifEmpty { null }, + albumTitle = albumTitle?.ifEmpty { null }, + trackMbid = trackMbid?.ifEmpty { null }, + trackTitle = trackTitle?.ifEmpty { null }, + ) + return try { + api.createRequest(payload.toBody()) + RequestOutcome.ACCEPTED + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + // Intentional swallow — see LikesRepository.toggleLike for + // the same offline-first rationale. + mutationQueue.enqueueRequestCreate(payload) + RequestOutcome.QUEUED + } + } +} + +// ── Mappers (internal — wire types stay out of UI) ── + +private fun LidarrSearchResultWire.toDomain(): LidarrSearchResultRef = LidarrSearchResultRef( + mbid = mbid, + name = name, + secondaryText = secondaryText, + imageUrl = imageUrl, + artistMbid = artistMbid, + albumMbid = albumMbid, + inLibrary = inLibrary, + requested = requested, +) + +private fun ArtistSuggestionWire.toDomain(): ArtistSuggestionRef = ArtistSuggestionRef( + mbid = mbid, + name = name, + imageUrl = imageUrl, + attribution = attribution.map { it.toDomain() }, +) + +private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContributionRef( + name = name, + isLiked = isLiked, +) + +private fun RequestCreatePayload.toBody(): CreateRequestBody = CreateRequestBody( + kind = kind, + artistMbid = artistMbid, + artistName = artistName, + albumMbid = albumMbid, + albumTitle = albumTitle, + trackMbid = trackMbid, + trackTitle = trackTitle, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt new file mode 100644 index 00000000..ce0d7648 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt @@ -0,0 +1,264 @@ +package com.fabledsword.minstrel.discover.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavHostController +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Search as LucideSearch +import com.fabledsword.minstrel.discover.data.RequestOutcome +import com.fabledsword.minstrel.models.ArtistSuggestionRef +import com.fabledsword.minstrel.models.LidarrRequestKind +import com.fabledsword.minstrel.models.LidarrSearchResultRef +import com.fabledsword.minstrel.nav.Discover +import com.fabledsword.minstrel.shared.widgets.LoadingCentered +import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold +import kotlinx.coroutines.launch + +// ─── Screen ────────────────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DiscoverScreen( + navController: NavHostController, + viewModel: DiscoverViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + MinstrelTopAppBar( + title = "Discover", + navController = navController, + currentRouteName = Discover::class.qualifiedName, + onBack = { navController.popBackStack() }, + ) + }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { inner -> + DiscoverBody( + inner = inner, + state = state, + viewModel = viewModel, + snackbar = snackbar, + scope = scope, + ) + } +} + +@Composable +private fun DiscoverBody( + inner: androidx.compose.foundation.layout.PaddingValues, + state: DiscoverState, + viewModel: DiscoverViewModel, + snackbar: SnackbarHostState, + scope: kotlinx.coroutines.CoroutineScope, +) { + Column(modifier = Modifier.fillMaxSize().padding(inner)) { + SearchBar( + query = state.query, + onQueryChange = viewModel::setQuery, + onSubmit = viewModel::runSearch, + ) + KindChips(kind = state.kind, onChange = viewModel::setKind) + PullToRefreshScaffold( + onRefresh = { viewModel.loadSuggestions().join() }, + modifier = Modifier.fillMaxSize(), + ) { + when (val r = state.results) { + ResultsState.Idle -> SuggestionsPane( + state = state.suggestions, + locallyRequestedMbids = state.locallyRequestedMbids, + onRequest = { s -> + scope.launch { + val outcome = viewModel.requestSuggestion(s) + snackbar.showSnackbar(snackbarFor(outcome, s.name)) + } + }, + ) + ResultsState.Loading -> LoadingCentered() + is ResultsState.Loaded -> ResultsList( + rows = r.items, + locallyRequestedMbids = state.locallyRequestedMbids, + onRequest = { row -> + scope.launch { + val outcome = viewModel.requestRow(row) + snackbar.showSnackbar(snackbarFor(outcome, row.name)) + } + }, + ) + is ResultsState.Error -> CenteredMessage("Search failed: ${r.message}") + } + } + } +} + +@Composable +private fun SearchBar( + query: String, + onQueryChange: (String) -> Unit, + onSubmit: () -> Unit, +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + singleLine = true, + placeholder = { Text("Search Lidarr for new music") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSubmit() }), + trailingIcon = { + IconButton(onClick = onSubmit) { + Icon(Lucide.LucideSearch, contentDescription = "Search") + } + }, + ) +} + +@Composable +private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = kind == LidarrRequestKind.ARTIST, + onClick = { onChange(LidarrRequestKind.ARTIST) }, + label = { Text("Artists") }, + ) + FilterChip( + selected = kind == LidarrRequestKind.ALBUM, + onClick = { onChange(LidarrRequestKind.ALBUM) }, + label = { Text("Albums") }, + ) + } +} + +@Composable +private fun SuggestionsPane( + state: SuggestionState, + locallyRequestedMbids: Set, + onRequest: (ArtistSuggestionRef) -> Unit, +) { + when (state) { + SuggestionState.Loading -> LoadingCentered() + is SuggestionState.Error -> CenteredMessage("Couldn't load suggestions.") + is SuggestionState.Loaded -> SuggestionsList( + items = state.items.filter { it.mbid !in locallyRequestedMbids }, + onRequest = onRequest, + ) + } +} + +@Composable +private fun SuggestionsList( + items: List, + onRequest: (ArtistSuggestionRef) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 16.dp), + ) { + item { SuggestionsHeader() } + if (items.isEmpty()) { + item { CenteredMessage("Listen to or like an artist to fill this in.") } + } else { + items(items = items, key = { it.mbid }) { s -> + SuggestionTile(s = s, onRequest = { onRequest(s) }) + HorizontalDivider() + } + } + } +} + +@Composable +private fun SuggestionsHeader() { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Text( + text = "Suggested for you", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = "Out-of-library artists drawn from what you've liked and played.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun ResultsList( + rows: List, + locallyRequestedMbids: Set, + onRequest: (LidarrSearchResultRef) -> Unit, +) { + if (rows.isEmpty()) { + CenteredMessage("Nothing to add for that search yet.") + return + } + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(items = rows, key = { it.mbid }) { row -> + ResultTile( + row = row, + locallyRequested = row.mbid in locallyRequestedMbids, + onRequest = { onRequest(row) }, + ) + HorizontalDivider() + } + } +} + +@Composable +private fun CenteredMessage(text: String) { + Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private fun snackbarFor(outcome: RequestOutcome, name: String): String = when (outcome) { + RequestOutcome.ACCEPTED -> "Requested: $name" + RequestOutcome.QUEUED -> "Request queued: $name" +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverTiles.kt b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverTiles.kt new file mode 100644 index 00000000..613c7bae --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverTiles.kt @@ -0,0 +1,131 @@ +package com.fabledsword.minstrel.discover.ui + +import androidx.compose.foundation.background +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +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.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.composables.icons.lucide.Disc3 +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.User +import com.fabledsword.minstrel.models.ArtistSuggestionRef +import com.fabledsword.minstrel.models.LidarrSearchResultRef + +@Composable +internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Avatar(imageUrl = s.imageUrl, fallback = Lucide.User, circular = true) + Column(modifier = Modifier.weight(1f)) { + Text( + text = s.name, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (s.attributionText.isNotEmpty()) { + Text( + text = s.attributionText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + Button(onClick = onRequest) { Text("Request") } + } +} + +@Composable +internal fun ResultTile( + row: LidarrSearchResultRef, + locallyRequested: Boolean, + onRequest: () -> Unit, +) { + val effectivelyRequested = row.requested || locallyRequested + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Avatar(imageUrl = row.imageUrl, fallback = Lucide.Disc3, circular = false) + Column(modifier = Modifier.weight(1f)) { + Text( + text = row.name, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (row.secondaryText.isNotEmpty()) { + Text( + text = row.secondaryText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + when { + row.inLibrary -> AssistChip(onClick = {}, enabled = false, label = { Text("In library") }) + effectivelyRequested -> + AssistChip(onClick = {}, enabled = false, label = { Text("Requested") }) + else -> Button(onClick = onRequest) { Text("Request") } + } + } +} + +@Composable +private fun Avatar(imageUrl: String, fallback: ImageVector, circular: Boolean) { + val clip = if (circular) CircleShape else RoundedCornerShape(6.dp) + Box( + modifier = Modifier + .size(56.dp) + .clip(clip) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + if (imageUrl.isEmpty()) { + Icon( + imageVector = fallback, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverViewModel.kt new file mode 100644 index 00000000..3ec241db --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverViewModel.kt @@ -0,0 +1,141 @@ +package com.fabledsword.minstrel.discover.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.discover.data.DiscoverRepository +import com.fabledsword.minstrel.discover.data.RequestOutcome +import com.fabledsword.minstrel.models.ArtistSuggestionRef +import com.fabledsword.minstrel.models.LidarrRequestKind +import com.fabledsword.minstrel.models.LidarrSearchResultRef +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +// ─── State ─────────────────────────────────────────────────────────── + +data class DiscoverState( + val query: String = "", + val kind: LidarrRequestKind = LidarrRequestKind.ARTIST, + val suggestions: SuggestionState = SuggestionState.Loading, + val results: ResultsState = ResultsState.Idle, + val locallyRequestedMbids: Set = emptySet(), +) + +sealed interface SuggestionState { + data object Loading : SuggestionState + data class Loaded(val items: List) : SuggestionState + data class Error(val message: String) : SuggestionState +} + +sealed interface ResultsState { + /** No active search — the screen shows the suggestion feed. */ + data object Idle : ResultsState + data object Loading : ResultsState + data class Loaded(val items: List) : ResultsState + data class Error(val message: String) : ResultsState +} + +// ─── ViewModel ─────────────────────────────────────────────────────── + +@HiltViewModel +class DiscoverViewModel @Inject constructor( + private val repository: DiscoverRepository, +) : ViewModel() { + + private val internal = MutableStateFlow(DiscoverState()) + val state: StateFlow = internal.asStateFlow() + + init { + loadSuggestions() + } + + fun setQuery(value: String) { + internal.update { + // Clearing the box returns to the suggestion feed. + val newResults = if (value.isBlank()) ResultsState.Idle else it.results + it.copy(query = value, results = newResults) + } + } + + fun setKind(kind: LidarrRequestKind) { + internal.update { it.copy(kind = kind) } + if (internal.value.query.isNotBlank()) runSearch() + } + + fun loadSuggestions(): Job = viewModelScope.launch { + internal.update { it.copy(suggestions = SuggestionState.Loading) } + try { + val items = repository.listSuggestions() + internal.update { it.copy(suggestions = SuggestionState.Loaded(items)) } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.update { + it.copy( + suggestions = SuggestionState.Error( + ErrorCopy.fromThrowable(e), + ), + ) + } + } + } + + fun runSearch() { + val q = internal.value.query.trim() + if (q.isEmpty()) { + internal.update { it.copy(results = ResultsState.Idle) } + return + } + viewModelScope.launch { + internal.update { it.copy(results = ResultsState.Loading) } + try { + val rows = repository.search(q, internal.value.kind) + internal.update { it.copy(results = ResultsState.Loaded(rows)) } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.update { + it.copy(results = ResultsState.Error(ErrorCopy.fromThrowable(e))) + } + } + } + } + + /** + * Request a search-result row. Returns the outcome (ACCEPTED vs + * QUEUED) so the screen can pick the snackbar wording and update + * the local "requested" overlay. + */ + suspend fun requestRow(row: LidarrSearchResultRef): RequestOutcome { + val kind = internal.value.kind + val outcome = repository.createRequest( + kind = kind, + artistMbid = if (kind == LidarrRequestKind.ARTIST) row.mbid else row.artistMbid, + artistName = if (kind == LidarrRequestKind.ARTIST) row.name else row.secondaryText, + albumMbid = if (kind == LidarrRequestKind.ALBUM) row.mbid else null, + albumTitle = if (kind == LidarrRequestKind.ALBUM) row.name else null, + ) + markRequested(row.mbid) + return outcome + } + + suspend fun requestSuggestion(s: ArtistSuggestionRef): RequestOutcome { + val outcome = repository.createRequest( + kind = LidarrRequestKind.ARTIST, + artistMbid = s.mbid, + artistName = s.name, + ) + markRequested(s.mbid) + return outcome + } + + private fun markRequested(mbid: String) { + internal.update { it.copy(locallyRequestedMbids = it.locallyRequestedMbids + mbid) } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/events/EventsStream.kt b/android/app/src/main/java/com/fabledsword/minstrel/events/EventsStream.kt new file mode 100644 index 00000000..b375756a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/events/EventsStream.kt @@ -0,0 +1,190 @@ +package com.fabledsword.minstrel.events + +import com.fabledsword.minstrel.auth.AuthStore +import com.fabledsword.minstrel.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.sse.EventSource +import okhttp3.sse.EventSourceListener +import okhttp3.sse.EventSources +import javax.inject.Inject +import javax.inject.Singleton + +private const val SSE_PATH = "/api/events/stream" +private const val EVENTS_BUFFER_CAPACITY = 64 +private const val BASE_BACKOFF_MS = 1_000L +private const val MAX_BACKOFF_MS = 30_000L +private const val BACKOFF_FACTOR = 2 + +/** + * Long-lived SSE subscription to `GET /api/events/stream`. Exposes + * [events] as a process-wide [SharedFlow]; consumers (per-screen + * ViewModels + the central [LiveEventsDispatcher]) collect filtered + * subsets of the stream. + * + * Connection lifecycle mirrors + * `flutter_client/lib/shared/live_events_provider.dart`: + * - Gated on having a session cookie. Subscription opens when the + * cookie transitions to non-null and closes when it transitions + * back to null (sign-out). + * - No client-side timeout — the server emits 15s heartbeats which + * okhttp-sse handles transparently. + * - Reconnect-with-backoff: if the stream drops mid-session (server + * restart, network blip) it reconnects with exponential backoff + * (1s → 2s → … → 30s cap), reset to 1s on a successful open. Only + * reconnects while still signed in; a sign-out cancels the pending + * retry. Without this a single blip silently kills cross-device + * reactivity until the next app launch. + * + * The URL passes through the placeholder host that + * `BaseUrlInterceptor` rewrites — same mechanism the rest of the + * Retrofit traffic uses, so server-URL changes propagate without + * needing a separate path through here. + */ +@Singleton +class EventsStream @Inject constructor( + private val authStore: AuthStore, + @ApplicationScope private val scope: CoroutineScope, + private val okHttpClient: OkHttpClient, + private val json: Json, +) { + private val factory = EventSources.createFactory(okHttpClient) + + private val emitter = MutableSharedFlow( + replay = 0, + extraBufferCapacity = EVENTS_BUFFER_CAPACITY, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val events: SharedFlow = emitter.asSharedFlow() + + private var currentSource: EventSource? = null + @Volatile private var signedIn = false + private var reconnectJob: Job? = null + private var backoffMs = BASE_BACKOFF_MS + + init { + scope.launch { + authStore.sessionCookie + .map { !it.isNullOrEmpty() } + .distinctUntilChanged() + .collect { isSignedIn -> + signedIn = isSignedIn + if (isSignedIn) { + backoffMs = BASE_BACKOFF_MS + connect() + } else { + disconnect() + } + } + } + } + + @Synchronized + private fun connect() { + reconnectJob?.cancel() + currentSource?.cancel() + currentSource = openSubscription() + } + + @Synchronized + private fun disconnect() { + reconnectJob?.cancel() + currentSource?.cancel() + currentSource = null + } + + /** + * Schedule a reconnect after the current backoff, then double it + * (capped). No-op when signed out — sign-out's [disconnect] + * cancels the pending job. A successful [Listener.onOpen] resets + * the backoff to the floor. + */ + @Synchronized + private fun scheduleReconnect() { + if (!signedIn) return + reconnectJob?.cancel() + val waitMs = backoffMs + backoffMs = (backoffMs * BACKOFF_FACTOR).coerceAtMost(MAX_BACKOFF_MS) + reconnectJob = scope.launch { + delay(waitMs) + if (signedIn) connect() + } + } + + private fun openSubscription(): EventSource { + val request = Request.Builder() + .url("http://placeholder.invalid$SSE_PATH") + .header("Accept", "text/event-stream") + .build() + return factory.newEventSource(request, Listener()) + } + + private inner class Listener : EventSourceListener() { + override fun onOpen(eventSource: EventSource, response: Response) { + backoffMs = BASE_BACKOFF_MS + } + + override fun onEvent( + eventSource: EventSource, + id: String?, + type: String?, + data: String, + ) { + parseFrame(kind = type, data = data)?.let(emitter::tryEmit) + } + + override fun onClosed(eventSource: EventSource) { + scheduleReconnect() + } + + override fun onFailure( + eventSource: EventSource, + t: Throwable?, + response: Response?, + ) { + scheduleReconnect() + } + } + + /** + * Parses one SSE frame into a [LiveEvent]. The SSE `event:` field + * carries the kind; the `data:` field is a JSON object with + * `kind`/`user_id`/`data` (the kind in the payload is redundant + * with the SSE event field — we prefer the SSE field, falling + * back to the payload field). Heartbeat comment frames don't + * reach here (okhttp-sse filters them). + */ + private fun parseFrame(kind: String?, data: String): LiveEvent? = try { + val payload = json.parseToJsonElement(data).jsonObject + val resolvedKind = kind ?: payload["kind"]?.jsonPrimitive?.contentOrNull + if (resolvedKind.isNullOrEmpty()) { + null + } else { + LiveEvent( + kind = resolvedKind, + userId = payload["user_id"]?.jsonPrimitive?.contentOrNull.orEmpty(), + data = (payload["data"] as? JsonObject) ?: JsonObject(emptyMap()), + ) + } + } catch ( + @Suppress("TooGenericExceptionCaught") _: Throwable, + ) { + null + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEvent.kt b/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEvent.kt new file mode 100644 index 00000000..c296b25f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEvent.kt @@ -0,0 +1,21 @@ +package com.fabledsword.minstrel.events + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +/** + * Parsed event from the server's SSE stream. Mirrors + * `flutter_client/lib/shared/live_events_provider.dart`'s `LiveEvent`. + * + * - [kind] is the SSE `event:` field (e.g. "track.liked", "playlist.deleted"). + * - [userId] is the actor whose user-scoped state changed (empty for + * broadcast events like scan.* that aren't user-bound). + * - [data] is the raw event payload; consumers deserialize into the + * typed shape they expect for the kind they handle. + */ +@Serializable +data class LiveEvent( + val kind: String, + val userId: String, + val data: JsonObject, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEventsDispatcher.kt b/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEventsDispatcher.kt new file mode 100644 index 00000000..9913508d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEventsDispatcher.kt @@ -0,0 +1,72 @@ +package com.fabledsword.minstrel.events + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.likes.data.LikesRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Maps incoming [LiveEvent]s to cross-screen state refreshes. Mirrors + * `flutter_client/lib/shared/live_events_dispatcher.dart`. Activated + * by force-@Inject in MinstrelApplication. + * + * Scope is deliberately narrow: this dispatcher only touches state + * that's reachable from /shared (i.e. that has a Repository-level + * refresh method). Screen-scoped state (PlaylistDetail for a + * specific id, a single Request's status_changed) is opted into by + * the screen's ViewModel subscribing to [EventsStream.events] + * directly. + * + * Includes a [ProcessLifecycleOwner] foreground hook that + * defensively re-runs the same refreshes — SSE catches up on its + * own, but a long-backgrounded app can hit `onStart` before the + * stream reconnects and the re-refresh avoids stale data flashing. + */ +@Singleton +class LiveEventsDispatcher @Inject constructor( + private val eventsStream: EventsStream, + private val likes: LikesRepository, + @ApplicationScope private val scope: CoroutineScope, +) : DefaultLifecycleObserver { + + init { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + scope.launch { + eventsStream.events.collect { e -> handle(e) } + } + } + + private fun handle(event: LiveEvent) { + when (event.kind) { + "track.liked", + "track.unliked", + "album.liked", + "album.unliked", + "artist.liked", + "artist.unliked", + -> refreshLikes() + } + // Other kinds (playlist.*, quarantine.*, request.status_changed, + // scan.*) reach screen-scoped subscribers via EventsStream + // directly. Add cross-screen cases here only when a new + // Repository-level refresh becomes available. + } + + override fun onStart(owner: LifecycleOwner) { + // App returned to the foreground. SSE will catch up but might + // not have reconnected yet; flush the cross-screen refreshes + // defensively. + refreshLikes() + } + + private fun refreshLikes() { + scope.launch { + runCatching { likes.refreshIds() } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/history/data/HistoryRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/history/data/HistoryRepository.kt new file mode 100644 index 00000000..3414993d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/history/data/HistoryRepository.kt @@ -0,0 +1,79 @@ +package com.fabledsword.minstrel.history.data + +import com.fabledsword.minstrel.api.endpoints.HistoryApi +import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao +import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity +import com.fabledsword.minstrel.library.data.toDomain +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.models.wire.HistoryPageWire +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * One row of listening history mapped into a UI-friendly shape. The + * server's HistoryEventWire pairs `played_at` (RFC3339) with the + * full TrackWire — we keep both, mapping the track through the + * existing wire→TrackRef converter so playback wiring stays + * consistent with the Library reads. + */ +data class HistoryEntry( + val id: String, + val playedAt: String, + val track: TrackRef, +) + +data class HistoryPage( + val entries: List, + val hasMore: Boolean, +) + +/** + * Cache-first listening history. Mirrors Flutter's `_historyProvider` + * (`cacheFirst` over the `cached_history_snapshot` drift row with + * `alwaysRefresh`) using the native Room + Flow idiom: + * + * - [observeHistory] emits the cached snapshot instantly (null until + * the first fetch lands), so the History tab paints from disk on + * cold open with no blocking spinner. + * - [refresh] pulls `/api/me/history`, stores the raw wire JSON as + * the single snapshot row, and the Flow re-emits — SWR. Errors + * propagate to the caller (the VM surfaces them only when there's + * no cache to show). + * + * The whole page is stored as one JSON blob (single-row table) rather + * than per-row Room entities — history is a read-only timestamp-keyed + * snapshot, so a blob is the simplest faithful mirror of the Drift + * snapshot and avoids ordering/paging bookkeeping for no UX gain. + */ +@Singleton +class HistoryRepository @Inject constructor( + private val snapshotDao: CachedHistorySnapshotDao, + private val json: Json, + retrofit: Retrofit, +) { + private val api: HistoryApi = retrofit.create() + + fun observeHistory(): Flow = + snapshotDao.observe().map { row -> + row?.let { json.decodeFromString(it.json).toDomain() } + } + + /** Fetch the latest history and overwrite the cached snapshot. */ + suspend fun refresh() { + val wire = api.getHistory() + val blob = json.encodeToString(HistoryPageWire.serializer(), wire) + snapshotDao.upsert(CachedHistorySnapshotEntity(json = blob)) + } +} + +private fun HistoryPageWire.toDomain(): HistoryPage = HistoryPage( + entries = events.map { + HistoryEntry(id = it.id, playedAt = it.playedAt, track = it.track.toDomain()) + }, + hasMore = hasMore, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt new file mode 100644 index 00000000..08fc9a8d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt @@ -0,0 +1,229 @@ +package com.fabledsword.minstrel.history.ui + +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.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.history.data.HistoryEntry +import com.fabledsword.minstrel.history.data.HistoryRepository +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.shared.UiState +import com.fabledsword.minstrel.shared.widgets.TrackRow +import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.LoadingCentered +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold +import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb +import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle +import java.time.temporal.ChronoUnit +import java.util.Locale +import javax.inject.Inject + +// ─── State ─────────────────────────────────────────────────────────── + +private const val SHARE_STOP_TIMEOUT_MS = 5_000L + +// ─── ViewModel ─────────────────────────────────────────────────────── + +@HiltViewModel +class HistoryTabViewModel @Inject constructor( + private val repository: HistoryRepository, + private val player: PlayerController, +) : ViewModel() { + + private val refreshError = MutableStateFlow(null) + + /** + * Cache-first: emits the cached snapshot instantly (paints from + * disk on cold open), then SWR-refreshes underneath. Shows Error + * only when there's nothing cached AND the refresh failed — a + * failed refresh over a populated cache stays silent. + */ + val uiState: StateFlow>> = + combine(repository.observeHistory(), refreshError) { page, err -> + when { + page != null && page.entries.isNotEmpty() -> UiState.Success(page.entries) + page != null -> UiState.Empty + err != null -> UiState.Error(err) + else -> UiState.Loading + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = UiState.Loading, + ) + + init { + refresh() + } + + fun refresh(): Job = viewModelScope.launch { + refreshError.value = null + runCatching { repository.refresh() } + .onFailure { refreshError.value = ErrorCopy.fromThrowable(it) } + } + + /** Single-track play, matching the Flutter history-row behavior. */ + fun playTrack(track: TrackRef) { + if (track.streamUrl.isEmpty()) return + player.setQueue(listOf(track), initialIndex = 0, source = "history") + } +} + +// ─── Composable ────────────────────────────────────────────────────── + +@Composable +fun HistoryTab( + onNavigateToAlbum: (String) -> Unit = {}, + onNavigateToArtist: (String) -> Unit = {}, + viewModel: HistoryTabViewModel = hiltViewModel(), + playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val playerState by playerViewModel.uiState.collectAsStateWithLifecycle() + val playingTrackId = playerState.currentTrack?.id + PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) { + when (val s = state) { + UiState.Loading -> LoadingCentered() + UiState.Empty -> EmptyState( + title = "No listening history yet", + body = "Play something — your recent plays will show up here.", + ) + is UiState.Error -> EmptyState( + title = "Couldn't load history", + body = s.message, + ) + is UiState.Success -> HistoryList( + entries = s.data, + playingTrackId = playingTrackId, + onPlay = viewModel::playTrack, + onNavigateToAlbum = onNavigateToAlbum, + onNavigateToArtist = onNavigateToArtist, + ) + } + } +} + +@Composable +private fun HistoryList( + entries: List, + playingTrackId: String?, + onPlay: (TrackRef) -> Unit, + onNavigateToAlbum: (String) -> Unit, + onNavigateToArtist: (String) -> Unit, +) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(items = entries, key = { it.id }) { entry -> + HistoryRow( + entry = entry, + nowPlaying = entry.track.id == playingTrackId, + onClick = { onPlay(entry.track) }, + onNavigateToAlbum = onNavigateToAlbum, + onNavigateToArtist = onNavigateToArtist, + ) + HorizontalDivider() + } + } +} + +@Composable +private fun HistoryRow( + entry: HistoryEntry, + nowPlaying: Boolean, + onClick: () -> Unit, + onNavigateToAlbum: (String) -> Unit, + onNavigateToArtist: (String) -> Unit, +) { + TrackRow( + title = entry.track.title, + artist = entry.track.artistName.ifEmpty { entry.track.albumTitle }, + trackId = entry.track.id, + onClick = onClick, + nowPlaying = nowPlaying, + enabled = entry.track.streamUrl.isNotEmpty(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + leading = { + TrackCoverThumb(coverUrl = entry.track.coverUrl, contentDescription = entry.track.title) + }, + trailing = { + Text( + text = relativeTime(entry.playedAt), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TrackActionsButton( + track = entry.track, + onNavigateToAlbum = onNavigateToAlbum, + onNavigateToArtist = onNavigateToArtist, + ) + }, + ) +} + +// ─── Time helpers ──────────────────────────────────────────────────── + +private const val MINUTES_PER_HOUR = 60L +private const val HOURS_PER_DAY = 24L +private const val DAYS_PER_WEEK = 7L + +/** + * Lightweight relative-time formatter mirroring Flutter's + * `library_screen.dart`'s `_relativeTime`: + * + * < 1h → "Nm ago" + * < 24h → "Nh ago" + * < 7d → "Tue 14:32" + * ≥ 7d → "May 1" (or "May 1, 2025" if different year) + * + * Falls back to the raw ISO string on parse failure so we never blow + * up the row over malformed timestamps. + */ +private fun relativeTime(iso: String): String { + val parsed = runCatching { OffsetDateTime.parse(iso) }.getOrNull() + ?: return iso + val local = parsed.atZoneSameInstant(ZoneId.systemDefault()) + val now = OffsetDateTime.now(ZoneId.systemDefault()) + val minutes = ChronoUnit.MINUTES.between(local, now) + val hours = ChronoUnit.HOURS.between(local, now) + val days = ChronoUnit.DAYS.between(local, now) + return when { + minutes < MINUTES_PER_HOUR -> "${minutes.coerceAtLeast(1)}m ago" + hours < HOURS_PER_DAY -> "${hours}h ago" + days < DAYS_PER_WEEK -> { + val dow = local.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()) + val time = local.toLocalTime().format(DateTimeFormatter.ofPattern("HH:mm")) + "$dow $time" + } + else -> { + val pattern = if (local.year == now.year) "MMM d" else "MMM d, yyyy" + local.toLocalDate().format(DateTimeFormatter.ofPattern(pattern)) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/data/HomeRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/data/HomeRepository.kt new file mode 100644 index 00000000..0b24e86f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/data/HomeRepository.kt @@ -0,0 +1,152 @@ +package com.fabledsword.minstrel.home.data + +import com.fabledsword.minstrel.api.endpoints.HomeApi +import com.fabledsword.minstrel.api.endpoints.MeApi +import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao +import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity +import com.fabledsword.minstrel.metadata.HomeArtistPrewarmer +import com.fabledsword.minstrel.metadata.MetadataProvider +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.models.ArtistRef +import com.fabledsword.minstrel.models.HomeTile +import com.fabledsword.minstrel.models.SystemPlaylistsStatus +import com.fabledsword.minstrel.models.TrackRef +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Cache-first reads for the Home screen. Per-section ID lists live in + * `cached_home_index`; entity rows live in `cached_albums` / + * `cached_artists` / `cached_tracks`. + * + * Each section is a `Flow>>`: the index Flow drives + * the ordered id list, and each id maps to a per-entity + * [MetadataProvider] Flow that emits the cached row (or null while it + * hydrates) and fires a dedup'd, concurrency-gated on-miss fetch. A + * null [HomeTile.value] renders as a skeleton in position; the tile + * reveals when the fetch lands and Room re-emits. Mirrors Flutter's + * per-item tile providers. + * + * `refreshIndex()` pulls `GET /api/home/index`, replaces each section + * in-place (delete-then-insert, so the section Flows re-fire), and + * pre-warms the top artists via [HomeArtistPrewarmer]. + */ +@Singleton +class HomeRepository @Inject constructor( + private val homeIndexDao: CachedHomeIndexDao, + private val metadataProvider: MetadataProvider, + private val prewarmer: HomeArtistPrewarmer, + retrofit: Retrofit, +) { + private val api: HomeApi = retrofit.create() + private val meApi: MeApi = retrofit.create() + + /** + * Caller's system-playlist build state for the Home placeholder + * cards. One-shot GET; the caller refreshes alongside the index. + */ + suspend fun getSystemPlaylistsStatus(): SystemPlaylistsStatus = + meApi.getSystemPlaylistsStatus().let { + SystemPlaylistsStatus( + inFlight = it.inFlight, + lastRunAt = it.lastRunAt, + lastError = it.lastError, + ) + } + + fun observeRecentlyAddedAlbums(): Flow>> = + observeAlbumSection(SECTION_RECENTLY_ADDED_ALBUMS) + + fun observeRediscoverAlbums(): Flow>> = + observeAlbumSection(SECTION_REDISCOVER_ALBUMS) + + fun observeRediscoverArtists(): Flow>> = + observeArtistSection(SECTION_REDISCOVER_ARTISTS) + + fun observeMostPlayedTracks(): Flow>> = + observeTrackSection(SECTION_MOST_PLAYED_TRACKS) + + fun observeLastPlayedArtists(): Flow>> = + observeArtistSection(SECTION_LAST_PLAYED_ARTISTS) + + /** + * Pulls /api/home/index, replaces each cached_home_index section, + * and pre-warms the top artists. The section Flows re-fire on the + * index change; missing entity rows hydrate via the on-miss path. + */ + suspend fun refreshIndex() { + val wire = api.getHomeIndex() + replaceSection(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums) + replaceSection(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums) + replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists) + replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks) + replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists) + prewarmer.warm(wire.rediscoverArtists + wire.lastPlayedArtists) + } + + private suspend fun replaceSection(section: String, entityType: String, ids: List) { + homeIndexDao.deleteBySection(section) + if (ids.isEmpty()) return + homeIndexDao.upsertAll( + ids.mapIndexed { index, id -> + CachedHomeIndexEntity( + section = section, + position = index, + entityType = entityType, + entityId = id, + ) + }, + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun observeAlbumSection(section: String): Flow>> = + homeIndexDao.observeBySection(section).flatMapLatest { rows -> + if (rows.isEmpty()) { + flowOf(emptyList()) + } else { + combine(rows.map { metadataProvider.observeAlbum(it.entityId) }) { refs -> + rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) } + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun observeArtistSection(section: String): Flow>> = + homeIndexDao.observeBySection(section).flatMapLatest { rows -> + if (rows.isEmpty()) { + flowOf(emptyList()) + } else { + combine(rows.map { metadataProvider.observeArtist(it.entityId) }) { refs -> + rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) } + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun observeTrackSection(section: String): Flow>> = + homeIndexDao.observeBySection(section).flatMapLatest { rows -> + if (rows.isEmpty()) { + flowOf(emptyList()) + } else { + combine(rows.map { metadataProvider.observeTrack(it.entityId) }) { refs -> + rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) } + } + } + } + + companion object { + const val SECTION_RECENTLY_ADDED_ALBUMS = "recently_added_albums" + const val SECTION_REDISCOVER_ALBUMS = "rediscover_albums" + const val SECTION_REDISCOVER_ARTISTS = "rediscover_artists" + const val SECTION_MOST_PLAYED_TRACKS = "most_played_tracks" + const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists" + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt new file mode 100644 index 00000000..711bf89d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt @@ -0,0 +1,704 @@ +@file:Suppress("TooManyFunctions") // Compose screen + section helper composables + skeletons + +package com.fabledsword.minstrel.home.ui + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import coil3.compose.AsyncImage +import com.composables.icons.lucide.Heart +import com.composables.icons.lucide.History +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Music +import com.fabledsword.minstrel.home.data.HomeRepository +import com.fabledsword.minstrel.library.widgets.AlbumCard +import com.fabledsword.minstrel.library.widgets.ArtistCard +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.models.ArtistRef +import com.fabledsword.minstrel.models.HomeTile +import com.fabledsword.minstrel.models.PlaylistRef +import com.fabledsword.minstrel.models.SystemPlaylistsStatus +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.nav.AlbumDetail +import com.fabledsword.minstrel.nav.ArtistDetail +import com.fabledsword.minstrel.nav.Home +import com.fabledsword.minstrel.nav.PlaylistDetail +import com.fabledsword.minstrel.playlists.data.PlaylistsRepository +import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard +import com.fabledsword.minstrel.playlists.widgets.PlaylistCard +import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard +import com.fabledsword.minstrel.shared.UiState +import com.fabledsword.minstrel.shared.asCacheFirstStateFlow +import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow +import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold +import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile +import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile +import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val SHARE_STOP_TIMEOUT_MS = 5_000L +private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140 +private const val RECENTLY_ADDED_CHUNK = 25 + +// ─── State ─────────────────────────────────────────────────────────── + +data class HomeSections( + val playlists: List = emptyList(), + val recentlyAddedAlbums: List> = emptyList(), + val rediscoverAlbums: List> = emptyList(), + val rediscoverArtists: List> = emptyList(), + val mostPlayedTracks: List> = emptyList(), + val lastPlayedArtists: List> = emptyList(), +) { + val isAllEmpty: Boolean + get() = playlists.isEmpty() && + recentlyAddedAlbums.isEmpty() && + rediscoverAlbums.isEmpty() && + rediscoverArtists.isEmpty() && + mostPlayedTracks.isEmpty() && + lastPlayedArtists.isEmpty() +} + +// ─── ViewModel ─────────────────────────────────────────────────────── + +@HiltViewModel +class HomeViewModel @Inject constructor( + private val homeRepository: HomeRepository, + private val playlistsRepository: PlaylistsRepository, + private val player: com.fabledsword.minstrel.player.PlayerController, + private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource, + connectivity: com.fabledsword.minstrel.connectivity.ConnectivityObserver, +) : ViewModel() { + + private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus()) + + /** System-playlist build status for the Home placeholder cards. */ + val systemStatus: StateFlow = systemStatusInternal.asStateFlow() + + /** True when the device has no usable internet — gates the offline-pool cards. */ + val offline: StateFlow = connectivity.online + .map { !it } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = false, + ) + + private val poolMessages = Channel(Channel.BUFFERED) + + /** Transient snackbar messages from offline-pool taps. */ + val transientMessages: Flow = poolMessages.receiveAsFlow() + + init { + refresh() + } + + /** + * Tap an offline pool: shuffle + play its cached tracks. Empty + * pool surfaces a snackbar rather than starting silent playback. + */ + fun playPool(kind: OfflinePoolKind) { + viewModelScope.launch { + val tracks = when (kind) { + OfflinePoolKind.RECENTLY_PLAYED -> shuffleSource.recentlyPlayed() + OfflinePoolKind.LIKED -> shuffleSource.liked() + }.shuffled() + if (tracks.isEmpty()) { + poolMessages.trySend("No cached ${kind.label} tracks yet") + } else { + player.setQueue(tracks, initialIndex = 0, source = "offline:${kind.name}") + } + } + } + + /** + * Tap on a Most-played tile: replace the queue with the section's + * tracks and start at the tapped index. Mirrors Flutter's + * `_resolveSectionTracks` — Home's section is itself the playable + * unit, not a list of album-detail links. + */ + fun playMostPlayed(tracks: List, startIndex: Int) { + if (tracks.isEmpty()) return + player.setQueue( + tracks = tracks, + initialIndex = startIndex.coerceIn(0, tracks.lastIndex), + source = "home:most_played", + ) + } + + /** + * Pulls both /home/index and the playlists list. Returns the Job + * for the combined refresh so a pull-to-refresh wrapper can await + * actual completion before hiding the indicator. + */ + fun refresh(): Job = viewModelScope.launch { + val home = launch { runCatching { homeRepository.refreshIndex() } } + val lists = launch { runCatching { playlistsRepository.refreshList() } } + val status = launch { + runCatching { homeRepository.getSystemPlaylistsStatus() } + .onSuccess { systemStatusInternal.value = it } + } + home.join() + lists.join() + status.join() + } + + val uiState: StateFlow> = + combineHomeFlows().asCacheFirstStateFlow(viewModelScope) + + /** + * Five home-section flows merged into the sections struct without + * playlists; then merged with the playlists flow in [combineHomeFlows]. + * Splitting it like this keeps each combine call inside the + * coroutines built-in arity limit (5) and avoids the typed-vararg + * acrobatics needed for a 6-flow combine across heterogeneous types. + */ + private fun observeHomeSections() = combine( + homeRepository.observeRecentlyAddedAlbums(), + homeRepository.observeRediscoverAlbums(), + homeRepository.observeRediscoverArtists(), + homeRepository.observeMostPlayedTracks(), + homeRepository.observeLastPlayedArtists(), + ) { recent, rediscoverAlbums, rediscoverArtists, mostPlayed, lastPlayed -> + HomeSections( + recentlyAddedAlbums = recent, + rediscoverAlbums = rediscoverAlbums, + rediscoverArtists = rediscoverArtists, + mostPlayedTracks = mostPlayed, + lastPlayedArtists = lastPlayed, + ) + } + + private fun combineHomeFlows() = + observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists -> + val merged = sections.copy(playlists = playlists) + if (merged.isAllEmpty) UiState.Empty else UiState.Success(merged) + } +} + +// ─── Screen ────────────────────────────────────────────────────────── + +@Composable +fun HomeScreen( + navController: NavHostController, + viewModel: HomeViewModel = hiltViewModel(), +) { + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(Unit) { + viewModel.transientMessages.collect { snackbarHostState.showSnackbar(it) } + } + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + MinstrelTopAppBar( + title = "Minstrel", + navController = navController, + currentRouteName = Home::class.qualifiedName, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { inner -> + val state by viewModel.uiState.collectAsStateWithLifecycle() + val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle() + val offline by viewModel.offline.collectAsStateWithLifecycle() + PullToRefreshScaffold( + onRefresh = { viewModel.refresh().join() }, + modifier = Modifier.fillMaxSize().padding(inner), + ) { + Crossfade(targetState = state, label = "home-state") { s -> + when (s) { + UiState.Loading -> HomeSkeletonContent() + UiState.Empty -> EmptyState( + title = "Welcome to Minstrel", + body = "Nothing to show yet — scan a folder in your server " + + "settings, then come back here for system playlists " + + "and recommendations.", + ) + is UiState.Error -> EmptyState( + title = "Couldn't load home", + body = s.message, + ) + is UiState.Success -> HomeSuccessContent( + sections = s.data, + systemStatus = systemStatus, + offline = offline, + onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) }, + onArtistClick = { id -> navController.navigate(ArtistDetail(id)) }, + onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) }, + onMostPlayedTap = viewModel::playMostPlayed, + onPlayPool = viewModel::playPool, + ) + } + } + } + } +} + +/** + * Skeleton stand-in shown during cold-load. Mirrors the success + * layout's section structure (Playlists carousel → Recently added + * carousel → Rediscover albums carousel → Last played artists + * carousel) so the Crossfade reveal doesn't reflow. + */ +@Composable +private fun HomeSkeletonContent() { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + item { SkeletonSectionHeader() } + item { SkeletonAlbumRow() } + item { SkeletonSectionHeader() } + item { SkeletonAlbumRow() } + item { SkeletonSectionHeader() } + item { SkeletonArtistRow() } + item { Spacer(Modifier.height(BOTTOM_PADDING_FOR_MINIPLAYER_DP.dp)) } + } +} + +private const val SKELETON_TILES_PER_ROW = 5 + +@Composable +private fun SkeletonAlbumRow() { + LazyRow( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 8.dp), + ) { + items(SKELETON_TILES_PER_ROW) { SkeletonAlbumTile() } + } +} + +@Composable +private fun SkeletonArtistRow() { + LazyRow( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 8.dp), + ) { + items(SKELETON_TILES_PER_ROW) { SkeletonArtistTile() } + } +} + +@Composable +private fun HomeSuccessContent( + sections: HomeSections, + systemStatus: SystemPlaylistsStatus, + offline: Boolean, + onAlbumClick: (String) -> Unit, + onArtistClick: (String) -> Unit, + onPlaylistClick: (String) -> Unit, + onMostPlayedTap: (List, Int) -> Unit, + onPlayPool: (OfflinePoolKind) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + item { + // Always rendered: real system/user playlists, with + // placeholder cards filling the For You / Discover / + // 3× Songs-like slots that haven't generated yet. When + // offline, the cache-backed pool cards lead the row. + PlaylistsRow( + rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline), + onPlaylistClick = onPlaylistClick, + onPlayPool = onPlayPool, + ) + } + recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick) + rediscoverSection( + albums = sections.rediscoverAlbums, + artists = sections.rediscoverArtists, + onAlbumClick = onAlbumClick, + onArtistClick = onArtistClick, + ) + mostPlayedSection(sections.mostPlayedTracks, onMostPlayedTap) + lastPlayedSection(sections.lastPlayedArtists, onArtistClick) + item { Spacer(Modifier.height(BOTTOM_PADDING_FOR_MINIPLAYER_DP.dp)) } + } +} + +private fun LazyListScope.recentlyAddedSection( + albums: List>, + onAlbumClick: (String) -> Unit, +) { + if (albums.isEmpty()) { + item { + EmptySection( + title = "Recently added", + body = "Albums you add to the library will show up here first.", + ) + } + return + } + // Chunk into stacked carousels of 25 so a large library reads as + // multiple rows instead of one unwieldy LazyRow. Only the first + // chunk carries the section title; the rest are continuation rows. + val chunks = albums.chunked(RECENTLY_ADDED_CHUNK) + itemsIndexed( + items = chunks, + key = { index, chunk -> "recent-$index-${chunk.first().id}" }, + ) { index, chunk -> + AlbumsRow( + title = if (index == 0) "Recently added" else "", + albums = chunk, + onAlbumClick = onAlbumClick, + ) + } +} + +private fun LazyListScope.rediscoverSection( + albums: List>, + artists: List>, + onAlbumClick: (String) -> Unit, + onArtistClick: (String) -> Unit, +) { + item { + if (albums.isNotEmpty() || artists.isNotEmpty()) { + RediscoverBlock(albums, artists, onAlbumClick, onArtistClick) + } else { + EmptySection( + title = "Rediscover", + body = "No forgotten favourites yet. Like albums or artists to fill this in.", + ) + } + } +} + +private fun LazyListScope.mostPlayedSection( + tracks: List>, + onMostPlayedTap: (List, Int) -> Unit, +) { + item { + if (tracks.isNotEmpty()) { + MostPlayedRow(tracks, onMostPlayedTap) + } else { + EmptySection( + title = "Most played", + body = "Play some music — your most-listened tracks will appear here.", + ) + } + } +} + +private fun LazyListScope.lastPlayedSection( + artists: List>, + onArtistClick: (String) -> Unit, +) { + item { + if (artists.isNotEmpty()) { + ArtistsRow("Last played", artists, onArtistClick) + } else { + EmptySection( + title = "Last played", + body = "Recently played artists will show up here.", + ) + } + } +} + +@Composable +private fun EmptySection(title: String, body: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = body, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * Rediscover may have both an albums sub-row and an artists sub-row. When + * both are present the artist row gets an empty title so the section + * reads as a single visual block — matches the Flutter behavior. + */ +@Composable +private fun RediscoverBlock( + albums: List>, + artists: List>, + onAlbumClick: (String) -> Unit, + onArtistClick: (String) -> Unit, +) { + Column { + if (albums.isNotEmpty()) { + AlbumsRow("Rediscover", albums, onAlbumClick) + } + if (artists.isNotEmpty()) { + val title = if (albums.isEmpty()) "Rediscover" else "" + ArtistsRow(title, artists, onArtistClick) + } + } +} + +@Composable +private fun AlbumsRow( + title: String, + albums: List>, + onAlbumClick: (String) -> Unit, +) { + HorizontalScrollRow(title = title) { + items(items = albums, key = { it.id }) { tile -> + val album = tile.value + if (album == null) { + SkeletonAlbumTile() + } else { + AlbumCard(album = album, onClick = { onAlbumClick(album.id) }) + } + } + } +} + +@Composable +private fun PlaylistsRow( + rowItems: List, + onPlaylistClick: (String) -> Unit, + onPlayPool: (OfflinePoolKind) -> Unit, +) { + HorizontalScrollRow(title = "Playlists") { + itemsIndexed(items = rowItems) { _, item -> + when (item) { + is PlaylistRowItem.OfflinePool -> OfflinePoolCard( + label = item.kind.label, + icon = iconForPool(item.kind), + onClick = { onPlayPool(item.kind) }, + ) + is PlaylistRowItem.Real -> PlaylistCard( + playlist = item.playlist, + onClick = { onPlaylistClick(item.playlist.id) }, + ) + is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard( + label = item.label, + variant = item.variant, + ) + } + } + } +} + +private fun iconForPool(kind: OfflinePoolKind) = when (kind) { + OfflinePoolKind.RECENTLY_PLAYED -> Lucide.History + OfflinePoolKind.LIKED -> Lucide.Heart +} + +/** A cache-backed offline pool, a real playlist tile, or a not-yet-generated slot. */ +private sealed interface PlaylistRowItem { + data class OfflinePool(val kind: OfflinePoolKind) : PlaylistRowItem + data class Real(val playlist: PlaylistRef) : PlaylistRowItem + data class Placeholder(val label: String, val variant: String) : PlaylistRowItem +} + +/** The two offline cache pools surfaced on Home when offline. */ +enum class OfflinePoolKind(val label: String) { + RECENTLY_PLAYED("Recently played"), + LIKED("Liked"), +} + +/** + * Builds the Home Playlists row. When [offline], the two cache-backed + * pool cards (Recently played, Liked) lead the row — mirrors Flutter's + * `_buildPlaylistsRow`. Then For You + Discover + 3× Songs-like fixed + * slots (real card when generated, placeholder otherwise), then any + * user-owned playlists. + */ +private fun buildPlaylistsRow( + owned: List, + status: SystemPlaylistsStatus, + offline: Boolean, +): List { + val out = mutableListOf() + if (offline) { + out += PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED) + out += PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED) + } + out += owned.firstOrNull { it.systemVariant == "for_you" } + ?.let { PlaylistRowItem.Real(it) } + ?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status)) + out += owned.firstOrNull { it.systemVariant == "discover" } + ?.let { PlaylistRowItem.Real(it) } + ?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status)) + val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(3) + for (i in 0 until SONGS_LIKE_SLOTS) { + out += songsLike.getOrNull(i) + ?.let { PlaylistRowItem.Real(it) } + ?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status)) + } + owned.filter { it.systemVariant == null }.forEach { out += PlaylistRowItem.Real(it) } + return out +} + +private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when { + s.inFlight -> "building" + s.lastError != null -> "failed" + slot == "songs-like" && s.lastRunAt != null -> "seed-needed" + else -> "pending" +} + +private const val SONGS_LIKE_SLOTS = 3 + +@Composable +private fun ArtistsRow( + title: String, + artists: List>, + onArtistClick: (String) -> Unit, +) { + HorizontalScrollRow(title = title) { + items(items = artists, key = { it.id }) { tile -> + val artist = tile.value + if (artist == null) { + SkeletonArtistTile() + } else { + ArtistCard(artist = artist, onClick = { onArtistClick(artist.id) }) + } + } + } +} + +/** + * Most-played section. Renders compact track tiles (cover + title + artist). + * Tapping a tile replaces the queue with the entire section starting + * at that tile — mirrors Flutter's `_resolveSectionTracks` behaviour + * where Home sections are themselves playable units. + */ +@Composable +private fun MostPlayedRow( + tracks: List>, + onTap: (List, Int) -> Unit, +) { + HorizontalScrollRow(title = "Most played") { + items(items = tracks, key = { it.id }) { tile -> + val track = tile.value + if (track == null) { + SkeletonCompactTrackTile() + } else { + CompactTrackTile( + track = track, + onClick = { + val playable = tracks.mapNotNull { it.value } + onTap(playable, playable.indexOfFirst { it.id == track.id }) + }, + ) + } + } + } +} + +@Composable +private fun SkeletonCompactTrackTile() { + Column(modifier = Modifier.width(140.dp)) { + SkeletonAlbumTile() + } +} + +@Composable +private fun CompactTrackTile( + track: TrackRef, + onClick: () -> Unit, +) { + Column( + modifier = Modifier + .width(140.dp) + .clickable(onClick = onClick), + ) { + Box( + modifier = Modifier + .size(140.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + if (track.coverUrl.isEmpty()) { + Icon( + imageVector = Lucide.Music, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(56.dp), + ) + } else { + AsyncImage( + model = track.coverUrl, + contentDescription = track.title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + } + Spacer(Modifier.height(8.dp)) + Text( + text = track.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = track.artistName.ifEmpty { " " }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryMappers.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryMappers.kt new file mode 100644 index 00000000..a6cf3982 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryMappers.kt @@ -0,0 +1,140 @@ +package com.fabledsword.minstrel.library.data + +import com.fabledsword.minstrel.cache.db.entities.CachedAlbumEntity +import com.fabledsword.minstrel.cache.db.entities.CachedArtistEntity +import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.models.ArtistRef +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.models.wire.AlbumDetailWire +import com.fabledsword.minstrel.models.wire.AlbumWire +import com.fabledsword.minstrel.models.wire.ArtistDetailWire +import com.fabledsword.minstrel.models.wire.ArtistWire +import com.fabledsword.minstrel.models.wire.TrackWire +import com.fabledsword.minstrel.shared.millisToSeconds +import com.fabledsword.minstrel.shared.secondsToMillis + +// Wire to Entity — used after a successful sync/fetch to persist into Room. +// Entity fields are a subset of wire fields (display names + sort keys); +// fields the entity doesn't store are simply dropped. + +fun ArtistWire.toEntity(): CachedArtistEntity = + CachedArtistEntity( + id = id, + name = name, + sortName = sortName, + ) + +fun AlbumWire.toEntity(): CachedAlbumEntity = + CachedAlbumEntity( + id = id, + artistId = artistId, + title = title, + sortTitle = sortTitle, + ) + +fun TrackWire.toEntity(): CachedTrackEntity = + CachedTrackEntity( + id = id, + albumId = albumId, + artistId = artistId, + title = title, + durationMs = durationSec.secondsToMillis(), + trackNumber = trackNumber, + discNumber = discNumber, + ) + +// Detail-wire to Entity — drops the embedded array; the embedded items +// are persisted separately via the regular wire mappers in the repository. + +fun ArtistDetailWire.toArtistEntity(): CachedArtistEntity = + CachedArtistEntity( + id = id, + name = name, + sortName = sortName, + ) + +fun AlbumDetailWire.toAlbumEntity(): CachedAlbumEntity = + CachedAlbumEntity( + id = id, + artistId = artistId, + title = title, + sortTitle = sortTitle, + ) + +// Entity to Domain — what ViewModels actually consume. The cache is the +// source of truth for cache-first reads; mapping to a domain type here +// keeps Room types out of the UI layer. + +fun CachedArtistEntity.toDomain(coverAlbumId: String = ""): ArtistRef = + ArtistRef( + id = id, + name = name, + sortName = sortName, + coverAlbumId = coverAlbumId, + ) + +fun CachedAlbumEntity.toDomain(artistName: String = ""): AlbumRef = + AlbumRef( + id = id, + title = title, + artistId = artistId, + sortTitle = sortTitle, + artistName = artistName, + ) + +fun CachedTrackEntity.toDomain( + albumTitle: String = "", + artistName: String = "", +): TrackRef = + TrackRef( + id = id, + title = title, + albumId = albumId, + artistId = artistId, + albumTitle = albumTitle, + artistName = artistName, + trackNumber = trackNumber, + discNumber = discNumber, + durationSec = durationMs.millisToSeconds(), + ) + +// Wire to Domain — for fresh server responses that bypass the cache +// (e.g. a not-yet-cached search hit). Same field set as Entity to +// Domain plus the wire-only fields (albumTitle, artistName, streamUrl). + +fun TrackWire.toDomain(): TrackRef = + TrackRef( + id = id, + title = title, + albumId = albumId, + artistId = artistId, + albumTitle = albumTitle, + artistName = artistName, + trackNumber = trackNumber, + discNumber = discNumber, + durationSec = durationSec, + streamUrl = streamUrl, + ) + +fun ArtistWire.toDomain(): ArtistRef = + ArtistRef( + id = id, + name = name, + sortName = sortName, + albumCount = albumCount, + coverUrl = coverUrl, + ) + +fun AlbumWire.toDomain(): AlbumRef = + AlbumRef( + id = id, + title = title, + artistId = artistId, + sortTitle = sortTitle, + artistName = artistName, + year = year, + trackCount = trackCount, + durationSec = durationSec, + coverUrl = coverUrl, + ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt new file mode 100644 index 00000000..de6f2c48 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt @@ -0,0 +1,151 @@ +package com.fabledsword.minstrel.library.data + +import com.fabledsword.minstrel.api.endpoints.LibraryApi +import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao +import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao +import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao +import com.fabledsword.minstrel.models.AlbumDetailRef +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.models.ArtistDetailRef +import com.fabledsword.minstrel.models.ArtistRef +import com.fabledsword.minstrel.models.TrackRef +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Cache-first reads of artists/albums/tracks. The Room DAOs are the + * source of truth ViewModels observe; refresh* methods pull from the + * server and upsert into Room — the Flow emissions then propagate + * automatically. + * + * Constructs its own LibraryApi from the shared Retrofit. Per the + * "repositories own their interfaces" pattern adopted in Phase 5.1's + * NetworkModule, we don't @Provides per-endpoint Retrofit interfaces + * — fewer Hilt bindings, locality of reference. + */ +// LibraryRepository owns reads + refreshes for three entity families +// (artists / albums / tracks) plus shuffle. Function count scales with +// entity-family count; splitting into per-family repos would +// scatter the shared dao + retrofit plumbing for no real gain. +@Suppress("TooManyFunctions") +@Singleton +class LibraryRepository @Inject constructor( + private val artistDao: CachedArtistDao, + private val albumDao: CachedAlbumDao, + private val trackDao: CachedTrackDao, + retrofit: Retrofit, +) { + private val api: LibraryApi = retrofit.create() + + // ---- Reads (Flow, cache-first; the cache is the source of truth) ---- + + // Joins each artist to its first cached album (by sort title) so the + // tile cover derives from /api/albums/{id}/cover — cached_artists + // stores no cover and sync carries none. Mirrors Flutter's + // artistTileProvider JOIN. + fun observeArtists(): Flow> = + combine(artistDao.observeAll(), albumDao.observeAll()) { artists, albums -> + val firstAlbumByArtist = albums + .groupBy { it.artistId } + .mapValues { (_, list) -> list.minByOrNull { it.sortTitle }?.id ?: "" } + artists.map { it.toDomain(coverAlbumId = firstAlbumByArtist[it.id] ?: "") } + } + + fun observeAlbums(): Flow> = + albumDao.observeAll().map { rows -> rows.map { it.toDomain() } } + + fun observeAlbumsForArtist(artistId: String): Flow> = + albumDao.observeByArtist(artistId).map { rows -> rows.map { it.toDomain() } } + + fun observeTracksForAlbum(albumId: String): Flow> = + trackDao.observeByAlbum(albumId).map { rows -> rows.map { it.toDomain() } } + + // ---- Single-shot lookups ---- + + suspend fun getArtist(id: String): ArtistRef? = artistDao.getById(id)?.toDomain() + + suspend fun getAlbum(id: String): AlbumRef? = albumDao.getById(id)?.toDomain() + + suspend fun getTrack(id: String): TrackRef? = trackDao.getById(id)?.toDomain() + + // ---- Server refreshes (sync-style writes; errors propagate) ---- + + /** + * Pulls the full artist detail (ArtistRef + albums), persists both, + * and returns the in-memory snapshot. Lets the ArtistDetail screen + * render from the wire response without the Room round-trip. + */ + suspend fun refreshArtistDetail(id: String): ArtistDetailRef { + val wire = api.getArtistDetail(id) + artistDao.upsertAll(listOf(wire.toArtistEntity())) + albumDao.upsertAll(wire.albums.map { it.toEntity() }) + return ArtistDetailRef( + artist = ArtistRef( + id = wire.id, + name = wire.name, + sortName = wire.sortName, + albumCount = wire.albumCount, + coverUrl = wire.coverUrl, + ), + albums = wire.albums.map { it.toDomain() }, + ) + } + + /** + * Pulls every track for an artist (across all their albums). Used + * by the "Play artist" button on the ArtistDetail header. No + * persistence — the response is consumed straight into a player + * queue. + */ + suspend fun fetchArtistTracks(id: String): List = + api.getArtistTracks(id).map { it.toDomain() } + + /** + * Pulls the album detail (AlbumRef + tracks), persists both, and + * returns the in-memory snapshot. Lets the AlbumDetail screen + * render immediately from the wire response without waiting for + * the Room round-trip. + */ + suspend fun refreshAlbumDetail(id: String): AlbumDetailRef { + val wire = api.getAlbumDetail(id) + albumDao.upsertAll(listOf(wire.toAlbumEntity())) + trackDao.upsertAll(wire.tracks.map { it.toEntity() }) + return AlbumDetailRef( + album = AlbumRef( + id = wire.id, + title = wire.title, + artistId = wire.artistId, + sortTitle = wire.sortTitle, + artistName = wire.artistName, + year = wire.year, + trackCount = wire.trackCount, + durationSec = wire.durationSec, + coverUrl = wire.coverUrl, + ), + tracks = wire.tracks.map { it.toDomain() }, + ) + } + + /** Track detail — single track, mainly for the hydration queue. */ + suspend fun refreshTrack(id: String) { + val wire = api.getTrack(id) + trackDao.upsertAll(listOf(wire.toEntity())) + } + + /** + * "Shuffle all" — server picks random tracks across the entire + * library. Backs the Library AppBar Shuffle button. No cache hit; + * the user-facing semantics are "give me something fresh". + */ + suspend fun shuffleLibrary(limit: Int = SHUFFLE_DEFAULT_LIMIT): List = + api.shuffleLibrary(limit = limit).map { it.toDomain() } + + private companion object { + const val SHUFFLE_DEFAULT_LIMIT = 100 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt new file mode 100644 index 00000000..2d07aa7b --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt @@ -0,0 +1,390 @@ +@file:Suppress("TooManyFunctions") // Compose screen + private helper composables + +package com.fabledsword.minstrel.library.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.animation.Crossfade +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.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.composables.icons.lucide.ArrowLeft +import com.composables.icons.lucide.Disc3 +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Play +import com.composables.icons.lucide.Shuffle +import com.fabledsword.minstrel.models.AlbumDetailRef +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.nav.AlbumDetail +import com.fabledsword.minstrel.nav.ArtistDetail +import com.fabledsword.minstrel.shared.formatDuration +import com.fabledsword.minstrel.shared.widgets.TrackRow +import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.LikeButton +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold +import com.fabledsword.minstrel.shared.widgets.ServerImage +import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow +import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton +import com.fabledsword.minstrel.theme.FabledSwordFlatTokens + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AlbumDetailScreen( + navController: NavHostController, + viewModel: AlbumDetailViewModel = hiltViewModel(), + playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(titleFor(state)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Lucide.ArrowLeft, contentDescription = "Back") + } + }, + ) + }, + ) { inner -> + PullToRefreshScaffold( + onRefresh = { viewModel.refresh().join() }, + modifier = Modifier.fillMaxSize().padding(inner), + ) { + AlbumDetailStateContent( + state = state, + viewModel = viewModel, + playerViewModel = playerViewModel, + navController = navController, + ) + } + } +} + +private fun titleFor(state: AlbumDetailUiState): String = when (state) { + is AlbumDetailUiState.Success -> state.detail.album.title + is AlbumDetailUiState.Loading -> state.seed?.title ?: "Album" + is AlbumDetailUiState.Error -> "Album" +} + +@Composable +private fun AlbumDetailStateContent( + state: AlbumDetailUiState, + viewModel: AlbumDetailViewModel, + playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel, + navController: NavHostController, +) { + Crossfade(targetState = state, label = "album-detail") { s -> + when (s) { + is AlbumDetailUiState.Loading -> + if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList() + is AlbumDetailUiState.Error -> EmptyState( + title = "Couldn't load album", + body = s.message, + ) + is AlbumDetailUiState.Success -> { + val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle() + val likedTrackIds by viewModel.likedTrackIds + .collectAsStateWithLifecycle() + val playerState by playerViewModel.uiState + .collectAsStateWithLifecycle() + AlbumBody( + detail = s.detail, + albumLiked = albumLiked, + likedTrackIds = likedTrackIds, + playingTrackId = playerState.currentTrack?.id, + onPlayAll = { viewModel.play(startTrackId = null) }, + onShuffleAll = viewModel::shuffle, + onTrackClick = { id -> viewModel.play(startTrackId = id) }, + onToggleAlbumLike = viewModel::toggleLikeAlbum, + onToggleTrackLike = viewModel::toggleLikeTrack, + onNavigateToAlbum = { id -> + navController.navigate(AlbumDetail(id)) + }, + onNavigateToArtist = { id -> + navController.navigate(ArtistDetail(id)) + }, + ) + } + } + } +} + +@Composable +private fun AlbumBody( + detail: AlbumDetailRef, + albumLiked: Boolean, + likedTrackIds: Set, + playingTrackId: String?, + onPlayAll: () -> Unit, + onShuffleAll: () -> Unit, + onTrackClick: (String) -> Unit, + onToggleAlbumLike: () -> Unit, + onToggleTrackLike: (String) -> Unit, + onNavigateToAlbum: (String) -> Unit, + onNavigateToArtist: (String) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 140.dp), + ) { + item { + AlbumHeader( + album = detail.album, + albumLiked = albumLiked, + onPlayAll = onPlayAll, + onShuffleAll = onShuffleAll, + onToggleAlbumLike = onToggleAlbumLike, + ) + } + item { HorizontalDivider() } + items(items = detail.tracks, key = { it.id }) { track -> + TrackRow( + track = track, + liked = track.id in likedTrackIds, + nowPlaying = track.id == playingTrackId, + onClick = { onTrackClick(track.id) }, + onToggleLike = { onToggleTrackLike(track.id) }, + onNavigateToAlbum = onNavigateToAlbum, + onNavigateToArtist = onNavigateToArtist, + ) + HorizontalDivider() + } + if (detail.tracks.isEmpty()) { + item { + Text( + text = "No tracks on this album.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(24.dp), + ) + } + } + } +} + +@Composable +private fun AlbumHeader( + album: AlbumRef, + albumLiked: Boolean, + onPlayAll: () -> Unit, + onShuffleAll: () -> Unit, + onToggleAlbumLike: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AlbumCover(album) + Column(modifier = Modifier.weight(1f)) { + Text( + text = album.title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (album.artistName.isNotEmpty()) { + Text( + text = album.artistName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = trackCountLine(album), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + LikeButton(liked = albumLiked, onToggle = onToggleAlbumLike) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = onPlayAll, + colors = ButtonDefaults.buttonColors(), + ) { + Icon(Lucide.Play, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Play") + } + OutlinedButton(onClick = onShuffleAll) { + Icon(Lucide.Shuffle, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Shuffle") + } + } + } +} + +@Composable +private fun AlbumCover(album: AlbumRef) { + Box( + modifier = Modifier + .size(120.dp) + .clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + if (album.id.isEmpty()) { + Icon( + imageVector = Lucide.Disc3, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(56.dp), + ) + } else { + ServerImage( + url = album.displayCoverUrl, + contentDescription = album.title, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +private fun trackCountLine(album: AlbumRef): String { + val tracks = if (album.trackCount == 1) "1 track" else "${album.trackCount} tracks" + return if (album.year != null) "$tracks · ${album.year}" else tracks +} + +@Composable +private fun TrackRow( + track: TrackRef, + liked: Boolean, + nowPlaying: Boolean, + onClick: () -> Unit, + onToggleLike: () -> Unit, + onNavigateToAlbum: (String) -> Unit, + onNavigateToArtist: (String) -> Unit, +) { + TrackRow( + title = track.title, + artist = track.artistName, + trackId = track.id, + onClick = onClick, + nowPlaying = nowPlaying, + leading = { + Text( + text = "${track.trackNumber ?: 0}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(28.dp), + ) + }, + trailing = { + Text( + text = formatDuration(track.durationSec, zero = "--:--"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 12.dp), + ) + LikeButton(liked = liked, onToggle = onToggleLike) + TrackActionsButton( + track = track, + onNavigateToAlbum = onNavigateToAlbum, + onNavigateToArtist = onNavigateToArtist, + ) + }, + ) +} + +private const val SKELETON_TRACK_ROWS = 8 + +@Composable +private fun SkeletonTrackList() { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(SKELETON_TRACK_ROWS) { SkeletonTrackRow() } + } +} + +/** + * Loading body that renders the album header from the navigation seed + * (cover + title + artist + count) so the screen reads as itself + * immediately, with skeleton track rows below until the fetch lands. + * Action buttons are omitted — they need the real track list. + */ +@Composable +private fun SeededAlbumLoading(seed: AlbumRef) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AlbumCover(seed) + Column(modifier = Modifier.weight(1f)) { + Text( + text = seed.title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (seed.artistName.isNotEmpty()) { + Text( + text = seed.artistName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = trackCountLine(seed), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + item { HorizontalDivider() } + items(SKELETON_TRACK_ROWS) { SkeletonTrackRow() } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt new file mode 100644 index 00000000..52926a69 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt @@ -0,0 +1,132 @@ +package com.fabledsword.minstrel.library.ui + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.library.data.LibraryRepository +import com.fabledsword.minstrel.likes.data.LikesRepository +import com.fabledsword.minstrel.models.AlbumDetailRef +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.nav.AlbumDetail +import com.fabledsword.minstrel.nav.DetailSeedCache +import com.fabledsword.minstrel.player.PlayerController +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val SHARE_STOP_TIMEOUT_MS = 5_000L + +sealed interface AlbumDetailUiState { + data class Loading(val seed: AlbumRef? = null) : AlbumDetailUiState + data class Success(val detail: AlbumDetailRef) : AlbumDetailUiState + data class Error(val message: String) : AlbumDetailUiState +} + +@HiltViewModel +class AlbumDetailViewModel @Inject constructor( + private val repository: LibraryRepository, + private val likes: LikesRepository, + private val player: PlayerController, + private val seedCache: DetailSeedCache, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val route: AlbumDetail = savedStateHandle.toRoute() + val albumId: String = route.id + + private val internal = + MutableStateFlow(AlbumDetailUiState.Loading()) + val uiState: StateFlow = internal.asStateFlow() + + val albumLiked: StateFlow = + likes.observeIsLiked(LikesRepository.ENTITY_ALBUM, albumId) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = false, + ) + + val likedTrackIds: StateFlow> = + likes.observeLikedTracks() + .map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = emptySet(), + ) + + init { + refresh() + } + + fun refresh(): Job = viewModelScope.launch { + val carried = when (val cur = internal.value) { + is AlbumDetailUiState.Loading -> cur.seed + is AlbumDetailUiState.Success -> cur.detail.album + is AlbumDetailUiState.Error -> null + } + internal.value = AlbumDetailUiState.Loading(carried ?: seedCache.peekAlbum(albumId)) + try { + val detail = repository.refreshAlbumDetail(albumId) + internal.value = AlbumDetailUiState.Success(detail) + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.value = AlbumDetailUiState.Error(ErrorCopy.fromThrowable(e)) + } + } + + /** Play the album starting at [startTrackId] (or the first track when null). */ + fun play(startTrackId: String?) { + val detail = (internal.value as? AlbumDetailUiState.Success)?.detail ?: return + if (detail.tracks.isEmpty()) return + val startIndex = detail.tracks + .indexOfFirst { it.id == startTrackId } + .coerceAtLeast(0) + player.setQueue( + tracks = detail.tracks, + initialIndex = startIndex, + source = "album:${detail.album.id}", + ) + } + + /** + * Play the album in shuffled order. Pre-shuffles the list and hands + * the result to the player — matches the PlaylistDetail shuffle + * pattern. A future refinement could use Media3's + * `setShuffleModeEnabled` for play-then-shuffle without disturbing + * the original queue ordering. + */ + fun shuffle() { + val detail = (internal.value as? AlbumDetailUiState.Success)?.detail ?: return + if (detail.tracks.isEmpty()) return + player.setQueue( + tracks = detail.tracks.shuffled(), + initialIndex = 0, + source = "album:${detail.album.id}", + ) + } + + fun toggleLikeAlbum() { + val desired = !albumLiked.value + viewModelScope.launch { + likes.toggleLike(LikesRepository.ENTITY_ALBUM, albumId, desired) + } + } + + fun toggleLikeTrack(trackId: String) { + val desired = trackId !in likedTrackIds.value + viewModelScope.launch { + likes.toggleLike(LikesRepository.ENTITY_TRACK, trackId, desired) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt new file mode 100644 index 00000000..01fa2b7e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt @@ -0,0 +1,316 @@ +package com.fabledsword.minstrel.library.ui + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.composables.icons.lucide.ArrowLeft +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Play +import com.composables.icons.lucide.User +import com.fabledsword.minstrel.library.widgets.AlbumCard +import com.fabledsword.minstrel.models.ArtistDetailRef +import com.fabledsword.minstrel.models.ArtistRef +import com.fabledsword.minstrel.models.albumCoverPath +import com.fabledsword.minstrel.nav.AlbumDetail +import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.LikeButton +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold +import com.fabledsword.minstrel.shared.widgets.ServerImage +import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ArtistDetailScreen( + navController: NavHostController, + viewModel: ArtistDetailViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(Unit) { + viewModel.transientMessages.collect { msg -> + snackbarHostState.showSnackbar(msg) + } + } + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(titleFor(state)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Lucide.ArrowLeft, contentDescription = "Back") + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { inner -> + PullToRefreshScaffold( + onRefresh = { viewModel.refresh().join() }, + modifier = Modifier.fillMaxSize().padding(inner), + ) { + Crossfade(targetState = state, label = "artist-detail") { s -> + when (s) { + is ArtistDetailUiState.Loading -> + if (s.seed != null) { + SeededArtistLoading(s.seed) + } else { + SkeletonArtistAlbumsGrid() + } + is ArtistDetailUiState.Error -> EmptyState( + title = "Couldn't load artist", + body = s.message, + ) + is ArtistDetailUiState.Success -> { + val artistLiked by viewModel.artistLiked + .collectAsStateWithLifecycle() + ArtistBody( + detail = s.detail, + artistLiked = artistLiked, + onPlay = viewModel::playArtist, + onToggleLike = viewModel::toggleLikeArtist, + onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) }, + ) + } + } + } + } + } +} + +private fun titleFor(state: ArtistDetailUiState): String = when (state) { + is ArtistDetailUiState.Success -> state.detail.artist.name + is ArtistDetailUiState.Loading -> state.seed?.name ?: "Artist" + is ArtistDetailUiState.Error -> "Artist" +} + +@Composable +private fun ArtistBody( + detail: ArtistDetailRef, + artistLiked: Boolean, + onPlay: () -> Unit, + onToggleLike: () -> Unit, + onAlbumClick: (String) -> Unit, +) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 176.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + ArtistHeader( + artist = detail.artist, + artistLiked = artistLiked, + fallbackAlbumId = detail.albums.firstOrNull()?.id, + onPlay = onPlay, + onToggleLike = onToggleLike, + ) + } + if (detail.albums.isEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + EmptyAlbumsHint() + } + } else { + items(items = detail.albums, key = { it.id }) { album -> + AlbumCard(album = album, onClick = { onAlbumClick(album.id) }) + } + } + } +} + +@Composable +private fun ArtistHeader( + artist: ArtistRef, + artistLiked: Boolean, + fallbackAlbumId: String?, + onPlay: () -> Unit, + onToggleLike: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + ArtistAvatar(artist, fallbackAlbumId) + Column(modifier = Modifier.weight(1f)) { + Text( + text = artist.name, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (artist.albumCount > 0) { + Text( + text = albumCountLine(artist), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + LikeButton(liked = artistLiked, onToggle = onToggleLike) + Button(onClick = onPlay) { + Icon(Lucide.Play, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Play") + } + } +} + +@Composable +private fun ArtistAvatar(artist: ArtistRef, fallbackAlbumId: String? = null) { + // Server coverUrl wins; otherwise mirror the server's "use the + // first album's cover" rule via the loaded album list (Flutter + // _ArtistAvatar). Bare Lucide.User only when neither exists. + val model = when { + artist.coverUrl.isNotEmpty() -> artist.coverUrl + fallbackAlbumId != null -> albumCoverPath(fallbackAlbumId) + else -> null + } + Box( + modifier = Modifier + .size(96.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + ServerImage( + url = model, + contentDescription = artist.name, + modifier = Modifier.fillMaxSize(), + ) { + Icon( + imageVector = Lucide.User, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(40.dp), + ) + } + } +} + +private fun albumCountLine(artist: ArtistRef): String = + if (artist.albumCount == 1) "1 album" else "${artist.albumCount} albums" + +@Composable +private fun EmptyAlbumsHint() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "No albums yet for this artist.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "They might still be importing or the scan hasn't picked them up.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private const val SKELETON_ARTIST_GRID_TILES = 9 + +@Composable +private fun SkeletonArtistAlbumsGrid() { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 176.dp), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize(), + ) { + items(SKELETON_ARTIST_GRID_TILES) { SkeletonAlbumTile() } + } +} + +/** + * Loading body that renders the artist header from the navigation + * seed (avatar + name + album count) with a skeleton album grid + * below until the fetch lands. Play / Like omitted — they need the + * loaded detail. + */ +@Composable +private fun SeededArtistLoading(seed: ArtistRef) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 176.dp), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize(), + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + ArtistAvatar(seed) + Column(modifier = Modifier.weight(1f)) { + Text( + text = seed.name, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (seed.albumCount > 0) { + Text( + text = albumCountLine(seed), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + items(SKELETON_ARTIST_GRID_TILES) { SkeletonAlbumTile() } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt new file mode 100644 index 00000000..9355c670 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt @@ -0,0 +1,121 @@ +package com.fabledsword.minstrel.library.ui + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.library.data.LibraryRepository +import com.fabledsword.minstrel.likes.data.LikesRepository +import com.fabledsword.minstrel.models.ArtistDetailRef +import com.fabledsword.minstrel.models.ArtistRef +import com.fabledsword.minstrel.nav.ArtistDetail +import com.fabledsword.minstrel.nav.DetailSeedCache +import com.fabledsword.minstrel.player.PlayerController +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val SHARE_STOP_TIMEOUT_MS = 5_000L + +sealed interface ArtistDetailUiState { + data class Loading(val seed: ArtistRef? = null) : ArtistDetailUiState + data class Success(val detail: ArtistDetailRef) : ArtistDetailUiState + data class Error(val message: String) : ArtistDetailUiState +} + +@HiltViewModel +class ArtistDetailViewModel @Inject constructor( + private val repository: LibraryRepository, + private val likes: LikesRepository, + private val player: PlayerController, + private val seedCache: DetailSeedCache, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val route: ArtistDetail = savedStateHandle.toRoute() + val artistId: String = route.id + + private val internal = + MutableStateFlow(ArtistDetailUiState.Loading()) + val uiState: StateFlow = internal.asStateFlow() + + /** Transient one-shot messages for the screen-level snackbar (e.g. Play-button failure). */ + private val transientMessagesChannel = Channel(Channel.BUFFERED) + val transientMessages: Flow = transientMessagesChannel.receiveAsFlow() + + val artistLiked: StateFlow = + likes.observeIsLiked(LikesRepository.ENTITY_ARTIST, artistId) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = false, + ) + + init { + refresh() + } + + fun toggleLikeArtist() { + val desired = !artistLiked.value + viewModelScope.launch { + likes.toggleLike(LikesRepository.ENTITY_ARTIST, artistId, desired) + } + } + + fun refresh(): Job = viewModelScope.launch { + val carried = when (val cur = internal.value) { + is ArtistDetailUiState.Loading -> cur.seed + is ArtistDetailUiState.Success -> cur.detail.artist + is ArtistDetailUiState.Error -> null + } + internal.value = ArtistDetailUiState.Loading(carried ?: seedCache.peekArtist(artistId)) + try { + val detail = repository.refreshArtistDetail(artistId) + internal.value = ArtistDetailUiState.Success(detail) + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.value = ArtistDetailUiState.Error(ErrorCopy.fromThrowable(e)) + } + } + + /** + * Pull every track across the artist's albums, shuffle, and start + * playing. Mirrors Flutter's artist Play button — pressing Play on + * an artist is implicitly "shuffle this artist", not "play album by + * album in tracklist order". Network round-trip per click; fine + * because the user explicitly tapped Play. + */ + fun playArtist() { + viewModelScope.launch { + try { + val tracks = repository.fetchArtistTracks(artistId).shuffled() + if (tracks.isEmpty()) { + transientMessagesChannel.trySend("No tracks to play for this artist.") + } else { + player.setQueue( + tracks = tracks, + initialIndex = 0, + source = "artist:$artistId", + ) + } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + transientMessagesChannel.trySend( + "Couldn't start playback: ${ErrorCopy.fromThrowable(e)}", + ) + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryData.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryData.kt new file mode 100644 index 00000000..ec406454 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryData.kt @@ -0,0 +1,15 @@ +package com.fabledsword.minstrel.library.ui + +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.models.ArtistRef + +/** + * Payload carried by `UiState.Success` for the Library screen. The + * grid renders artists and albums side-by-side, so the state wraps + * both lists into a single value; the generic `shared/UiState` then + * provides Loading / Empty / Error around it. + */ +data class LibraryData( + val artists: List, + val albums: List, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt new file mode 100644 index 00000000..d01236eb --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt @@ -0,0 +1,250 @@ +@file:Suppress("TooManyFunctions") // Compose screen + tab helpers + skeleton helpers + +package com.fabledsword.minstrel.library.ui + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.fabledsword.minstrel.history.ui.HistoryTab +import com.fabledsword.minstrel.library.widgets.AlbumCard +import com.fabledsword.minstrel.library.widgets.ArtistCard +import com.fabledsword.minstrel.likes.ui.LikedTab +import com.fabledsword.minstrel.quarantine.ui.HiddenTab +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.models.ArtistRef +import com.fabledsword.minstrel.nav.AlbumDetail +import com.fabledsword.minstrel.nav.ArtistDetail +import com.fabledsword.minstrel.nav.Library +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Shuffle +import com.fabledsword.minstrel.shared.UiState +import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.ErrorRetry +import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold +import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile +import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile + +/** + * Library tab. Five-tab TabBar (Artists / Albums / History / Liked / + * Hidden) matching `flutter_client/lib/library/library_screen.dart`. + * + * Artists + Albums are wired against the existing LibraryViewModel + * (cache-first reads of cached_artists / cached_albums). The other + * three tabs render placeholder EmptyStates pointing at the upcoming + * data-layer phases: + * - History → Phase 9 (server-side /api/me/history wiring) + * - Liked → Phase 8 (likes write path + cached_likes hydration) + * - Hidden → Phase 10 (quarantine flag/unflag UI lives in Admin slice + * but the user-side "my hidden tracks" view comes here) + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LibraryScreen( + navController: NavHostController, + viewModel: LibraryViewModel = hiltViewModel(), +) { + var selectedTab by remember { mutableIntStateOf(0) } + val tabs = LIBRARY_TABS + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + Column { + MinstrelTopAppBar( + title = "Library", + navController = navController, + currentRouteName = Library::class.qualifiedName, + actions = { + IconButton(onClick = viewModel::shuffleAll) { + Icon( + imageVector = Lucide.Shuffle, + contentDescription = "Shuffle all", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + ) + PrimaryScrollableTabRow( + selectedTabIndex = selectedTab, + edgePadding = 16.dp, + ) { + tabs.forEachIndexed { index, label -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { Text(label) }, + ) + } + } + } + }, + ) { inner -> + Box(modifier = Modifier.fillMaxSize().padding(inner)) { + when (selectedTab) { + TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController) + TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController) + TAB_HISTORY -> HistoryTab( + onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) }, + onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) }, + ) + TAB_LIKED -> LikedTab(navController = navController) + TAB_HIDDEN -> HiddenTab() + } + } + } +} + +private const val TAB_ARTISTS = 0 +private const val TAB_ALBUMS = 1 +private const val TAB_HISTORY = 2 +private const val TAB_LIKED = 3 +private const val TAB_HIDDEN = 4 + +private val LIBRARY_TABS = listOf("Artists", "Albums", "History", "Liked", "Hidden") + +@Composable +private fun ArtistsTab( + viewModel: LibraryViewModel, + navController: NavHostController, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) { + Crossfade(targetState = state, label = "library-artists") { s -> + when (s) { + UiState.Loading -> SkeletonArtistsGrid() + UiState.Empty -> EmptyState( + title = "No artists yet", + body = "Scan a folder in your server settings to " + + "populate the library.", + ) + is UiState.Error -> ErrorRetry( + message = s.message, + onRetry = { viewModel.refresh() }, + ) + is UiState.Success -> ArtistsGrid( + artists = s.data.artists, + onArtistClick = { id -> navController.navigate(ArtistDetail(id)) }, + ) + } + } + } +} + +@Composable +private fun AlbumsTab( + viewModel: LibraryViewModel, + navController: NavHostController, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) { + Crossfade(targetState = state, label = "library-albums") { s -> + when (s) { + UiState.Loading -> SkeletonAlbumsGrid() + UiState.Empty -> EmptyState( + title = "No albums yet", + body = "Scan a folder in your server settings to " + + "populate the library.", + ) + is UiState.Error -> ErrorRetry( + message = s.message, + onRetry = { viewModel.refresh() }, + ) + is UiState.Success -> AlbumsGrid( + albums = s.data.albums, + onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) }, + ) + } + } + } +} + +@Composable +private fun ArtistsGrid( + artists: List, + onArtistClick: (String) -> Unit, +) { + LazyVerticalGrid( + // ArtistCard is 144dp wide; let the grid pack as many per row as + // the device allows (3 on a typical phone, 5+ on tablets). + columns = GridCells.Adaptive(minSize = 144.dp), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items = artists, key = { it.id }) { artist -> + ArtistCard(artist = artist, onClick = { onArtistClick(artist.id) }) + } + } +} + +@Composable +private fun AlbumsGrid( + albums: List, + onAlbumClick: (String) -> Unit, +) { + LazyVerticalGrid( + // AlbumCard is 176dp wide (144dp cover + 8dp gutters). + columns = GridCells.Adaptive(minSize = 176.dp), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items = albums, key = { it.id }) { album -> + AlbumCard(album = album, onClick = { onAlbumClick(album.id) }) + } + } +} + +private const val SKELETON_GRID_TILES = 12 + +@Composable +private fun SkeletonArtistsGrid() { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 144.dp), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize(), + ) { + items(SKELETON_GRID_TILES) { SkeletonArtistTile() } + } +} + +@Composable +private fun SkeletonAlbumsGrid() { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 176.dp), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize(), + ) { + items(SKELETON_GRID_TILES) { SkeletonAlbumTile() } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryViewModel.kt new file mode 100644 index 00000000..e2af8795 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryViewModel.kt @@ -0,0 +1,61 @@ +package com.fabledsword.minstrel.library.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.cache.sync.SyncController +import com.fabledsword.minstrel.library.data.LibraryRepository +import com.fabledsword.minstrel.shared.UiState +import com.fabledsword.minstrel.shared.asCacheFirstStateFlow +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class LibraryViewModel @Inject constructor( + private val repository: LibraryRepository, + private val syncController: SyncController, + private val player: com.fabledsword.minstrel.player.PlayerController, +) : ViewModel() { + + val uiState: StateFlow> = + combine( + repository.observeArtists(), + repository.observeAlbums(), + ) { artists, albums -> + if (artists.isEmpty() && albums.isEmpty()) { + UiState.Empty + } else { + UiState.Success(LibraryData(artists, albums)) + } + } + .asCacheFirstStateFlow(viewModelScope) + + /** + * Pull-to-refresh entry point. Triggers /api/library/sync via the + * SyncController; the resulting Room writes flow through the + * observeArtists/observeAlbums flows to re-render the grids. + */ + fun refresh(): Job = viewModelScope.launch { + syncController.syncSafe() + } + + /** + * "Shuffle all" — fetches a random sample across the entire + * library and starts playback. Silent on transport failure (user + * can re-tap; the action has no inline error surface). + */ + fun shuffleAll() { + viewModelScope.launch { + runCatching { + val tracks = repository.shuffleLibrary() + .filter { it.streamUrl.isNotEmpty() } + if (tracks.isNotEmpty()) { + player.setQueue(tracks, initialIndex = 0, source = "library_shuffle") + } + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt new file mode 100644 index 00000000..37de12e0 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt @@ -0,0 +1,80 @@ +package com.fabledsword.minstrel.library.widgets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.composables.icons.lucide.Disc3 +import com.composables.icons.lucide.Lucide +import androidx.compose.material3.Icon +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.nav.LocalDetailSeedCache +import com.fabledsword.minstrel.shared.widgets.CoverTile + +/** + * One album tile. Mirrors the Flutter `AlbumCard` (~176dp wide, 144dp + * square cover). Tap fires `onClick` — wired to `AlbumDetail` nav in + * Phase 5.5. The tile stashes its `album` in [LocalDetailSeedCache] + * before firing onClick so the detail screen's AppBar renders title / + * cover / counts immediately without waiting for the fetch. + */ +@Composable +fun AlbumCard( + album: AlbumRef, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val seedCache = LocalDetailSeedCache.current + Surface( + modifier = modifier + .width(176.dp) + .clickable { + seedCache.stashAlbum(album) + onClick() + }, + color = Color.Transparent, + ) { + Column(modifier = Modifier.padding(horizontal = 8.dp)) { + CoverTile( + url = album.displayCoverUrl, + contentDescription = album.title, + size = 144.dp, + background = MaterialTheme.colorScheme.surfaceVariant, + fallback = { + Icon( + imageVector = Lucide.Disc3, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(56.dp), + ) + }, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = album.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = album.artistName.ifEmpty { " " }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/ArtistCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/ArtistCard.kt new file mode 100644 index 00000000..b0498cd7 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/ArtistCard.kt @@ -0,0 +1,79 @@ +package com.fabledsword.minstrel.library.widgets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.User +import com.fabledsword.minstrel.models.ArtistRef +import com.fabledsword.minstrel.nav.LocalDetailSeedCache +import com.fabledsword.minstrel.shared.widgets.CoverTile + +/** + * One artist tile. Circular cover; name centered beneath. Tap fires + * `onClick` — wired to `ArtistDetail` nav in Phase 5.5. Stashes the + * `artist` in [LocalDetailSeedCache] on click so the ArtistDetail + * AppBar renders the name before the fetch lands. + */ +@Composable +fun ArtistCard( + artist: ArtistRef, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val seedCache = LocalDetailSeedCache.current + Surface( + modifier = modifier + .width(144.dp) + .clickable { + seedCache.stashArtist(artist) + onClick() + }, + color = Color.Transparent, + ) { + Column( + modifier = Modifier.padding(horizontal = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CoverTile( + url = artist.displayCoverUrl, + contentDescription = artist.name, + size = 128.dp, + shape = CircleShape, + fallback = { + Icon( + imageVector = Lucide.User, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(56.dp), + ) + }, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = artist.name, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt new file mode 100644 index 00000000..1e5087ed --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt @@ -0,0 +1,143 @@ +package com.fabledsword.minstrel.likes.data + +import com.fabledsword.minstrel.api.endpoints.LikesApi +import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao +import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao +import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao +import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao +import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity +import com.fabledsword.minstrel.cache.mutations.MutationQueue +import com.fabledsword.minstrel.library.data.toDomain +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.models.ArtistRef +import com.fabledsword.minstrel.models.TrackRef +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Cache-first reads + offline-first writes for likes. Mirrors the + * Flutter `likesControllerProvider` + `cached_likes`-driven Liked tab + * pattern. + * + * Local `userId` discriminator is a constant for now (`LOCAL_USER_ID`) + * because the AuthController hookup lands with Phase 11. Single-user + * device is the only shape we target; if multi-tenant on one device + * ever shows up, swap this constant for `AuthStore.userId.value` + * everywhere and migrate the cached_likes rows. + * + * Write path follows `feedback_offline_first_for_server_writes` — + * never fire-and-forget: + * 1. Optimistic Room mutation (UI flips instantly). + * 2. Best-effort server call. + * 3. On exception → enqueue in MutationQueue for the Phase 12.2 + * replayer to drain when connectivity returns. + * + * Reads expose Flows of hydrated DomainRefs. Per-tile hydration goes + * through the existing Library DAOs — IDs without cached entity rows + * are silently dropped (the SyncController fills the gap async). + */ +@Singleton +class LikesRepository @Inject constructor( + private val likeDao: CachedLikeDao, + private val albumDao: CachedAlbumDao, + private val artistDao: CachedArtistDao, + private val trackDao: CachedTrackDao, + private val mutationQueue: MutationQueue, + retrofit: Retrofit, +) { + private val api: LikesApi = retrofit.create() + + // ── Reads ───────────────────────────────────────────────────────── + + fun observeLikedArtists(): Flow> = + likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_ARTIST).map { ids -> + ids.mapNotNull { artistDao.getById(it)?.toDomain() } + } + + fun observeLikedAlbums(): Flow> = + likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_ALBUM).map { ids -> + ids.mapNotNull { albumDao.getById(it)?.toDomain() } + } + + fun observeLikedTracks(): Flow> = + likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_TRACK).map { ids -> + ids.mapNotNull { trackDao.getById(it)?.toDomain() } + } + + fun observeIsLiked(entityType: String, entityId: String): Flow = + likeDao.observeIsLiked(LOCAL_USER_ID, entityType, entityId) + + /** One-shot snapshot of the liked track-id set — for the offline pool filter. */ + suspend fun likedTrackIds(): Set = + likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_TRACK).first().toSet() + + // ── Writes (optimistic local → best-effort REST → enqueue on fail) ── + + /** + * Toggle like state for [entityType]/[entityId]. UI doesn't need to + * await this; the Flow re-emits immediately from the optimistic + * Room write. Returns true if the call reached the server, false + * if it got enqueued for later replay. + */ + suspend fun toggleLike(entityType: String, entityId: String, desiredState: Boolean): Boolean { + // 1. Optimistic Room mutation. + if (desiredState) { + likeDao.upsertAll( + listOf(CachedLikeEntity(LOCAL_USER_ID, entityType, entityId)), + ) + } else { + likeDao.delete(LOCAL_USER_ID, entityType, entityId) + } + // 2. Best-effort REST call; 3. enqueue on failure. + val kindPath = serverPathFor(entityType) + return try { + if (desiredState) api.like(kindPath, entityId) else api.unlike(kindPath, entityId) + true + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + // Intentional swallow: the queue replays this later; surfacing + // the network error here would force callers (UI) to handle it + // even though it's already been buffered. The replayer logs + // per-attempt diagnostics when it lands in Phase 12.2. + mutationQueue.enqueueLikeToggle(entityType, entityId, desiredState) + false + } + } + + /** + * Pulls `GET /api/likes/ids` and reconciles `cached_likes`. Called + * by the LikedTab ViewModel on init (and by the SyncController in + * Phase 12). Adds rows for IDs the server has but we don't; the + * delete-of-stale-rows pass lives in the SyncController where the + * full reconciliation runs. + */ + suspend fun refreshIds() { + val wire = api.ids() + val rows = mutableListOf() + rows += wire.artistIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_ARTIST, it) } + rows += wire.albumIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_ALBUM, it) } + rows += wire.trackIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_TRACK, it) } + likeDao.upsertAll(rows) + } + + companion object { + // TODO(Phase 11): swap for AuthStore.userId.value once auth lands. + const val LOCAL_USER_ID: String = "local" + const val ENTITY_ARTIST: String = "artist" + const val ENTITY_ALBUM: String = "album" + const val ENTITY_TRACK: String = "track" + + private fun serverPathFor(entityType: String): String = when (entityType) { + ENTITY_ARTIST -> "artists" + ENTITY_ALBUM -> "albums" + ENTITY_TRACK -> "tracks" + else -> error("Unknown entity type: $entityType") + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt new file mode 100644 index 00000000..9587e7f2 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt @@ -0,0 +1,263 @@ +package com.fabledsword.minstrel.likes.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavHostController +import com.fabledsword.minstrel.likes.data.LikesRepository +import com.fabledsword.minstrel.library.widgets.AlbumCard +import com.fabledsword.minstrel.library.widgets.ArtistCard +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.models.ArtistRef +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.nav.AlbumDetail +import com.fabledsword.minstrel.nav.ArtistDetail +import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.shared.UiState +import com.fabledsword.minstrel.shared.asCacheFirstStateFlow +import com.fabledsword.minstrel.shared.widgets.TrackRow +import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow +import com.fabledsword.minstrel.shared.widgets.LikeButton +import com.fabledsword.minstrel.shared.widgets.LoadingCentered +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold +import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb +import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import javax.inject.Inject + +// ─── State ─────────────────────────────────────────────────────────── + +data class LikedSections( + val artists: List = emptyList(), + val albums: List = emptyList(), + val tracks: List = emptyList(), +) { + val isAllEmpty: Boolean + get() = artists.isEmpty() && albums.isEmpty() && tracks.isEmpty() +} + +// ─── ViewModel ─────────────────────────────────────────────────────── + +@HiltViewModel +class LikedTabViewModel @Inject constructor( + private val repository: LikesRepository, + private val player: PlayerController, +) : ViewModel() { + + init { + refresh() + } + + fun refresh(): Job = viewModelScope.launch { + runCatching { repository.refreshIds() } + } + + /** + * Unlike a track from inline heart-tap. The LikesRepository write + * routes through the MutationQueue so offline taps replay later. + */ + fun unlikeTrack(trackId: String) { + viewModelScope.launch { + repository.toggleLike(LikesRepository.ENTITY_TRACK, trackId, desiredState = false) + } + } + + val uiState: StateFlow> = combine( + repository.observeLikedArtists(), + repository.observeLikedAlbums(), + repository.observeLikedTracks(), + ) { artists, albums, tracks -> + val sections = LikedSections(artists, albums, tracks) + if (sections.isAllEmpty) UiState.Empty else UiState.Success(sections) + }.asCacheFirstStateFlow(viewModelScope) + + /** + * Build a queue from the currently-displayed liked tracks and start + * playback at [initialIndex]. Mirrors Flutter's tap-on-liked-track + * behavior: the visible track list becomes the play queue. Tracks + * with empty streamUrl are ignored as queue entries (the row that + * routes here is itself click-gated on streamUrl). + */ + fun playTracks(tracks: List, initialIndex: Int) { + if (tracks.isEmpty() || initialIndex !in tracks.indices) return + if (tracks[initialIndex].streamUrl.isEmpty()) return + player.setQueue(tracks, initialIndex = initialIndex, source = "liked") + } +} + +// ─── Composable ────────────────────────────────────────────────────── + +@Composable +fun LikedTab( + navController: NavHostController, + viewModel: LikedTabViewModel = hiltViewModel(), + playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val playerState by playerViewModel.uiState.collectAsStateWithLifecycle() + val playingTrackId = playerState.currentTrack?.id + PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) { + when (val s = state) { + UiState.Loading -> LoadingCentered() + UiState.Empty -> EmptyState( + title = "No likes yet", + body = "Tap the heart on an artist, album, or track to start " + + "building your liked collection.", + ) + is UiState.Error -> EmptyState( + title = "Couldn't load likes", + body = s.message, + ) + is UiState.Success -> LikedContent( + sections = s.data, + playingTrackId = playingTrackId, + onArtistClick = { id -> navController.navigate(ArtistDetail(id)) }, + onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) }, + onTrackClick = { index -> + viewModel.playTracks(s.data.tracks, index) + }, + onUnlike = viewModel::unlikeTrack, + onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) }, + onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) }, + ) + } + } +} + +@Composable +private fun LikedContent( + sections: LikedSections, + playingTrackId: String?, + onArtistClick: (String) -> Unit, + onAlbumClick: (String) -> Unit, + onTrackClick: (Int) -> Unit, + onUnlike: (String) -> Unit, + onNavigateToAlbum: (String) -> Unit, + onNavigateToArtist: (String) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + if (sections.artists.isNotEmpty()) { + item { + SectionHeader("Artists", sections.artists.size) + } + item { + HorizontalScrollRow(title = "") { + items(items = sections.artists, key = { "art-${it.id}" }) { artist -> + ArtistCard(artist = artist, onClick = { onArtistClick(artist.id) }) + } + } + } + } + if (sections.albums.isNotEmpty()) { + item { + SectionHeader("Albums", sections.albums.size) + } + item { + HorizontalScrollRow(title = "") { + items(items = sections.albums, key = { "alb-${it.id}" }) { album -> + AlbumCard(album = album, onClick = { onAlbumClick(album.id) }) + } + } + } + } + if (sections.tracks.isNotEmpty()) { + item { + SectionHeader("Tracks", sections.tracks.size) + } + itemsIndexed(items = sections.tracks, key = { _, t -> "trk-${t.id}" }) { index, track -> + LikedTrackRow( + track = track, + nowPlaying = track.id == playingTrackId, + onClick = { onTrackClick(index) }, + onUnlike = { onUnlike(track.id) }, + onNavigateToAlbum = onNavigateToAlbum, + onNavigateToArtist = onNavigateToArtist, + ) + HorizontalDivider() + } + } + item { Spacer(Modifier.height(96.dp)) } + } +} + +@Composable +private fun SectionHeader(label: String, count: Int) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = "$count", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun LikedTrackRow( + track: TrackRef, + nowPlaying: Boolean, + onClick: () -> Unit, + onUnlike: () -> Unit, + onNavigateToAlbum: (String) -> Unit, + onNavigateToArtist: (String) -> Unit, +) { + TrackRow( + title = track.title, + artist = track.artistName.ifEmpty { track.albumTitle }, + trackId = track.id, + onClick = onClick, + nowPlaying = nowPlaying, + enabled = track.streamUrl.isNotEmpty(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + leading = { + TrackCoverThumb(coverUrl = track.coverUrl, contentDescription = track.title) + }, + trailing = { + LikeButton(liked = true, onToggle = onUnlike) + TrackActionsButton( + track = track, + onNavigateToAlbum = onNavigateToAlbum, + onNavigateToArtist = onNavigateToArtist, + ) + }, + ) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/metadata/FreshnessSweeper.kt b/android/app/src/main/java/com/fabledsword/minstrel/metadata/FreshnessSweeper.kt new file mode 100644 index 00000000..96550e1a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/metadata/FreshnessSweeper.kt @@ -0,0 +1,76 @@ +package com.fabledsword.minstrel.metadata + +import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao +import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao +import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao +import com.fabledsword.minstrel.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import javax.inject.Inject +import javax.inject.Singleton + +private const val SWEEP_INTERVAL_MS = 30L * 60 * 1000 // 30 minutes +private const val STALENESS_MS = 24L * 60 * 60 * 1000 // 24 hours +private const val SWEEP_BATCH_SIZE = 100 +private const val SWEEP_CONCURRENCY = 4 + +/** + * Background freshness pass over [MetadataProvider]. Wakes every + * [SWEEP_INTERVAL_MS] and re-fetches up to [SWEEP_BATCH_SIZE] + * cached_album / cached_artist / cached_track rows whose + * `fetchedAt` is older than [STALENESS_MS]. Reuses + * [MetadataProvider.refreshAlbum] / refreshArtist / refreshTrack — + * dedup applies, so a sweep that overlaps with a user-driven + * on-miss fetch is a no-op on the duplicated ID. + * + * Bounded by [SWEEP_CONCURRENCY] in-flight requests so we don't + * saturate the connection pool. Failures are tolerated per-row — + * one bad ID doesn't stop the batch. + * + * Constructed at app launch via the construct-the-singleton trick + * in [com.fabledsword.minstrel.MinstrelApplication]. + */ +@Singleton +class FreshnessSweeper @Inject constructor( + private val albumDao: CachedAlbumDao, + private val artistDao: CachedArtistDao, + private val trackDao: CachedTrackDao, + private val metadataProvider: MetadataProvider, + @ApplicationScope private val appScope: CoroutineScope, +) { + init { + appScope.launch { + while (true) { + sweep() + delay(SWEEP_INTERVAL_MS) + } + } + } + + private suspend fun sweep() { + val cutoff = System.currentTimeMillis() - STALENESS_MS + val sem = Semaphore(SWEEP_CONCURRENCY) + sweepBucket(albumDao.idsStaleBefore(cutoff, SWEEP_BATCH_SIZE), sem) { id -> + metadataProvider.refreshAlbum(id).join() + } + sweepBucket(artistDao.idsStaleBefore(cutoff, SWEEP_BATCH_SIZE), sem) { id -> + metadataProvider.refreshArtist(id).join() + } + sweepBucket(trackDao.idsStaleBefore(cutoff, SWEEP_BATCH_SIZE), sem) { id -> + metadataProvider.refreshTrack(id).join() + } + } + + private suspend fun sweepBucket( + ids: List, + sem: Semaphore, + refresh: suspend (String) -> Unit, + ) { + ids.forEach { id -> + sem.withPermit { refresh(id) } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/metadata/HomeArtistPrewarmer.kt b/android/app/src/main/java/com/fabledsword/minstrel/metadata/HomeArtistPrewarmer.kt new file mode 100644 index 00000000..a89b39e6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/metadata/HomeArtistPrewarmer.kt @@ -0,0 +1,33 @@ +package com.fabledsword.minstrel.metadata + +import java.util.Collections +import javax.inject.Inject +import javax.inject.Singleton + +private const val TOP_N = 8 + +/** + * Pre-warms the Room cache for likely-tap artist tiles on Home. Mirrors + * Flutter's `MetadataPrefetcher`: conservative — artists only (a single + * round-trip per id), top-8 per call, and once per id per session so + * repeated index refreshes don't re-fetch. Albums are intentionally not + * pre-warmed (their detail fetch fans out a track list). + * + * Fetches go through [MetadataProvider.refreshArtist], so they share the + * same per-id dedup AND the 4-slot concurrency gate as on-render + * hydration — the prewarm can't starve the tiles the user is looking at. + */ +@Singleton +class HomeArtistPrewarmer @Inject constructor( + private val metadataProvider: MetadataProvider, +) { + private val warmed = Collections.synchronizedSet(mutableSetOf()) + + fun warm(artistIds: List) { + artistIds.asSequence() + .filter { it.isNotEmpty() } + .filter { warmed.add(it) } + .take(TOP_N) + .forEach { metadataProvider.refreshArtist(it) } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/metadata/MetadataProvider.kt b/android/app/src/main/java/com/fabledsword/minstrel/metadata/MetadataProvider.kt new file mode 100644 index 00000000..24290d88 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/metadata/MetadataProvider.kt @@ -0,0 +1,126 @@ +package com.fabledsword.minstrel.metadata + +import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao +import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao +import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao +import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.library.data.LibraryRepository +import com.fabledsword.minstrel.library.data.toDomain +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.models.ArtistRef +import com.fabledsword.minstrel.models.TrackRef +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +private const val MAX_CONCURRENT_FETCHES = 4 + +/** + * Cache-first metadata reads with eager on-miss live-fetch. + * + * Mirrors Flutter's tile-provider pattern (`albumTileProvider(id)` / + * `artistTileProvider(id)` / `trackTileProvider(id)`): subscribers + * observe a per-ID Flow that emits the Room row immediately if + * present, and otherwise triggers a single background fetch that + * upserts into Room — the flow then re-emits the populated row. + * + * Fetch dedup is per-ID via a ConcurrentHashMap of in-flight Jobs; + * five tiles asking for the same missing album = one network call. + * Fetch errors are swallowed (the row stays null and the tile keeps + * rendering its skeleton); the [FreshnessSweeper] retries later. + * + * Fire-and-forget: subscribers see Flow where null means "not + * cached yet" — they render skeletons in that case and re-render + * automatically when the fetch lands. + */ +@Singleton +class MetadataProvider @Inject constructor( + private val albumDao: CachedAlbumDao, + private val artistDao: CachedArtistDao, + private val trackDao: CachedTrackDao, + private val libraryRepository: LibraryRepository, + @ApplicationScope private val appScope: CoroutineScope, +) { + private val inFlightAlbums = ConcurrentHashMap() + private val inFlightArtists = ConcurrentHashMap() + private val inFlightTracks = ConcurrentHashMap() + + // Caps concurrent on-miss fetches so a cold Home (~30 tiles) doesn't + // fire ~30 parallel /api calls. Mirrors Flutter HydrationQueue's + // _maxConcurrent = 4. Per-id dedup (above) is orthogonal: the gate + // bounds distinct-id fetches; extras suspend on the semaphore. + private val fetchGate = Semaphore(MAX_CONCURRENT_FETCHES) + + /** + * Live AlbumRef? for [id]. Fires a background fetch the first + * time the cache emits null. Safe to subscribe concurrently from + * multiple tiles — fetch is deduplicated. + */ + fun observeAlbum(id: String): Flow = + albumDao.observeById(id) + .map { it?.toDomain() } + .onEach { row -> if (row == null) fetchAlbum(id) } + + fun observeArtist(id: String): Flow = + combine( + artistDao.observeById(id), + albumDao.observeByArtist(id), + ) { artist, albums -> + artist?.toDomain(coverAlbumId = albums.minByOrNull { it.sortTitle }?.id ?: "") + }.onEach { row -> if (row == null) fetchArtist(id) } + + fun observeTrack(id: String): Flow = + trackDao.observeById(id) + .map { it?.toDomain() } + .onEach { row -> if (row == null) fetchTrack(id) } + + /** + * Force-refresh [id] from the server even if the cache has it. + * Used by [FreshnessSweeper] to walk stale rows. Same dedup as + * the on-miss path — repeated calls during an in-flight fetch + * are no-ops. + */ + fun refreshAlbum(id: String): Job = fetchAlbum(id) + fun refreshArtist(id: String): Job = fetchArtist(id) + fun refreshTrack(id: String): Job = fetchTrack(id) + + private fun fetchAlbum(id: String): Job = launchDeduped(inFlightAlbums, id) { + libraryRepository.refreshAlbumDetail(id) + } + + private fun fetchArtist(id: String): Job = launchDeduped(inFlightArtists, id) { + libraryRepository.refreshArtistDetail(id) + } + + private fun fetchTrack(id: String): Job = launchDeduped(inFlightTracks, id) { + libraryRepository.refreshTrack(id) + } + + private fun launchDeduped( + inFlight: ConcurrentHashMap, + id: String, + block: suspend () -> Unit, + ): Job = inFlight.computeIfAbsent(id) { + appScope.launch { + try { + fetchGate.withPermit { block() } + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + // Soft-fail. Row stays null; tile stays skeletal until + // FreshnessSweeper retries or the user pulls to refresh. + } finally { + inFlight.remove(id) + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/AdminQuarantineItem.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/AdminQuarantineItem.kt new file mode 100644 index 00000000..03b1b09c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/AdminQuarantineItem.kt @@ -0,0 +1,41 @@ +package com.fabledsword.minstrel.models + +/** + * One user's quarantine report under an aggregated row. + */ +data class AdminQuarantineReportRef( + val userId: String, + val username: String, + val reason: String, + val notes: String? = null, + val createdAt: String = "", +) + +/** + * Aggregated quarantine row in the admin queue — all reports against a + * single track collapsed into per-reason counts plus the underlying + * per-user reports. Mirrors Flutter's `AdminQuarantineItem`. + * + * `topReasonSummary` returns the most-cited reason, with a "(+N more)" + * suffix when other distinct reasons exist. + */ +data class AdminQuarantineItemRef( + val trackId: String, + val trackTitle: String, + val artistName: String, + val albumTitle: String, + val albumId: String, + val lidarrAlbumMbid: String? = null, + val reportCount: Int = 0, + val latestAt: String = "", + val reasonCounts: Map = emptyMap(), + val reports: List = emptyList(), +) { + val topReasonSummary: String + get() { + if (reasonCounts.isEmpty()) return "" + val sorted = reasonCounts.entries.sortedByDescending { it.value } + val top = sorted.first().key + return if (sorted.size > 1) "$top (+${sorted.size - 1} more)" else top + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/AdminUserRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/AdminUserRef.kt new file mode 100644 index 00000000..96fa7bd5 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/AdminUserRef.kt @@ -0,0 +1,15 @@ +package com.fabledsword.minstrel.models + +/** + * One admin-side user row. Mirrors Flutter's `AdminUser`. The + * admin-scoped variant exposes `autoApproveRequests` and the + * canonical `createdAt` that the user-scoped /me response omits. + */ +data class AdminUserRef( + val id: String, + val username: String, + val displayName: String? = null, + val isAdmin: Boolean = false, + val autoApproveRequests: Boolean = false, + val createdAt: String = "", +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/AlbumDetailRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/AlbumDetailRef.kt new file mode 100644 index 00000000..3d84cca0 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/AlbumDetailRef.kt @@ -0,0 +1,12 @@ +package com.fabledsword.minstrel.models + +/** + * Snapshot of one album with its track list. Returned by + * `LibraryRepository.refreshAlbumDetail` so the AlbumDetail screen + * can render immediately from the server response without waiting + * for the Room round-trip. + */ +data class AlbumDetailRef( + val album: AlbumRef, + val tracks: List, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/AlbumRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/AlbumRef.kt new file mode 100644 index 00000000..a58369de --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/AlbumRef.kt @@ -0,0 +1,36 @@ +package com.fabledsword.minstrel.models + +/** + * Lightweight reference to one album. Mirrors + * `flutter_client/lib/models/album.dart`'s `AlbumRef`. + * + * `coverUrl` and `durationSec` match the server contract (not + * `cover_art_url` / `duration_ms`). `year` is omitempty server-side so + * stays nullable. `coverUrl` is non-null but may be empty — UI branches + * on isEmpty. + */ +data class AlbumRef( + val id: String, + val title: String, + val artistId: String, + val sortTitle: String = "", + val artistName: String = "", + val year: Int? = null, + val trackCount: Int = 0, + val durationSec: Int = 0, + val coverUrl: String = "", +) { + /** + * Cover URL the UI should render. Returns the server-provided + * `coverUrl` when populated (regular API responses always carry it), + * otherwise derives `/api/albums/{id}/cover` via the placeholder + * host that `BaseUrlInterceptor` rewrites to the live server URL. + * + * The fallback path matters because the cached_albums entity → + * AlbumRef mapper drops coverUrl — without this property, + * AlbumCards rendered from cache (Library Albums tab, ArtistDetail + * grid, Home rows on cold-start) would show no artwork at all. + */ + val displayCoverUrl: String + get() = coverUrl.ifEmpty { albumCoverPath(id) } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/ArtistDetailRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/ArtistDetailRef.kt new file mode 100644 index 00000000..74992d36 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/ArtistDetailRef.kt @@ -0,0 +1,12 @@ +package com.fabledsword.minstrel.models + +/** + * Snapshot of one artist with their album list. Returned by + * `LibraryRepository.refreshArtistDetail` so the ArtistDetail screen + * can render from the wire response without waiting for the Room + * round-trip. + */ +data class ArtistDetailRef( + val artist: ArtistRef, + val albums: List, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/ArtistRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/ArtistRef.kt new file mode 100644 index 00000000..94b78166 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/ArtistRef.kt @@ -0,0 +1,36 @@ +package com.fabledsword.minstrel.models + +/** + * Lightweight reference to one artist. Mirrors + * `flutter_client/lib/models/artist.dart`'s `ArtistRef`. + * + * `coverUrl` is the server's field name (NOT cover_art_url). Server emits + * empty string when the artist has no representative album cover; UI code + * branches on isEmpty rather than null-checking. + */ +data class ArtistRef( + val id: String, + val name: String, + val sortName: String = "", + val albumCount: Int = 0, + val coverUrl: String = "", + val coverAlbumId: String = "", +) { + /** + * Cover URL the UI should render, or null when the artist has no + * representative cover. Prefers the server-provided `coverUrl` + * (relative, e.g. `/api/artists` ships it); otherwise derives the + * first cached album's `/api/albums/{id}/cover` via [coverAlbumId]. + * + * The fallback matters because cached_artists stores no cover and + * the sync payload carries none — so for cache-sourced artist tiles + * we mirror Flutter's `artistTileProvider`, which JOINs the first + * album to supply the cover. Resolve through `ServerImage`. + */ + val displayCoverUrl: String? + get() = when { + coverUrl.isNotEmpty() -> coverUrl + coverAlbumId.isNotEmpty() -> albumCoverPath(coverAlbumId) + else -> null + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/CoverUrls.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/CoverUrls.kt new file mode 100644 index 00000000..432dcff8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/CoverUrls.kt @@ -0,0 +1,11 @@ +package com.fabledsword.minstrel.models + +/** + * Absolute cover URL for an album, or "" when [albumId] is blank. Uses + * the placeholder host that `BaseUrlInterceptor` rewrites to the live + * server (carrying the auth cookie); `ServerImage` passes it through, + * and the raw-AsyncImage cover surfaces still load it. The single + * album-cover URL builder for the whole client. + */ +fun albumCoverPath(albumId: String): String = + if (albumId.isEmpty()) "" else "http://placeholder.invalid/api/albums/$albumId/cover" diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Discover.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Discover.kt new file mode 100644 index 00000000..b04e9fe8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Discover.kt @@ -0,0 +1,69 @@ +package com.fabledsword.minstrel.models + +/** + * Kind of Lidarr request being created. Wire form is the lowercase + * enum name; the helper [wire] keeps that mapping in one place. + */ +enum class LidarrRequestKind { + ARTIST, ALBUM, TRACK; + + val wire: String get() = name.lowercase() +} + +/** + * Lidarr search hit. Mirrors `flutter_client/lib/models/lidarr.dart`'s + * `LidarrSearchResult` — `mbid` is the result's own MBID; `artistMbid` + * and `albumMbid` are filled when the row is an album/track and the + * UI needs the parent IDs to build the request. + * + * `inLibrary` and `requested` together cover the "already-handled" + * cases — UI greys the row + swaps the Request button for a pill. + */ +data class LidarrSearchResultRef( + val mbid: String, + val name: String, + val secondaryText: String = "", + val imageUrl: String = "", + val artistMbid: String = "", + val albumMbid: String = "", + val inLibrary: Boolean = false, + val requested: Boolean = false, +) + +/** + * Per-seed attribution string for a suggestion — "you liked X" / + * "you played Y". `isLiked = true` discriminates the verb. + */ +data class SeedContributionRef( + val name: String, + val isLiked: Boolean, +) + +/** + * Out-of-library artist suggestion from `GET /api/discover/suggestions`. + * `attributionText` mirrors Flutter's SuggestionFeed.attributionText + * (Oxford-comma, max 3 seeds) so the subtitle reads naturally. + */ +data class ArtistSuggestionRef( + val mbid: String, + val name: String, + val imageUrl: String = "", + val attribution: List = emptyList(), +) { + val attributionText: String + get() { + if (attribution.isEmpty()) return "" + val phrases = attribution + .take(MAX_ATTRIBUTION_PHRASES) + .map { "${if (it.isLiked) "liked" else "played"} ${it.name}" } + return when (phrases.size) { + 1 -> "Because you ${phrases[0]}." + 2 -> "Because you ${phrases[0]} and ${phrases[1]}." + else -> "Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}." + } + } + + companion object { + private const val MAX_ATTRIBUTION_PHRASES = 3 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/HomeTile.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/HomeTile.kt new file mode 100644 index 00000000..7ca9365b --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/HomeTile.kt @@ -0,0 +1,10 @@ +package com.fabledsword.minstrel.models + +/** + * A Home section entry: the stable entity [id] (always known from the + * cached_home_index) paired with its hydrated [value], or null while the + * entity row hasn't landed in Room yet. The UI renders a skeleton tile + * for null and the real card for non-null, keyed by [id] so the swap is + * a content change rather than a re-key. + */ +data class HomeTile(val id: String, val value: T?) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Invite.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Invite.kt new file mode 100644 index 00000000..c7eadab2 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Invite.kt @@ -0,0 +1,22 @@ +package com.fabledsword.minstrel.models + +/** + * Domain shape for one admin-issued registration invite. Mirrors + * `flutter_client/lib/models/invite.dart Invite` and the server's + * `inviteResp` from `internal/api/admin_invites.go`. + * + * `invitedBy` and `redeemedBy` are UUIDs of users (not usernames); + * the server doesn't denormalise. TTL is hardcoded server-side at + * 24h — admins can only customise the optional `note`. + */ +data class Invite( + val token: String, + val invitedBy: String, + val note: String?, + val createdAt: String, + val expiresAt: String, + val redeemedAt: String? = null, + val redeemedBy: String? = null, +) { + val isRedeemed: Boolean get() = redeemedAt != null +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/ListenBrainzStatus.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/ListenBrainzStatus.kt new file mode 100644 index 00000000..0813d11e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/ListenBrainzStatus.kt @@ -0,0 +1,16 @@ +package com.fabledsword.minstrel.models + +/** + * Caller's ListenBrainz integration state. Mirrors + * `flutter_client/lib/models/my_profile.dart ListenBrainzStatus` + * and the server's `listenBrainzResp`. + * + * The token itself is never read back from the server — `tokenSet` + * is the visible signal. Empty `lastScrobbledAt` means no scrobble + * has happened (either never enabled, or first play hasn't ended yet). + */ +data class ListenBrainzStatus( + val enabled: Boolean, + val tokenSet: Boolean, + val lastScrobbledAt: String? = null, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/MyProfile.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/MyProfile.kt new file mode 100644 index 00000000..44695010 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/MyProfile.kt @@ -0,0 +1,15 @@ +package com.fabledsword.minstrel.models + +/** + * Domain shape for the caller's account profile. Mirrors Flutter's + * `MyProfile`. `displayName` and `email` are nullable — server stores + * null when the user hasn't set them yet (registration only requires + * a username). + */ +data class MyProfile( + val id: String, + val username: String, + val displayName: String?, + val email: String?, + val isAdmin: Boolean, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt new file mode 100644 index 00000000..a4610c05 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt @@ -0,0 +1,66 @@ +package com.fabledsword.minstrel.models + +/** + * Lightweight reference to one playlist (user or system-generated). + * Mirrors `flutter_client/lib/models/playlist.dart`'s `Playlist`. + * + * `systemVariant` discriminates user vs. system playlists — null for + * user-owned, one of "for_you" / "discover" / "songs_like_artist" / etc. + * for server-generated mixes. The UI gates "edit / delete / append + * tracks" affordances on `isSystem` so user playlists are the only + * mutable ones. + */ +data class PlaylistRef( + val id: String, + val userId: String, + val name: String, + val description: String = "", + val isPublic: Boolean = false, + val systemVariant: String? = null, + val trackCount: Int = 0, + val coverUrl: String = "", + val ownerUsername: String = "", +) { + val isSystem: Boolean get() = systemVariant != null + + /** + * Whether the "Regenerate" affordance applies. Mirrors Flutter's + * `Playlist.refreshable`: every system playlist EXCEPT + * songs_like_artist (server's *songs-like-X* family doesn't + * expose a refresh trigger; rebuilds are scheduler-driven). + */ + val refreshable: Boolean get() = isSystem && systemVariant != "songs_like_artist" +} + +/** + * One row of a playlist's tracks. Mirrors Flutter's `PlaylistTrack`. + * + * `trackId` and `streamUrl` are nullable because the upstream track can + * be removed from the library while the row stays in the playlist — + * those tiles render grey + unplayable per Flutter's `isAvailable` + * convention. + */ +data class PlaylistTrackRef( + val position: Int, + val trackId: String?, + val title: String, + val albumId: String?, + val albumTitle: String = "", + val artistId: String?, + val artistName: String = "", + val durationSec: Int = 0, + val streamUrl: String? = null, +) { + val isAvailable: Boolean get() = trackId != null + + /** + * Cover URL derived from the parent album's `/api/albums/{id}/cover` + * endpoint, using the placeholder host that `BaseUrlInterceptor` + * rewrites to the live server URL. Same mechanism as + * [TrackRef.coverUrl] — Coil shares the OkHttp client. Empty when + * the row has no album binding (rare; usually only for tracks + * whose upstream album was deleted). + */ + val coverUrl: String + get() = albumCoverPath(albumId.orEmpty()) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/QuarantineRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/QuarantineRef.kt new file mode 100644 index 00000000..b18cef97 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/QuarantineRef.kt @@ -0,0 +1,40 @@ +package com.fabledsword.minstrel.models + +/** + * One quarantined track owned by the caller. Mirrors the Flutter + * `QuarantineMineRow` (web `LidarrQuarantineMineRow`). + * + * `reasonLabel` provides the display string mapped from the raw + * server-side reason key — UI renders it as a pill on the row. + */ +data class QuarantineRef( + val trackId: String, + val reason: String, + val notes: String? = null, + val createdAt: String = "", + val trackTitle: String = "", + val trackDurationMs: Int = 0, + val albumId: String = "", + val albumTitle: String = "", + val albumCoverArtPath: String? = null, + val artistId: String = "", + val artistName: String = "", +) { + val reasonLabel: String + get() = when (reason) { + "bad_rip" -> "Bad rip" + "wrong_file" -> "Wrong file" + "wrong_tags" -> "Wrong tags" + "duplicate" -> "Duplicate" + else -> "Other" + } + + /** + * Cover URL derived from the parent album's + * `/api/albums/{id}/cover` endpoint. Uses the placeholder host + * that `BaseUrlInterceptor` rewrites to the live server URL. + * Empty when the row has no album binding (rare). + */ + val coverUrl: String + get() = albumCoverPath(albumId) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Request.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Request.kt new file mode 100644 index 00000000..45b075d6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Request.kt @@ -0,0 +1,64 @@ +package com.fabledsword.minstrel.models + +/** + * Status of a Lidarr request. Stored on the wire as a plain string; + * the typed enum keeps switch / when blocks exhaustive and lets the + * UI map directly to a status pill color without raw-string matches. + */ +enum class RequestStatus { + PENDING, APPROVED, REJECTED, COMPLETED, FAILED, UNKNOWN; + + companion object { + fun fromWire(s: String): RequestStatus = when (s) { + "pending" -> PENDING + "approved" -> APPROVED + "rejected" -> REJECTED + "completed" -> COMPLETED + "failed" -> FAILED + else -> UNKNOWN + } + } + + val wire: String get() = name.lowercase() +} + +/** + * One Lidarr request the user has submitted. Mirrors + * `flutter_client/lib/models/admin_request.dart AdminRequest` — + * shared between the user-side `/api/requests` view and the admin + * cross-user view since the wire shape is identical. + * + * `displayName` picks the right field based on kind: + * - album → albumTitle (fallback artistName) + * - track → trackTitle (fallback artistName) + * - artist → artistName + * + * `listenHrefId` returns the most-specific matched library ID once the + * request completes (track → album → artist) so the "Listen" CTA can + * navigate to the right detail screen. Returns null until the + * server's ingest job populates the matched_* fields. + */ +data class RequestRef( + val id: String, + val userId: String, + val status: RequestStatus, + val kind: String, + val artistName: String, + val albumTitle: String? = null, + val trackTitle: String? = null, + val requestedAt: String, + val decidedAt: String? = null, + val notes: String? = null, + val importedAlbumCount: Int = 0, + val importedTrackCount: Int = 0, + val matchedTrackId: String? = null, + val matchedAlbumId: String? = null, + val matchedArtistId: String? = null, +) { + val displayName: String + get() = when (kind) { + "album" -> albumTitle ?: artistName + "track" -> trackTitle ?: artistName + else -> artistName + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/SearchResponseRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/SearchResponseRef.kt new file mode 100644 index 00000000..57409613 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/SearchResponseRef.kt @@ -0,0 +1,17 @@ +package com.fabledsword.minstrel.models + +/** + * Three-facet search result. Each list is the first page returned by + * `/api/search`; deeper pages per facet are a future enhancement. + * + * `isEmpty` lets the screen pick between "no matches" and "type to + * search" copy. + */ +data class SearchResponseRef( + val artists: List, + val albums: List, + val tracks: List, +) { + val isEmpty: Boolean + get() = artists.isEmpty() && albums.isEmpty() && tracks.isEmpty() +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/SystemPlaylistsStatus.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/SystemPlaylistsStatus.kt new file mode 100644 index 00000000..edee47ee --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/SystemPlaylistsStatus.kt @@ -0,0 +1,17 @@ +package com.fabledsword.minstrel.models + +/** + * Caller's most recent system_playlist_runs state, driving the Home + * placeholder cards for not-yet-generated system playlists. Mirrors + * `flutter_client/lib/models/system_playlists_status.dart` and the + * server's `systemPlaylistsStatusResp`. + * + * Zero values (inFlight=false, both timestamps null) mean the user + * has never had a build attempted — the placeholders read as + * "pending". + */ +data class SystemPlaylistsStatus( + val inFlight: Boolean = false, + val lastRunAt: String? = null, + val lastError: String? = null, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/TrackRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/TrackRef.kt new file mode 100644 index 00000000..345d0137 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/TrackRef.kt @@ -0,0 +1,45 @@ +package com.fabledsword.minstrel.models + +import kotlinx.serialization.Serializable + +/** + * Lightweight reference to one track. Mirrors + * `flutter_client/lib/models/track.dart`'s `TrackRef`. + * + * The `Ref` suffix matches the Flutter convention — these types carry + * only the IDs + display fields needed for list rendering + the player + * queue, not full per-row metadata. + * + * Constructed from `TrackWire` (server JSON) via `LibraryMappers`, and + * from `CachedTrackEntity` (Room cache) via the same mappers. Either + * source produces an equivalent TrackRef. + * + * `@Serializable` because the ResumeController persists List + * as JSON in `cached_resume_state.json`. (Mild leak of persistence + * concern into the domain type, but the alternative — a duplicate + * persisted DTO with manual mappers — wasn't worth the boilerplate.) + */ +@Serializable +data class TrackRef( + val id: String, + val title: String, + val albumId: String, + val artistId: String, + val albumTitle: String = "", + val artistName: String = "", + val trackNumber: Int? = null, + val discNumber: Int? = null, + val durationSec: Int = 0, + val streamUrl: String = "", +) { + /** + * Cover URL derived from the parent album's `/api/albums/{id}/cover` + * endpoint. Uses the placeholder host that `BaseUrlInterceptor` + * rewrites to the live server URL — Coil shares the same OkHttp + * client as Retrofit so the rewrite + auth-cookie flow applies + * identically. Empty `albumId` yields an empty string; callers + * branch on isEmpty to show the placeholder icon. + */ + val coverUrl: String + get() = albumCoverPath(albumId) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/UpdateInfo.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/UpdateInfo.kt new file mode 100644 index 00000000..745de7c4 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/UpdateInfo.kt @@ -0,0 +1,15 @@ +package com.fabledsword.minstrel.models + +/** + * Wire shape returned by `GET /api/client/version`. Mirrors + * `flutter_client/lib/update/update_info.dart UpdateInfo`. + * + * `version` is the server-bundled APK version (may have a leading + * "v" from the git tag); `apkUrl` is server-relative (e.g. + * `/api/client/apk`); `sizeBytes` is the download size. + */ +data class UpdateInfo( + val version: String, + val apkUrl: String, + val sizeBytes: Long, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/UserRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/UserRef.kt new file mode 100644 index 00000000..35572695 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/UserRef.kt @@ -0,0 +1,19 @@ +package com.fabledsword.minstrel.models + +import kotlinx.serialization.Serializable + +/** + * The caller's identity as returned by `POST /api/auth/login`. Narrow + * field set — no password hash, no api token, no Subsonic password — + * matches the server's `UserView` envelope. + * + * `@Serializable` so AuthStore can persist the current user across + * cold restarts via the JSON-encoded `userJson` column on + * `AuthSessionEntity`. + */ +@Serializable +data class UserRef( + val id: String, + val username: String, + val isAdmin: Boolean = false, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminQuarantineWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminQuarantineWire.kt new file mode 100644 index 00000000..f41d4bd8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminQuarantineWire.kt @@ -0,0 +1,37 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * One user's individual report under an aggregated quarantine row. + * Mirrors Flutter's `AdminQuarantineReport` / + * `internal/api/admin_quarantine.go` reportView. + */ +@Serializable +data class AdminQuarantineReportWire( + @SerialName("user_id") val userId: String = "", + val username: String = "", + val reason: String = "", + val notes: String? = null, + @SerialName("created_at") val createdAt: String = "", +) + +/** + * One row of `GET /api/admin/quarantine` — all reports against a + * single track collapsed into per-reason counts plus the underlying + * per-user reports. Mirrors `adminQueueRowView`. + */ +@Serializable +data class AdminQuarantineItemWire( + @SerialName("track_id") val trackId: String = "", + @SerialName("track_title") val trackTitle: String = "", + @SerialName("artist_name") val artistName: String = "", + @SerialName("album_title") val albumTitle: String = "", + @SerialName("album_id") val albumId: String = "", + @SerialName("lidarr_album_mbid") val lidarrAlbumMbid: String? = null, + @SerialName("report_count") val reportCount: Int = 0, + @SerialName("latest_at") val latestAt: String = "", + @SerialName("reason_counts") val reasonCounts: Map = emptyMap(), + val reports: List = emptyList(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminUserWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminUserWire.kt new file mode 100644 index 00000000..18fe9227 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminUserWire.kt @@ -0,0 +1,29 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * One admin-side user row. Mirrors `adminUserView` from + * `internal/api/admin_users.go`. Distinct from the user-scoped /me + * profile shape because admin endpoints expose `auto_approve_requests` + * and the canonical `created_at`. + */ +@Serializable +data class AdminUserWire( + val id: String = "", + val username: String = "", + @SerialName("display_name") val displayName: String? = null, + @SerialName("is_admin") val isAdmin: Boolean = false, + @SerialName("auto_approve_requests") val autoApproveRequests: Boolean = false, + @SerialName("created_at") val createdAt: String = "", +) + +/** + * Envelope of `GET /api/admin/users`. Server wraps the list in + * `{"users": [...]}`; the repository unwraps before mapping. + */ +@Serializable +data class AdminUsersListWire( + val users: List = emptyList(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AlbumDetailWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AlbumDetailWire.kt new file mode 100644 index 00000000..6dc5d6a8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AlbumDetailWire.kt @@ -0,0 +1,22 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Wire shape for `GET /api/albums/{id}` — server returns AlbumRef + * fields PLUS an embedded `tracks` array. + */ +@Serializable +data class AlbumDetailWire( + val id: String, + val title: String, + @SerialName("artist_id") val artistId: String, + @SerialName("sort_title") val sortTitle: String = "", + @SerialName("artist_name") val artistName: String = "", + val year: Int? = null, + @SerialName("track_count") val trackCount: Int = 0, + @SerialName("duration_sec") val durationSec: Int = 0, + @SerialName("cover_url") val coverUrl: String = "", + val tracks: List = emptyList(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AlbumWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AlbumWire.kt new file mode 100644 index 00000000..acf8f626 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AlbumWire.kt @@ -0,0 +1,20 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Wire shape for `AlbumRef`. Mirrors `flutter_client/lib/models/album.dart`. + */ +@Serializable +data class AlbumWire( + val id: String, + val title: String, + @SerialName("artist_id") val artistId: String, + @SerialName("sort_title") val sortTitle: String = "", + @SerialName("artist_name") val artistName: String = "", + val year: Int? = null, + @SerialName("track_count") val trackCount: Int = 0, + @SerialName("duration_sec") val durationSec: Int = 0, + @SerialName("cover_url") val coverUrl: String = "", +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ArtistDetailWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ArtistDetailWire.kt new file mode 100644 index 00000000..06e0522f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ArtistDetailWire.kt @@ -0,0 +1,18 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Wire shape for `GET /api/artists/{id}` — server returns ArtistRef + * fields PLUS an embedded `albums` array. + */ +@Serializable +data class ArtistDetailWire( + val id: String, + val name: String, + @SerialName("sort_name") val sortName: String = "", + @SerialName("album_count") val albumCount: Int = 0, + @SerialName("cover_url") val coverUrl: String = "", + val albums: List = emptyList(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ArtistWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ArtistWire.kt new file mode 100644 index 00000000..1c389a6a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ArtistWire.kt @@ -0,0 +1,16 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Wire shape for `ArtistRef`. Mirrors `flutter_client/lib/models/artist.dart`. + */ +@Serializable +data class ArtistWire( + val id: String, + val name: String, + @SerialName("sort_name") val sortName: String = "", + @SerialName("album_count") val albumCount: Int = 0, + @SerialName("cover_url") val coverUrl: String = "", +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AuthWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AuthWire.kt new file mode 100644 index 00000000..f678f7e1 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AuthWire.kt @@ -0,0 +1,38 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * `POST /api/auth/login` request body. + */ +@Serializable +data class LoginRequestBody( + val username: String, + val password: String, +) + +/** + * `POST /api/auth/login` response. Server emits `{token, user{id, + * username, is_admin}}`. The cookie path actually carries the + * session — the token is duplicated here for clients that prefer + * header-based auth (not us). We use the AuthCookieInterceptor's + * Set-Cookie capture instead. + */ +@Serializable +data class LoginResponseWire( + val token: String = "", + val user: UserWire, +) + +/** + * Mirrors `internal/api/types.go UserView` — the narrow caller-facing + * user shape. `is_admin` is always present in the response but + * tolerated as missing (default false) so older fixtures load. + */ +@Serializable +data class UserWire( + val id: String, + val username: String, + @SerialName("is_admin") val isAdmin: Boolean = false, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/DiscoverWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/DiscoverWire.kt new file mode 100644 index 00000000..bf09d37d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/DiscoverWire.kt @@ -0,0 +1,65 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * One row of `GET /api/lidarr/search`. Mirrors + * `web/src/lib/api/types.ts LidarrSearchResult` / + * `flutter_client/lib/models/lidarr.dart LidarrSearchResult`. + * + * `inLibrary` and `requested` let the UI greyout rows the user can't + * act on (already imported / already awaiting review). All defaults + * empty so missing keys don't throw on decode. + */ +@Serializable +data class LidarrSearchResultWire( + val mbid: String = "", + val name: String = "", + @SerialName("secondary_text") val secondaryText: String = "", + @SerialName("image_url") val imageUrl: String = "", + @SerialName("artist_mbid") val artistMbid: String = "", + @SerialName("album_mbid") val albumMbid: String = "", + @SerialName("in_library") val inLibrary: Boolean = false, + val requested: Boolean = false, +) + +/** + * `GET /api/discover/suggestions` row — an out-of-library artist + * surfaced from ListenBrainz-derived seeds. `imageUrl` is resolved + * on-demand from Lidarr server-side and may be empty. + */ +@Serializable +data class ArtistSuggestionWire( + val mbid: String = "", + val name: String = "", + @SerialName("image_url") val imageUrl: String = "", + val attribution: List = emptyList(), +) + +/** + * Per-seed attribution for an ArtistSuggestion — "because you liked X" + * / "because you played Y". `isLiked = true` means the seed came from + * the user's likes; false means a played-but-not-liked play. + */ +@Serializable +data class SeedContributionWire( + val name: String = "", + @SerialName("is_liked") val isLiked: Boolean = false, +) + +/** + * Body posted to `POST /api/requests`. Mirrors the Flutter `createRequest` + * payload shape. Optional fields are emitted only when non-null + * server-side; matching that here keeps the wire identical. + */ +@Serializable +data class CreateRequestBody( + val kind: String, + @SerialName("lidarr_artist_mbid") val artistMbid: String, + @SerialName("artist_name") val artistName: String, + @SerialName("lidarr_album_mbid") val albumMbid: String? = null, + @SerialName("album_title") val albumTitle: String? = null, + @SerialName("lidarr_track_mbid") val trackMbid: String? = null, + @SerialName("track_title") val trackTitle: String? = null, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/EventsWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/EventsWire.kt new file mode 100644 index 00000000..f901b11b --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/EventsWire.kt @@ -0,0 +1,51 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Wire shapes for `POST /api/events`. The endpoint multiplexes four + * variants on the `type` discriminator field, mirroring + * `flutter_client/lib/api/endpoints/events.dart`. + * + * play_started returns the server-assigned play_event_id (nullable — + * server may suppress under certain conditions); the other three + * variants return 204-equivalent (no body). + */ + +@Serializable +data class PlayStartedRequest( + val type: String = "play_started", + @SerialName("track_id") val trackId: String, + @SerialName("client_id") val clientId: String, + val source: String? = null, +) + +@Serializable +data class PlayStartedResponse( + @SerialName("play_event_id") val playEventId: String? = null, +) + +@Serializable +data class PlayEndedRequest( + val type: String = "play_ended", + @SerialName("play_event_id") val playEventId: String, + @SerialName("duration_played_ms") val durationPlayedMs: Long, +) + +@Serializable +data class PlaySkippedRequest( + val type: String = "play_skipped", + @SerialName("play_event_id") val playEventId: String, + @SerialName("position_ms") val positionMs: Long, +) + +@Serializable +data class PlayOfflineRequest( + val type: String = "play_offline", + @SerialName("track_id") val trackId: String, + @SerialName("client_id") val clientId: String, + val at: String, + @SerialName("duration_played_ms") val durationPlayedMs: Long, + val source: String? = null, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/HistoryWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/HistoryWire.kt new file mode 100644 index 00000000..16f9e676 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/HistoryWire.kt @@ -0,0 +1,27 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * One row of `GET /api/me/history`. Mirrors `internal/api/me.go`'s + * HistoryEvent — a paired (id, played_at, track) tuple. `playedAt` is + * RFC3339; the UI formats it as a relative timestamp. + */ +@Serializable +data class HistoryEventWire( + val id: String = "", + @SerialName("played_at") val playedAt: String = "", + val track: TrackWire, +) + +/** + * Envelope of `GET /api/me/history`. Not the standard `Page` shape + * — history is timestamp-keyed so `total` isn't meaningful, only the + * "more pages exist below" hint. + */ +@Serializable +data class HistoryPageWire( + val events: List = emptyList(), + @SerialName("has_more") val hasMore: Boolean = false, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/HomeIndexWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/HomeIndexWire.kt new file mode 100644 index 00000000..8d0b4fa1 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/HomeIndexWire.kt @@ -0,0 +1,28 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Wire shape of `GET /api/home/index` — five flat slices of entity-ID + * strings, one per Home section. Mirrors + * `flutter_client/lib/models/home_index.dart` (and the server's + * `internal/api/types.go HomeIndexPayload`). + * + * Section name implies entity type; no per-entry type tag is needed: + * - recentlyAddedAlbums → album + * - rediscoverAlbums → album + * - rediscoverArtists → artist + * - mostPlayedTracks → track + * - lastPlayedArtists → artist + * + * All slices default to empty so missing keys don't throw on decode. + */ +@Serializable +data class HomeIndexWire( + @SerialName("recently_added_albums") val recentlyAddedAlbums: List = emptyList(), + @SerialName("rediscover_albums") val rediscoverAlbums: List = emptyList(), + @SerialName("rediscover_artists") val rediscoverArtists: List = emptyList(), + @SerialName("most_played_tracks") val mostPlayedTracks: List = emptyList(), + @SerialName("last_played_artists") val lastPlayedArtists: List = emptyList(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/InviteWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/InviteWire.kt new file mode 100644 index 00000000..fbbb302e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/InviteWire.kt @@ -0,0 +1,29 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Wire shape for one invite row. + * `invited_by`/`redeemed_by` carry user UUIDs. + */ +@Serializable +data class InviteWire( + val token: String = "", + @SerialName("invited_by") val invitedBy: String = "", + val note: String? = null, + @SerialName("created_at") val createdAt: String = "", + @SerialName("expires_at") val expiresAt: String = "", + @SerialName("redeemed_at") val redeemedAt: String? = null, + @SerialName("redeemed_by") val redeemedBy: String? = null, +) + +/** + * Envelope for `GET /api/admin/invites` — `{"invites": [...]}`. + * The single-invite create endpoint returns a bare [InviteWire], so + * the list endpoint is the only place this envelope shows up. + */ +@Serializable +data class AdminInvitesListWire( + val invites: List = emptyList(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/LikedIdsWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/LikedIdsWire.kt new file mode 100644 index 00000000..5ae279c5 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/LikedIdsWire.kt @@ -0,0 +1,19 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Wire shape of `GET /api/likes/ids`. Server returns the full ID set + * of items the caller has liked, partitioned by entity type — used by + * the LikesRepository to do a one-shot refill of `cached_likes` on + * first launch and after sign-in. + * + * Empty defaults so missing keys don't throw on decode. + */ +@Serializable +data class LikedIdsWire( + @SerialName("artist_ids") val artistIds: List = emptyList(), + @SerialName("album_ids") val albumIds: List = emptyList(), + @SerialName("track_ids") val trackIds: List = emptyList(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ListenBrainzStatusWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ListenBrainzStatusWire.kt new file mode 100644 index 00000000..52deaa73 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ListenBrainzStatusWire.kt @@ -0,0 +1,16 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Wire shape for `GET /api/me/listenbrainz` and the responses from + * the two PUT variants. `token` is write-only — the server never + * sends it back, so the field doesn't appear here. + */ +@Serializable +data class ListenBrainzStatusWire( + val enabled: Boolean = false, + @SerialName("token_set") val tokenSet: Boolean = false, + @SerialName("last_scrobbled_at") val lastScrobbledAt: String? = null, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/MyProfileWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/MyProfileWire.kt new file mode 100644 index 00000000..7c811efc --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/MyProfileWire.kt @@ -0,0 +1,21 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Wire shape for `GET /api/me` and the return value of + * `PUT /api/me/profile`. Mirrors + * `flutter_client/lib/models/my_profile.dart`: + * - `display_name` and `email` are nullable; server returns null + * when the user hasn't set them yet (registration only requires + * a username). + */ +@Serializable +data class MyProfileWire( + val id: String = "", + val username: String = "", + @SerialName("display_name") val displayName: String? = null, + val email: String? = null, + @SerialName("is_admin") val isAdmin: Boolean = false, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/PlaylistWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/PlaylistWire.kt new file mode 100644 index 00000000..dd1b977f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/PlaylistWire.kt @@ -0,0 +1,76 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Wire shape of one playlist as returned by `/api/playlists` and + * `/api/playlists/{id}`. Mirrors `internal/api/playlists.go Playlist`. + * + * All string fields default to empty so missing keys don't throw. + * `systemVariant` is nullable — non-null means a system-generated mix. + */ +@Serializable +data class PlaylistWire( + val id: String, + @SerialName("user_id") val userId: String = "", + val name: String = "", + val description: String = "", + @SerialName("is_public") val isPublic: Boolean = false, + @SerialName("system_variant") val systemVariant: String? = null, + @SerialName("track_count") val trackCount: Int = 0, + @SerialName("cover_url") val coverUrl: String = "", + @SerialName("owner_username") val ownerUsername: String = "", + @SerialName("created_at") val createdAt: String = "", + @SerialName("updated_at") val updatedAt: String = "", +) + +/** + * `GET /api/playlists?kind=user|system|all` envelope. Server splits + * the caller's own playlists from public ones owned by others; both + * are surfaced to the UI as separate sections. + */ +@Serializable +data class PlaylistsListWire( + val owned: List = emptyList(), + val public: List = emptyList(), +) + +/** + * One row inside a `PlaylistDetailWire.tracks`. `trackId` / `albumId` + * / `artistId` / `streamUrl` are nullable because the upstream track + * may have been removed from the library while the row stays in the + * playlist with its display fields preserved. + */ +@Serializable +data class PlaylistTrackWire( + val position: Int = 0, + @SerialName("track_id") val trackId: String? = null, + val title: String = "", + @SerialName("album_id") val albumId: String? = null, + @SerialName("album_title") val albumTitle: String = "", + @SerialName("artist_id") val artistId: String? = null, + @SerialName("artist_name") val artistName: String = "", + @SerialName("duration_sec") val durationSec: Int = 0, + @SerialName("stream_url") val streamUrl: String? = null, +) + +/** + * `GET /api/playlists/{id}` envelope. Server flattens the playlist + * fields at the root level and adds a `tracks` array — matches the + * Flutter fromJson which calls `Playlist.fromJson(j)` on the same map + * that owns `tracks`. + */ +@Serializable +data class PlaylistDetailWire( + val id: String, + @SerialName("user_id") val userId: String = "", + val name: String = "", + val description: String = "", + @SerialName("is_public") val isPublic: Boolean = false, + @SerialName("system_variant") val systemVariant: String? = null, + @SerialName("track_count") val trackCount: Int = 0, + @SerialName("cover_url") val coverUrl: String = "", + @SerialName("owner_username") val ownerUsername: String = "", + val tracks: List = emptyList(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/QuarantineMineWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/QuarantineMineWire.kt new file mode 100644 index 00000000..ac77d37c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/QuarantineMineWire.kt @@ -0,0 +1,29 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * One row of `GET /api/quarantine/mine`. Mirrors + * `flutter_client/lib/models/quarantine_mine.dart QuarantineMineRow` + * (web `LidarrQuarantineMineRow`). + * + * Reason values: `bad_rip` / `wrong_file` / `wrong_tags` / `duplicate` + * / `other`. Stored as a plain string on the wire; the UI does the + * display-label mapping inline rather than via an enum to match + * Flutter's switch-on-string pattern. + */ +@Serializable +data class QuarantineMineWire( + @SerialName("track_id") val trackId: String = "", + val reason: String = "other", + val notes: String? = null, + @SerialName("created_at") val createdAt: String = "", + @SerialName("track_title") val trackTitle: String = "", + @SerialName("track_duration_ms") val trackDurationMs: Int = 0, + @SerialName("album_id") val albumId: String = "", + @SerialName("album_title") val albumTitle: String = "", + @SerialName("album_cover_art_path") val albumCoverArtPath: String? = null, + @SerialName("artist_id") val artistId: String = "", + @SerialName("artist_name") val artistName: String = "", +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/RadioResponseWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/RadioResponseWire.kt new file mode 100644 index 00000000..b9dbbf4d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/RadioResponseWire.kt @@ -0,0 +1,15 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.Serializable + +/** + * Response envelope for `GET /api/radio?seed_track=&limit=`. + * The seed is returned at index 0 followed by up to limit-1 + * weighted-shuffle picks scored by the server's recommendation engine. + * Mirrors Flutter's `RadioApi.seedTrack` parse — the `tracks` key is + * the only field in the envelope. + */ +@Serializable +data class RadioResponseWire( + val tracks: List = emptyList(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/RequestWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/RequestWire.kt new file mode 100644 index 00000000..faa4973c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/RequestWire.kt @@ -0,0 +1,36 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Wire shape of `requestView` from `internal/api/requests.go` — the + * row returned by both `GET /api/requests` (caller's own requests) and + * `GET /api/admin/requests` (cross-user admin view). Mirrors + * `flutter_client/lib/models/admin_request.dart AdminRequest`. + * + * Status values: `pending` / `approved` / `rejected` / `completed` / + * `failed`. Kind values: `artist` / `album` / `track`. + * + * `matched_*_id` are set after a completed request ingests into the + * local library; the user-side "Listen" affordance picks the most + * specific one (track → album → artist). + */ +@Serializable +data class RequestWire( + val id: String, + @SerialName("user_id") val userId: String = "", + val status: String = "pending", + val kind: String = "artist", + @SerialName("artist_name") val artistName: String = "", + @SerialName("album_title") val albumTitle: String? = null, + @SerialName("track_title") val trackTitle: String? = null, + @SerialName("requested_at") val requestedAt: String = "", + @SerialName("decided_at") val decidedAt: String? = null, + val notes: String? = null, + @SerialName("imported_album_count") val importedAlbumCount: Int = 0, + @SerialName("imported_track_count") val importedTrackCount: Int = 0, + @SerialName("matched_track_id") val matchedTrackId: String? = null, + @SerialName("matched_album_id") val matchedAlbumId: String? = null, + @SerialName("matched_artist_id") val matchedArtistId: String? = null, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SearchWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SearchWire.kt new file mode 100644 index 00000000..4b8e0bc6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SearchWire.kt @@ -0,0 +1,48 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.Serializable + +/** + * Server's `Page[T]` envelope for search results — three concrete + * variants since kotlinx.serialization's generic support is fine but + * the three call sites are easier to read with explicit types. + * + * `total` reflects the full match count for that facet (not just + * what fit in `items` at this offset/limit). The mobile UI only + * walks the first page today; infinite scroll per facet is a future + * enhancement. + */ +@Serializable +data class PagedArtistsWire( + val items: List = emptyList(), + val total: Int = 0, + val limit: Int = 0, + val offset: Int = 0, +) + +@Serializable +data class PagedAlbumsWire( + val items: List = emptyList(), + val total: Int = 0, + val limit: Int = 0, + val offset: Int = 0, +) + +@Serializable +data class PagedTracksWire( + val items: List = emptyList(), + val total: Int = 0, + val limit: Int = 0, + val offset: Int = 0, +) + +/** + * `GET /api/search` response envelope. Three facets keyed by entity + * type, each independently paged. + */ +@Serializable +data class SearchResponseWire( + val artists: PagedArtistsWire = PagedArtistsWire(), + val albums: PagedAlbumsWire = PagedAlbumsWire(), + val tracks: PagedTracksWire = PagedTracksWire(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SyncResponseWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SyncResponseWire.kt new file mode 100644 index 00000000..23bbc822 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SyncResponseWire.kt @@ -0,0 +1,85 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Sync-specific shape for one artist row. Distinct from `ArtistWire` + * (the regular API's display shape) — the sync endpoint returns the + * raw DB row including `mbid` and the resolved cover paths, while the + * regular API returns derived display fields (album_count, cover_url). + */ +@Serializable +data class SyncArtistWire( + val id: String, + val name: String = "", + @SerialName("sort_name") val sortName: String = "", + val mbid: String? = null, + @SerialName("artist_thumb_path") val artistThumbPath: String? = null, + @SerialName("artist_fanart_path") val artistFanartPath: String? = null, +) + +@Serializable +data class SyncAlbumWire( + val id: String, + @SerialName("artist_id") val artistId: String, + val title: String = "", + @SerialName("sort_title") val sortTitle: String = "", + @SerialName("release_date") val releaseDate: String? = null, + @SerialName("cover_art_path") val coverArtPath: String? = null, + val mbid: String? = null, +) + +@Serializable +data class SyncTrackWire( + val id: String, + @SerialName("album_id") val albumId: String, + @SerialName("artist_id") val artistId: String, + val title: String = "", + @SerialName("duration_ms") val durationMs: Int = 0, + @SerialName("track_number") val trackNumber: Int? = null, + @SerialName("disc_number") val discNumber: Int? = null, + @SerialName("file_path") val filePath: String? = null, + @SerialName("file_format") val fileFormat: String? = null, + val genre: String? = null, +) + +/** + * `/api/library/sync` upsert bucket. Server batches all entity types + * in one shot per cursor advance. v1 native client only consumes + * artist/album/track — likes get their own `/api/likes/ids` refresh + * path, and playlists pull on the Playlists screen visit. + */ +@Serializable +data class SyncUpsertsWire( + val artist: List = emptyList(), + val album: List = emptyList(), + val track: List = emptyList(), +) + +/** + * `/api/library/sync` delete bucket. Each list is a flat ID array; + * the entity DAOs delete those rows. + */ +@Serializable +data class SyncDeletesWire( + val artist: List = emptyList(), + val album: List = emptyList(), + val track: List = emptyList(), +) + +/** + * Successful sync response body. `cursor` advances to the new + * watermark; persist it back to sync_metadata so the next sync resumes + * from there. The empty-delta case is signaled by HTTP 204 — the + * SyncController handles that without parsing. + * + * 410 Gone means the cursor is too old to be useful; SyncController + * destructively wipes the cache and retries from cursor=0. + */ +@Serializable +data class SyncResponseWire( + val cursor: Long, + val upserts: SyncUpsertsWire = SyncUpsertsWire(), + val deletes: SyncDeletesWire = SyncDeletesWire(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SystemPlaylistsStatusWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SystemPlaylistsStatusWire.kt new file mode 100644 index 00000000..cae3e7a5 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SystemPlaylistsStatusWire.kt @@ -0,0 +1,14 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Wire shape for `GET /api/me/system-playlists-status`. + */ +@Serializable +data class SystemPlaylistsStatusWire( + @SerialName("in_flight") val inFlight: Boolean = false, + @SerialName("last_run_at") val lastRunAt: String? = null, + @SerialName("last_error") val lastError: String? = null, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/TrackWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/TrackWire.kt new file mode 100644 index 00000000..dba0ab70 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/TrackWire.kt @@ -0,0 +1,29 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Wire shape for `TrackRef` as the server emits it. Mirrors + * `flutter_client/lib/models/track.dart`'s `TrackRef.fromJson` + * field-for-field; the keys are snake_case because the server is Go + * (json:"album_id" etc.). + * + * Mappers in feature packages translate this to the domain TrackRef + * data class and the `cached_tracks` Room entity. Default values match + * the Flutter Dart-side "?? 0" / "?? ''" fallbacks for forward-compat + * with sparse server responses. + */ +@Serializable +data class TrackWire( + val id: String, + val title: String, + @SerialName("album_id") val albumId: String, + @SerialName("album_title") val albumTitle: String = "", + @SerialName("artist_id") val artistId: String, + @SerialName("artist_name") val artistName: String = "", + @SerialName("track_number") val trackNumber: Int? = null, + @SerialName("disc_number") val discNumber: Int? = null, + @SerialName("duration_sec") val durationSec: Int = 0, + @SerialName("stream_url") val streamUrl: String = "", +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/UpdateInfoWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/UpdateInfoWire.kt new file mode 100644 index 00000000..fe772cd6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/UpdateInfoWire.kt @@ -0,0 +1,15 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Wire shape for `GET /api/client/version`. Defaults match Flutter: + * apk_url falls back to `/api/client/apk` if the server omits it. + */ +@Serializable +data class UpdateInfoWire( + val version: String = "", + @SerialName("apk_url") val apkUrl: String = "/api/client/apk", + @SerialName("size_bytes") val sizeBytes: Long = 0, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/DetailSeedCache.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/DetailSeedCache.kt new file mode 100644 index 00000000..953ff536 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/DetailSeedCache.kt @@ -0,0 +1,52 @@ +package com.fabledsword.minstrel.nav + +import androidx.compose.runtime.staticCompositionLocalOf +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.models.ArtistRef +import com.fabledsword.minstrel.models.PlaylistRef +import javax.inject.Inject +import javax.inject.Singleton + +/** + * In-memory hand-off for detail-screen "seed" data — the lightweight + * Ref the caller already has when navigating to AlbumDetail / + * ArtistDetail / PlaylistDetail. The detail VM peeks the seed on + * init so the AppBar header (title / cover / counts) renders + * immediately, before the network round-trip completes. + * + * Mirrors Flutter's go_router `extra:` parameter pattern. Bounded + * per-bucket via insertion-order LRU so back/forward navigation + * doesn't grow unbounded. + */ +@Singleton +class DetailSeedCache @Inject constructor() { + private val albums = lru(MAX_ENTRIES) + private val artists = lru(MAX_ENTRIES) + private val playlists = lru(MAX_ENTRIES) + + @Synchronized fun stashAlbum(ref: AlbumRef) { albums[ref.id] = ref } + @Synchronized fun stashArtist(ref: ArtistRef) { artists[ref.id] = ref } + @Synchronized fun stashPlaylist(ref: PlaylistRef) { playlists[ref.id] = ref } + + @Synchronized fun peekAlbum(id: String): AlbumRef? = albums[id] + @Synchronized fun peekArtist(id: String): ArtistRef? = artists[id] + @Synchronized fun peekPlaylist(id: String): PlaylistRef? = playlists[id] + + private companion object { + private const val MAX_ENTRIES = 32 + private const val LOAD_FACTOR = 0.75f + private fun lru(max: Int): MutableMap = + object : LinkedHashMap(max, LOAD_FACTOR, true) { + override fun removeEldestEntry(eldest: Map.Entry) = size > max + } + } +} + +/** + * CompositionLocal carrying the app's [DetailSeedCache]. Provided + * at the [com.fabledsword.minstrel.MainActivity] root so any screen + * can stash a seed before calling `navController.navigate(...)`. + */ +val LocalDetailSeedCache = staticCompositionLocalOf { + error("DetailSeedCache not provided — wrap content in CompositionLocalProvider") +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/HeroScopes.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/HeroScopes.kt new file mode 100644 index 00000000..db2b310f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/HeroScopes.kt @@ -0,0 +1,27 @@ +package com.fabledsword.minstrel.nav + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * Scope hand-off for shared-element (Hero) transitions between + * MiniPlayer and NowPlaying covers. + * + * [LocalSharedTransitionScope] is provided once by the + * `SharedTransitionLayout` wrapping the NavHost. + * [LocalAnimatedContentScope] is re-provided per `composable` + * destination since each route has its own AnimatedContentScope. + * + * Both are nullable so widgets degrade gracefully (no hero, but + * still render) outside a SharedTransitionLayout — useful in + * previews and tests. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +val LocalSharedTransitionScope = staticCompositionLocalOf { null } + +val LocalAnimatedContentScope = staticCompositionLocalOf { null } + +/** Shared-element key for the MiniPlayer → NowPlaying cover hero. */ +const val HERO_KEY_NOW_PLAYING_COVER = "now-playing-cover" diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt new file mode 100644 index 00000000..3fe330d4 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt @@ -0,0 +1,212 @@ +package com.fabledsword.minstrel.nav + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import com.fabledsword.minstrel.admin.ui.AdminLandingScreen +import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen +import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen +import com.fabledsword.minstrel.admin.ui.AdminUsersScreen +import com.fabledsword.minstrel.auth.ui.LoginScreen +import com.fabledsword.minstrel.auth.ui.ServerUrlScreen +import com.fabledsword.minstrel.discover.ui.DiscoverScreen +import com.fabledsword.minstrel.home.ui.HomeScreen +import com.fabledsword.minstrel.library.ui.AlbumDetailScreen +import com.fabledsword.minstrel.library.ui.ArtistDetailScreen +import com.fabledsword.minstrel.library.ui.LibraryScreen +import com.fabledsword.minstrel.player.ui.NowPlayingScreen +import com.fabledsword.minstrel.player.ui.QueueScreen +import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen +import com.fabledsword.minstrel.playlists.ui.PlaylistsListScreen +import com.fabledsword.minstrel.requests.ui.RequestsScreen +import com.fabledsword.minstrel.search.ui.SearchScreen +import com.fabledsword.minstrel.settings.ui.SettingsScreen +import com.fabledsword.minstrel.shared.widgets.ShellScaffold + +/** + * App NavHost. Mirrors the Flutter split between shell routes (wrapped + * by `_ShellWithPlayerBar` → banners + screen + MiniPlayer) and + * top-level non-shell routes (NowPlaying / Queue / unauthenticated). + * + * Per-route transition: only NowPlaying gets the modal slide-up; the + * rest use the default fade. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun MinstrelNavGraph( + navController: NavHostController, + startDestination: Any, + modifier: Modifier = Modifier, +) { + val expandPlayer = { navController.navigate(NowPlaying) } + SharedTransitionLayout(modifier = modifier.fillMaxSize()) { + CompositionLocalProvider(LocalSharedTransitionScope provides this) { + NavHost( + navController = navController, + startDestination = startDestination, + modifier = Modifier.fillMaxSize(), + ) { + inShellTopLevel(navController, expandPlayer) + inShellDetail(navController, expandPlayer) + outsideShell(navController) + } + } + } +} + +private fun NavGraphBuilder.inShellTopLevel( + navController: NavHostController, + expandPlayer: () -> Unit, +) { + composable { + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + HomeScreen(navController = navController) + } + } + } + composable { + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + LibraryScreen(navController = navController) + } + } + } + composable { + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + SearchScreen(navController = navController) + } + } + } + composable { + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + DiscoverScreen(navController = navController) + } + } + } + composable { + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + PlaylistsListScreen(navController = navController) + } + } + } + composable { + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + SettingsScreen(navController = navController) + } + } + } + composable { + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + AdminLandingScreen(navController = navController) + } + } + } + composable { + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + RequestsScreen(navController = navController) + } + } + } +} + +/** + * Wraps content with [LocalAnimatedContentScope] sourced from the + * enclosing `composable<>` lambda's [AnimatedContentScope] receiver + * so the MiniPlayer / NowPlaying covers can apply + * `Modifier.sharedElement(...)` for the Hero transition. + * + * Must be invoked from inside a `composable<>` lambda — its `this` + * is the [AnimatedContentScope]. + */ +@Composable +private fun AnimatedContentScope.WithAnimatedScope( + content: @Composable () -> Unit, +) { + CompositionLocalProvider(LocalAnimatedContentScope provides this) { + content() + } +} + +private fun NavGraphBuilder.inShellDetail( + navController: NavHostController, + expandPlayer: () -> Unit, +) { + composable { + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + AlbumDetailScreen(navController = navController) + } + } + } + composable { + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + ArtistDetailScreen(navController = navController) + } + } + } + composable { + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + PlaylistDetailScreen(navController = navController) + } + } + } + composable { + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + AdminRequestsScreen(navController = navController) + } + } + } + composable { + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + AdminQuarantineScreen(navController = navController) + } + } + } + composable { + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + AdminUsersScreen(navController = navController) + } + } + } +} + +private fun NavGraphBuilder.outsideShell(navController: NavHostController) { + composable( + // Slide up from the bottom on enter; back down on dismiss. + // Mirrors the Flutter CustomTransitionPage (280ms easeOutCubic). + enterTransition = { slideInVertically(initialOffsetY = { it }) }, + exitTransition = { slideOutVertically(targetOffsetY = { it }) }, + popEnterTransition = { slideInVertically(initialOffsetY = { it }) }, + popExitTransition = { slideOutVertically(targetOffsetY = { it }) }, + ) { + WithAnimatedScope { + NowPlayingScreen(navController = navController) + } + } + composable { QueueScreen(navController = navController) } + composable { ServerUrlScreen(navController = navController) } + composable { LoginScreen(navController = navController) } +} + diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt new file mode 100644 index 00000000..24509fe6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt @@ -0,0 +1,31 @@ +package com.fabledsword.minstrel.nav + +import kotlinx.serialization.Serializable + +// ── In-shell top-level destinations (reachable from MainAppBarActions +// icons + kebab) ────────────────────────────────────────────────────── + +@Serializable data object Home +@Serializable data object Library +@Serializable data object Search +@Serializable data object Discover +@Serializable data object Playlists +@Serializable data object Settings +@Serializable data object Admin +@Serializable data object Requests + +// ── In-shell detail / push-on-top destinations ──────────────────────── + +@Serializable data class AlbumDetail(val id: String) +@Serializable data class ArtistDetail(val id: String) +@Serializable data class PlaylistDetail(val id: String) +@Serializable data object AdminRequests +@Serializable data object AdminQuarantine +@Serializable data object AdminUsers + +// ── Outside-shell full-screen routes ────────────────────────────────── + +@Serializable data object NowPlaying // slide-up modal transition +@Serializable data object Queue // standard push +@Serializable data object ServerUrl // unauthenticated layer +@Serializable data object Login // unauthenticated layer diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/CoverPrefetcher.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/CoverPrefetcher.kt new file mode 100644 index 00000000..5b80d260 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/CoverPrefetcher.kt @@ -0,0 +1,61 @@ +package com.fabledsword.minstrel.player + +import android.content.Context +import coil3.SingletonImageLoader +import coil3.request.ImageRequest +import com.fabledsword.minstrel.di.ApplicationScope +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Warms Coil's memory + disk cache with the *next* queued track's + * cover so when the player advances, the on-screen AsyncImage and + * the NowPlaying dominant-color extraction both hit a cache instead + * of waiting on the network. + * + * Pulls the next cover URL out of [PlayerController.uiState] every + * time the queue or current-index changes, deduplicates, and fires + * a Coil enqueue. Fire-and-forget — the request's own Disposable is + * discarded; Coil's cache write happens on the loader's background + * thread regardless of subscription. + * + * Constructed at app launch via the construct-the-singleton trick + * in [com.fabledsword.minstrel.MinstrelApplication]. + */ +@Singleton +class CoverPrefetcher @Inject constructor( + @ApplicationContext private val context: Context, + private val playerController: PlayerController, + @ApplicationScope private val scope: CoroutineScope, +) { + init { + scope.launch { + playerController.uiState + .mapNotNull { it.peekNextCoverUrl() } + .filter { it.isNotEmpty() } + .distinctUntilChanged() + .collect(::prefetch) + } + } + + private fun prefetch(url: String) { + val request = ImageRequest.Builder(context) + .data(url) + .build() + SingletonImageLoader.get(context).enqueue(request) + } +} + +/** + * Cover URL for `queueIndex + 1`, or null when the player is at the + * end of the queue / has no queue. Empty string is preserved so the + * caller can filter it; we don't want to prefetch a placeholder URL. + */ +private fun PlayerUiState.peekNextCoverUrl(): String? = + queue.getOrNull(queueIndex + 1)?.coverUrl diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt new file mode 100644 index 00000000..fba943f6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt @@ -0,0 +1,64 @@ +package com.fabledsword.minstrel.player + +import android.content.Intent +import androidx.media3.common.Player +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +/** + * The app's foreground media-playback service. Replaces what the Flutter + * audio_service plugin wrapped — Media3 owns the foreground lifecycle, + * the MediaSession, the notification card, lock-screen surface, BT/AVRCP + * routing, Pixel Watch tile, and Android Auto adapter natively. + * + * Manifest declares this with `foregroundServiceType="mediaPlayback"` + * plus the standard `MediaSessionService` intent-filter — Media3's + * MediaButtonReceiver is registered automatically by the library, no + * manual receiver class needed. + * + * Lifecycle: + * - onCreate: build the ExoPlayer + MediaSession once. + * - onGetSession: return the live session to any binding controller + * (system UI media card, Wear OS companion, MediaController3 clients). + * - onTaskRemoved: if the user swipes the app away, keep playing when + * audio is active (standard media-app behavior — music shouldn't + * die because the app left recents); otherwise stop the service so + * the lingering notification clears. + * - onDestroy: release the session + player. + */ +@AndroidEntryPoint +class MinstrelPlayerService : MediaSessionService() { + + @Inject lateinit var playerFactory: PlayerFactory + + private var mediaSession: MediaSession? = null + + override fun onCreate() { + super.onCreate() + val player = playerFactory.build() + mediaSession = MediaSession.Builder(this, player).build() + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = + mediaSession + + override fun onTaskRemoved(rootIntent: Intent?) { + val player = mediaSession?.player ?: return super.onTaskRemoved(rootIntent) + val activelyPlaying = player.playWhenReady && player.playbackState != Player.STATE_ENDED + if (!activelyPlaying) { + stopSelf() + } + super.onTaskRemoved(rootIntent) + } + + override fun onDestroy() { + mediaSession?.run { + player.release() + release() + } + mediaSession = null + super.onDestroy() + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayEventsReporter.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayEventsReporter.kt new file mode 100644 index 00000000..f2f076fc --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayEventsReporter.kt @@ -0,0 +1,247 @@ +package com.fabledsword.minstrel.player + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import com.fabledsword.minstrel.api.endpoints.EventsApi +import com.fabledsword.minstrel.auth.AuthStore +import com.fabledsword.minstrel.cache.mutations.MutationQueue +import com.fabledsword.minstrel.cache.mutations.PlayOfflinePayload +import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.models.wire.PlayEndedRequest +import com.fabledsword.minstrel.models.wire.PlaySkippedRequest +import com.fabledsword.minstrel.models.wire.PlayStartedRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import retrofit2.Retrofit +import retrofit2.create +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +private const val COMPLETION_TOLERANCE_MS = 3_000L + +/** + * Mobile play-event lifecycle reporter. Closes the parity gap where + * Android-played tracks never reached the server — without this, + * History stayed empty for mobile listening, recommendation scoring + * was biased toward web/desktop, ListenBrainz scrobbles never fired, + * and system-playlist rotation didn't advance on mobile activity. + * + * State machine over (current track id, playing) observed against + * [PlayerController.uiState]. Mirrors Flutter's + * `play_events_reporter.dart`. The skip-vs-ended classification uses + * a 3-second tolerance to avoid marking naturally-finished in-queue + * tracks as skipped (which would dilute the recommendation + * skip-ratio). + * + * Live calls go through [EventsApi]; failures fall through to the + * offline [MutationQueue] so a flaky connection never loses a play. + * App-background / detached states close the current play durably + * via the offline queue (a fire-and-forget POST during teardown is + * unreliable; the queue survives a process kill). + */ +@Singleton +class PlayEventsReporter @Inject constructor( + private val playerController: PlayerController, + private val authStore: AuthStore, + private val mutationQueue: MutationQueue, + @ApplicationScope private val scope: CoroutineScope, + retrofit: Retrofit, +) : DefaultLifecycleObserver { + + private val api: EventsApi = retrofit.create() + + private var curTrackId: String? = null + private var curStartedAt: Instant? = null + private var curSource: String? = null + private var curLastPositionMs: Long = 0 + private var curDurationMs: Long = 0 + private var curReachedEnd: Boolean = false + + // Server play_event_id when the live play_started succeeded; + // null if start failed/offline — then the close is captured into + // the offline mutation queue instead of a live ended/skipped. + private var openPlayEventId: String? = null + + private var prevTrackId: String? = null + + init { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + scope.launch { + playerController.uiState.collect { state -> + evaluate( + trackId = state.currentTrack?.id, + playing = state.isPlaying, + positionMs = state.positionMs, + durationMs = state.durationMs, + source = state.currentSource, + ) + } + } + } + + private fun evaluate( + trackId: String?, + playing: Boolean, + positionMs: Long, + durationMs: Long, + source: String?, + ) { + // Track-change → close the prior tracked play. + if (trackId != prevTrackId && curTrackId != null) { + closeCurrent(viaOffline = false) + } + + // Entered playing for a new track → begin tracking it. + if (trackId != null && playing && curTrackId != trackId) { + beginTrack(trackId = trackId, source = source, durationMs = durationMs) + } + + // Advance the tracked play's progress. The gate `trackId == + // curTrackId` keeps a finishing track's last-known position + // intact across the moment the listener fires for a track + // change (where currentTrack is briefly the new one). + if (curTrackId != null && trackId == curTrackId) { + curLastPositionMs = positionMs + if (durationMs > 0) { + curDurationMs = durationMs + if (positionMs >= curDurationMs - COMPLETION_TOLERANCE_MS) { + curReachedEnd = true + } + } + } + + prevTrackId = trackId + } + + private fun beginTrack(trackId: String, source: String?, durationMs: Long) { + curTrackId = trackId + curStartedAt = Clock.System.now() + curSource = source + curLastPositionMs = 0 + curDurationMs = durationMs + curReachedEnd = false + openPlayEventId = null + startLive(trackId = trackId, source = source) + } + + private fun startLive(trackId: String, source: String?) { + val cid = resolveClientId() ?: return + scope.launch { + try { + val response = api.playStarted( + PlayStartedRequest( + trackId = trackId, + clientId = cid, + source = source.takeIf { !it.isNullOrEmpty() }, + ), + ) + if (curTrackId == trackId) { + openPlayEventId = response.playEventId + } + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + // Offline / flaky — openPlayEventId stays null; close + // path will enqueue the completed play for replay. + } + } + } + + private fun closeCurrent(viaOffline: Boolean) { + val trackId = curTrackId + val startedAt = curStartedAt + if (trackId == null || startedAt == null) { + resetCurrent() + return + } + val reached = curReachedEnd + val lastPos = curLastPositionMs + val durationMs = if (reached && curDurationMs > 0) curDurationMs else lastPos + val source = curSource + val id = openPlayEventId + + if (!viaOffline && id != null) { + scope.launch { + try { + if (reached) { + api.playEnded( + PlayEndedRequest( + playEventId = id, + durationPlayedMs = durationMs, + ), + ) + } else { + api.playSkipped( + PlaySkippedRequest( + playEventId = id, + positionMs = lastPos, + ), + ) + } + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + enqueueOffline(trackId, startedAt, source, durationMs) + } + } + } else { + enqueueOffline(trackId, startedAt, source, durationMs) + } + resetCurrent() + } + + private fun resetCurrent() { + curTrackId = null + curStartedAt = null + curSource = null + curLastPositionMs = 0 + curDurationMs = 0 + curReachedEnd = false + openPlayEventId = null + } + + private fun enqueueOffline( + trackId: String, + startedAt: Instant, + source: String?, + durationPlayedMs: Long, + ) { + val cid = resolveClientId() ?: return + scope.launch { + mutationQueue.enqueuePlayOffline( + PlayOfflinePayload( + trackId = trackId, + clientId = cid, + atIso = startedAt.toString(), + durationPlayedMs = durationPlayedMs, + source = source.takeIf { !it.isNullOrEmpty() }, + ), + ) + } + } + + /** + * Returns the persisted client id, generating + persisting one on + * first read. The setter is fire-and-forget on the app scope so + * this stays synchronous; the in-memory StateFlow update is + * immediate so the next caller observes the new value. + */ + private fun resolveClientId(): String? { + authStore.clientId.value?.let { return it } + val fresh = UUID.randomUUID().toString() + authStore.setClientId(fresh) + return fresh + } + + // ── Lifecycle: durable-close on app background / detach ──────── + + override fun onStop(owner: LifecycleOwner) { + if (curTrackId != null) { + closeCurrent(viaOffline = true) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlaybackErrorReporter.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlaybackErrorReporter.kt new file mode 100644 index 00000000..12a6e775 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlaybackErrorReporter.kt @@ -0,0 +1,68 @@ +package com.fabledsword.minstrel.player + +import com.fabledsword.minstrel.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +private const val DEBOUNCE_MS = 2_000L + +/** + * Surfaces ExoPlayer track-load failures (404 / decoder failure / + * premature EOS / network drop) as user-visible snackbar messages. + * + * Without this the player just silently skips the dead track — the + * worst kind of bug, because the user can't tell "this file is + * broken" from "the app is flaky". + * + * Pattern mirrors Flutter's `playback_error_reporter.dart`: collect + * per-error events from [PlayerController.playbackErrorEvents], + * debounce in a 2s window, and emit "Couldn't play 'X' — skipping" + * for a single error or "Skipped N unplayable tracks" when a burst + * lands inside the window. Stops the user from seeing a stack of N + * toasts on a network blip that fails N tracks in a row. + * + * ShellScaffold collects [messages] into its existing snackbar host + * so the reporter doesn't need its own UI surface. Constructed at + * app launch via the construct-the-singleton trick in + * [com.fabledsword.minstrel.MinstrelApplication]. + */ +@Singleton +class PlaybackErrorReporter @Inject constructor( + private val playerController: PlayerController, + @ApplicationScope private val scope: CoroutineScope, +) { + private val outChannel = Channel(Channel.BUFFERED) + + /** Coalesced user-facing snackbar text. Subscribers should show in a SnackbarHost. */ + val messages: Flow = outChannel.receiveAsFlow() + + init { + scope.launch { + val buffer = mutableListOf() + var debounceJob: kotlinx.coroutines.Job? = null + playerController.playbackErrorEvents.collect { title -> + buffer.add(title) + debounceJob?.cancel() + debounceJob = scope.launch { + delay(DEBOUNCE_MS) + if (buffer.isEmpty()) return@launch + val n = buffer.size + val first = buffer.first() + buffer.clear() + val msg = if (n == 1) { + "Couldn't play \"$first\" — skipping" + } else { + "Skipped $n unplayable tracks" + } + outChannel.trySend(msg) + } + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt new file mode 100644 index 00000000..6a172a0d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -0,0 +1,298 @@ +package com.fabledsword.minstrel.player + +import android.content.ComponentName +import android.content.Context +import android.os.Bundle +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.shared.resolveServerUrl +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * UI-facing facade over Media3's [MediaController] — the IPC client to + * [MinstrelPlayerService]'s [androidx.media3.session.MediaSession]. + * + * ViewModels collect [uiState] (a StateFlow projection of the player's + * current track + transport state + position) and call the + * transport methods. The MediaController is connected asynchronously in + * the singleton's init block; until it's ready, transport methods are + * no-ops and uiState carries the default (empty) snapshot. + * + * Why a dedicated facade vs ViewModels using MediaController directly: + * lifecycle. ViewModels come and go with their host Composables; one + * MediaController for the process is the right shape, and centralising + * the StateFlow projection means each ViewModel doesn't have to attach + * its own Player.Listener. + */ +// PlayerController is the UI-side facade over the full Media3 surface +// (transport + queue + radio + lifecycle). All the public methods are +// legitimately part of one cohesive controller — splitting into +// "TransportController", "QueueController", etc. would scatter related +// state for no gain. Suppress the per-class function-count cap rather +// than fragment. +@Suppress("TooManyFunctions") +@Singleton +class PlayerController @Inject constructor( + @ApplicationContext private val context: Context, + @ApplicationScope private val scope: CoroutineScope, + private val radio: RadioController, +) { + private val sessionToken = + SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java)) + + private var mediaController: MediaController? = null + + private val uiStateInternal = MutableStateFlow(PlayerUiState()) + val uiState: StateFlow = uiStateInternal.asStateFlow() + + /** + * Per-error events for [com.fabledsword.minstrel.player.PlaybackErrorReporter]. + * Emits the failing track's title (or `"Track"` when unknown) each + * time Media3's `onPlayerError` fires — not bound to the lifetime of + * any UI subscriber so a torn-down screen doesn't drop events. + * Conflated channel so backpressure can't stall the player loop. + */ + private val playbackErrorEventsChannel = Channel(Channel.CONFLATED) + val playbackErrorEvents: Flow = playbackErrorEventsChannel.receiveAsFlow() + + /** + * Stable queue snapshot kept in sync with the player's MediaItems — + * the player's own getMediaItem(index) returns Media3 types; we keep + * our domain TrackRef list alongside for the UiState projection. + */ + private var queueRefs: List = emptyList() + + init { + scope.launch { connectAndObserve() } + } + + // ── Transport (no-op until the controller is connected) ────────────── + + fun play() { mediaController?.play() } + fun pause() { mediaController?.pause() } + fun seekTo(positionMs: Long) { mediaController?.seekTo(positionMs) } + fun skipToNext() { mediaController?.seekToNextMediaItem() } + fun skipToPrevious() { mediaController?.seekToPreviousMediaItem() } + + /** Flip shuffle on/off. Media3 emits onEvents → uiState reflects. */ + fun toggleShuffle() { + val controller = mediaController ?: return + controller.shuffleModeEnabled = !controller.shuffleModeEnabled + } + + /** + * Cycles the player's repeat mode: off → all → one → off. Mirrors + * Flutter's `cycleRepeat`. The wire order (none/all/one) matches + * what the Now Playing icon swap expects — Lucide.Repeat for off + * and all, Lucide.Repeat1 for one. + */ + fun cycleRepeat() { + val controller = mediaController ?: return + controller.repeatMode = when (controller.repeatMode) { + Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL + Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE + else -> Player.REPEAT_MODE_OFF + } + } + + /** Jump to [index] in the current queue. No-op if index is out of range. */ + fun seekToIndex(index: Int) { + val controller = mediaController ?: return + if (index !in queueRefs.indices) return + controller.seekTo(index, /* positionMs = */ 0L) + controller.play() + } + + /** + * Replace the queue with [tracks] and start playing from [initialIndex]. + * [source] tags the queue with its origin (e.g. "for_you") so the + * server-side rotation reporter can advance it; carried in + * MediaItem extras. + */ + fun setQueue(tracks: List, initialIndex: Int = 0, source: String? = null) { + val controller = mediaController ?: return + queueRefs = tracks + val items = tracks.map { it.toMediaItem(source) } + controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L) + controller.prepare() + controller.play() + } + + /** + * Insert [track] into the queue immediately after the currently + * playing item. Does NOT auto-play — playback state is preserved + * (Media3's [Player.addMediaItem] doesn't disturb the current + * window). No-op when the player isn't connected or the track has + * no streamUrl. + */ + fun playNext(track: TrackRef) { + val controller = mediaController ?: return + if (track.streamUrl.isEmpty()) return + val insertAt = (controller.currentMediaItemIndex + 1).coerceAtLeast(0) + queueRefs = queueRefs.toMutableList().apply { add(insertAt, track) } + controller.addMediaItem(insertAt, track.toMediaItem(source = null)) + } + + /** + * Append [track] to the end of the queue. Does NOT auto-play. + * Same streamUrl + connectivity guards as [playNext]. + */ + fun enqueue(track: TrackRef) { + val controller = mediaController ?: return + if (track.streamUrl.isEmpty()) return + queueRefs = queueRefs + track + controller.addMediaItem(track.toMediaItem(source = null)) + } + + /** + * Seed a fresh radio queue from [trackId] and start playback. + * Replaces the existing queue. The `source` tag is "radio:" + * so the server-side rotation reporter can distinguish radio + * plays from album / playlist plays. No-op on an empty server + * response; throws on transport / non-2xx for the caller to + * surface in a snackbar. + */ + suspend fun startRadio(trackId: String) { + val tracks = radio.seed(trackId) + if (tracks.isEmpty()) return + setQueue(tracks, initialIndex = 0, source = "radio:$trackId") + } + + // ── Internal: async connect + Listener-driven UI state sync ────────── + + private suspend fun connectAndObserve() { + val controller = + try { + connectController() + } catch ( + // Service-connection failure is a "show error, move on" boundary — + // any throwable from the Media3 IPC handshake gets surfaced via + // playbackError. Specific-exception handling buys nothing here. + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + uiStateInternal.value = + uiStateInternal.value.copy(playbackError = e.message ?: "Player unavailable") + return + } + mediaController = controller + controller.addListener( + object : Player.Listener { + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + val title = queueRefs + .getOrNull(controller.currentMediaItemIndex) + ?.title + ?.takeIf { it.isNotEmpty() } + ?: "Track" + playbackErrorEventsChannel.trySend(title) + } + + override fun onEvents(player: Player, events: Player.Events) { + val idx = player.currentMediaItemIndex + val current = queueRefs.getOrNull(idx) + val source = player.currentMediaItem + ?.mediaMetadata + ?.extras + ?.getString(MINSTREL_SOURCE_KEY) + uiStateInternal.value = + PlayerUiState( + currentTrack = current, + queue = queueRefs, + queueIndex = idx, + isPlaying = player.isPlaying, + isBuffering = player.playbackState == Player.STATE_BUFFERING, + positionMs = player.currentPosition.coerceAtLeast(0), + durationMs = player.duration.coerceAtLeast(0), + bufferedPositionMs = player.bufferedPosition.coerceAtLeast(0), + playbackError = player.playerError?.message, + currentSource = source, + shuffleEnabled = player.shuffleModeEnabled, + repeatMode = when (player.repeatMode) { + Player.REPEAT_MODE_ALL -> RepeatMode.ALL + Player.REPEAT_MODE_ONE -> RepeatMode.ONE + else -> RepeatMode.OFF + }, + ) + } + }, + ) + } + + /** + * Bridges Media3's `ListenableFuture.buildAsync()` + * to a suspend function without pulling in `kotlinx-coroutines-guava` + * — the future's `addListener` callback is enough. `Runnable::run` + * is a direct executor (no extra threading) since the callback only + * unparks our continuation. + */ + private suspend fun connectController(): MediaController = + suspendCancellableCoroutine { cont -> + val future = MediaController.Builder(context, sessionToken).buildAsync() + future.addListener( + { + try { + cont.resume(future.get()) + } catch ( + // ListenableFuture.get() throws ExecutionException + + // InterruptedException + CancellationException — handle + // them uniformly by forwarding to the continuation; + // the caller catches at the outer boundary. + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + cont.resumeWithException(e) + } + }, + Runnable::run, + ) + cont.invokeOnCancellation { future.cancel(/* mayInterruptIfRunning = */ false) } + } + + private fun TrackRef.toMediaItem(source: String?): MediaItem { + val metadata = MediaMetadata.Builder() + .setTitle(title) + .setArtist(artistName) + .setAlbumTitle(albumTitle) + .apply { + if (source != null) setExtras(sourceExtras(source)) + } + .build() + // Server's stream_url is a relative path (/api/tracks/{id}/stream); + // resolve to the placeholder.invalid form so OkHttpDataSource (sharing + // BaseUrlInterceptor) rewrites it to the live server with the auth + // cookie. Same resolver as covers — one place for /api/* URLs. + val resolvedUri = resolveServerUrl(streamUrl) ?: streamUrl + return MediaItem.Builder() + .setMediaId(id) + .setUri(resolvedUri) + // Key the Media3 SimpleCache by trackId (not the stream URL) so + // cache residency can be queried per track for the cached dot + + // offline pools, independent of URL/host rewrites. + .setCustomCacheKey(id) + .setMediaMetadata(metadata) + .build() + } + + private fun sourceExtras(source: String): Bundle = + Bundle().apply { putString(MINSTREL_SOURCE_KEY, source) } + + companion object { + const val MINSTREL_SOURCE_KEY: String = "minstrel_source" + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt new file mode 100644 index 00000000..8bfe3ccb --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt @@ -0,0 +1,74 @@ +package com.fabledsword.minstrel.player + +import android.content.Context +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.cache.CacheDataSink +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import com.fabledsword.minstrel.cache.audiocache.CacheConfig +import dagger.hilt.android.qualifiers.ApplicationContext +import okhttp3.OkHttpClient +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Builds the process-singleton ExoPlayer with our shared OkHttp + + * SimpleCache chain. The MinstrelPlayerService (Phase 6.2) calls + * `build()` once during onCreate. + * + * `simpleCache` is exposed as a val so the AudioCacheEvictionWorker + * (Phase 12.3) can call `cache.removeSpan(...)` when evicting tracks + * to disk-clear their cached spans. + * + * The default `LeastRecentlyUsedCacheEvictor` provides the floor LRU + * (sizeBytes cap = rollingCap); our policy layer in the worker layers + * the 2-bucket protection on top by feeding `removeSpan` only for + * unprotected tracks. + */ +@Singleton +class PlayerFactory @Inject constructor( + @ApplicationContext private val context: Context, + private val okHttpClient: OkHttpClient, + private val cacheConfig: CacheConfig, +) { + private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() } + + val simpleCache: SimpleCache = SimpleCache( + cacheDir, + LeastRecentlyUsedCacheEvictor(cacheConfig.rollingCapBytes), + StandaloneDatabaseProvider(context), + ) + + fun build(): ExoPlayer { + val httpDataSource = OkHttpDataSource.Factory(okHttpClient) + val cacheDataSource = CacheDataSource.Factory() + .setCache(simpleCache) + .setUpstreamDataSourceFactory(httpDataSource) + .setCacheWriteDataSinkFactory( + CacheDataSink.Factory() + .setCache(simpleCache) + .setFragmentSize(C.LENGTH_UNSET.toLong()), + ) + val mediaSourceFactory = DefaultMediaSourceFactory(context) + .setDataSourceFactory(cacheDataSource) + + return ExoPlayer.Builder(context) + .setMediaSourceFactory(mediaSourceFactory) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build(), + /* handleAudioFocus = */ true, + ) + .setHandleAudioBecomingNoisy(true) + .build() + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerModule.kt new file mode 100644 index 00000000..ebba0584 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerModule.kt @@ -0,0 +1,44 @@ +package com.fabledsword.minstrel.player + +import com.fabledsword.minstrel.auth.AuthStore +import com.fabledsword.minstrel.cache.audiocache.CacheConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Player + audio-cache wiring. PlayerFactory has @Inject so it doesn't + * need a @Provides here; this module exists to provide the + * configuration values (CacheConfig) it depends on, and any future + * player-related singletons that aren't @Inject-constructible. + * + * MinstrelPlayerService (Phase 6.2) injects PlayerFactory and calls + * `build()` from `onCreate`. The eviction worker (Phase 12.3) injects + * PlayerFactory too and reads `simpleCache` from it. + */ +@Module +@InstallIn(SingletonComponent::class) +object PlayerModule { + + /** + * CacheConfig snapshot from the user's persisted CacheSettings. + * SimpleCache is constructed once per process, so changes via the + * Settings UI take effect on next app launch (not mid-flight). + * AuthStore's StateFlow hydrates async from Room — when the + * factory @Provides runs, the value is either the persisted + * setting (if Room finished first) or CacheSettings.DEFAULT. + * Worst case the user's tuning lands on next-next launch, which + * is acceptable for a single-user device pref. + */ + @Provides + @Singleton + fun provideCacheConfig(authStore: AuthStore): CacheConfig { + val s = authStore.cacheSettings.value + return CacheConfig( + likedCapBytes = s.likedCapBytes, + rollingCapBytes = s.rollingCapBytes, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt new file mode 100644 index 00000000..fedaf877 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt @@ -0,0 +1,31 @@ +package com.fabledsword.minstrel.player + +import com.fabledsword.minstrel.models.TrackRef + +/** + * Cycle on the repeat button: off → all → one → off. Mirrors + * `AudioServiceRepeatMode` in flutter_client and maps directly to + * the three Media3 `Player.REPEAT_MODE_*` int constants. + */ +enum class RepeatMode { OFF, ALL, ONE } + +/** + * Snapshot of what the player is doing, for the UI to read via + * [PlayerController.uiState]. Replaces the audio_service-style + * `PlaybackState + MediaItem` split — Media3 has those internally; + * this is the projection we hand to ViewModels. + */ +data class PlayerUiState( + val currentTrack: TrackRef? = null, + val queue: List = emptyList(), + val queueIndex: Int = -1, + val isPlaying: Boolean = false, + val isBuffering: Boolean = false, + val positionMs: Long = 0, + val durationMs: Long = 0, + val bufferedPositionMs: Long = 0, + val playbackError: String? = null, + val currentSource: String? = null, + val shuffleEnabled: Boolean = false, + val repeatMode: RepeatMode = RepeatMode.OFF, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/RadioController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/RadioController.kt new file mode 100644 index 00000000..a146dc16 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/RadioController.kt @@ -0,0 +1,33 @@ +package com.fabledsword.minstrel.player + +import com.fabledsword.minstrel.api.endpoints.RadioApi +import com.fabledsword.minstrel.library.data.toDomain +import com.fabledsword.minstrel.models.TrackRef +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Thin facade over `/api/radio` that maps the server response into + * the domain `TrackRef` list `PlayerController.setQueue` consumes. + * Owning the mapping here keeps PlayerController free of Retrofit + * + wire-type imports; the player only sees `List`. + * + * Construction follows the project convention from NetworkModule: + * per-endpoint Retrofit interfaces aren't @Provides-bound; consumers + * inject the shared `Retrofit` and call `.create()` themselves. + */ +@Singleton +class RadioController @Inject constructor(retrofit: Retrofit) { + private val api: RadioApi = retrofit.create() + + /** + * Calls `GET /api/radio?seed_track=` and maps the + * response into domain TrackRefs. Returns an empty list on a + * 2xx-but-empty server response; throws on any transport or + * non-2xx status (caller surfaces the error). + */ + suspend fun seed(trackId: String): List = + api.seedTrack(trackId).tracks.map { it.toDomain() } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ResumeController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ResumeController.kt new file mode 100644 index 00000000..1f666738 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ResumeController.kt @@ -0,0 +1,96 @@ +package com.fabledsword.minstrel.player + +import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao +import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity +import com.fabledsword.minstrel.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Persists the player's last queue + position to Room so a torn-down + * session can resume on next app launch. Mirrors + * `flutter_client/lib/cache/resume_controller.dart`. + * + * Subscribes to [PlayerController.uiState] in init; persists when the + * (track-id, queueIndex) changes — captures real session transitions + * (track started / skipped) without churning on every 1Hz position tick. + * + * Restore is one-shot at app start (called from `MinstrelApplication. + * onCreate`). Reads the row, decodes, calls + * [PlayerController.setQueue] — the player's transport buttons + any + * paired Wear/lock-screen surface now have something to act on even + * if the user never opened the in-app UI this run. + */ +@Singleton +class ResumeController @Inject constructor( + private val playerController: PlayerController, + private val dao: CachedResumeStateDao, + private val json: Json, + @ApplicationScope private val scope: CoroutineScope, +) { + init { + scope.launch { observeAndPersist() } + } + + suspend fun restore() { + // runCatching folds row-missing + decode-failure + empty-tracks into + // a single null-or-go-ahead expression so detekt's ReturnCount rule + // is satisfied. The .onFailure side effect drops a corrupted row + // rather than leaving a poison pill across launches. + val row = dao.get() ?: return + val payload = + runCatching { json.decodeFromString(row.json) } + .onFailure { + Timber.w(it, "ResumeController: dropping unreadable resume state") + dao.clear() + } + .getOrNull() + ?.takeIf { it.tracks.isNotEmpty() } + ?: return + playerController.setQueue( + tracks = payload.tracks, + initialIndex = payload.queueIndex + .coerceAtLeast(0) + .coerceAtMost(payload.tracks.lastIndex), + source = payload.source, + ) + // PositionMs not seeked yet — Player connection may still be + // establishing; the seek call would no-op. Acceptable to start + // from 0 on resume for now; #future-polish if it bothers anyone. + } + + private suspend fun observeAndPersist() { + playerController.uiState + // Skip the initial "empty" snapshot before anything has been played. + .drop(1) + .map { Triple(it.currentTrack?.id, it.queueIndex, it.queue.size) } + .distinctUntilChanged() + .collect { persistCurrentSnapshot() } + } + + private suspend fun persistCurrentSnapshot() { + val state = playerController.uiState.value + if (state.queue.isEmpty()) { + // Clear so an emptied queue doesn't auto-restore stale tracks. + dao.clear() + return + } + val payload = ResumePayload( + tracks = state.queue, + queueIndex = state.queueIndex.coerceAtLeast(0), + positionMs = state.positionMs, + ) + dao.upsert( + CachedResumeStateEntity( + json = json.encodeToString(ResumePayload.serializer(), payload), + ), + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ResumePayload.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ResumePayload.kt new file mode 100644 index 00000000..5042dde3 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ResumePayload.kt @@ -0,0 +1,23 @@ +package com.fabledsword.minstrel.player + +import com.fabledsword.minstrel.models.TrackRef +import kotlinx.serialization.Serializable + +/** + * The JSON-serialized shape persisted in + * `cached_resume_state.json` by [ResumeController]. Versioned via the + * `schema` field so a future change can read old snapshots safely + * (decode → drop on mismatch rather than crash). + */ +@Serializable +data class ResumePayload( + val schema: Int = SCHEMA_VERSION, + val tracks: List, + val queueIndex: Int, + val positionMs: Long, + val source: String? = null, +) { + companion object { + const val SCHEMA_VERSION: Int = 1 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/DominantColor.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/DominantColor.kt new file mode 100644 index 00000000..4298a2dc --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/DominantColor.kt @@ -0,0 +1,76 @@ +package com.fabledsword.minstrel.player.ui + +import androidx.compose.animation.animateColorAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.palette.graphics.Palette +import coil3.SingletonImageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.request.allowHardware +import coil3.toBitmap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val GRADIENT_TWEEN_MS = 600 + +/** + * Animated dominant-color extractor for the NowPlaying gradient + * backdrop. Fetches [coverUrl] via Coil's singleton loader (so the + * bitmap is shared with the on-screen AsyncImage cache), runs + * androidx.palette on a background thread, and animates the result + * via [animateColorAsState] so track changes tween smoothly. + * + * The held color is NOT reset when [coverUrl] changes — it stays on + * the previous track's dominant until the new palette resolves, so the + * gradient tweens old→new directly instead of dipping toward the + * fallback mid-swap. Mirrors `now_playing_screen.dart`'s preload-then- + * swap ("keep the previous dominant"); the cover image swaps smoothly + * via `CoverPrefetcher`, which warms the next track's bytes into Coil. + * + * Starts at [Color.Transparent] (cold mount) and resets to it only + * when a track has no cover — callers layer a base color underneath so + * the screen never sits on flat black. + */ +@Composable +fun rememberDominantColor(coverUrl: String?): Color { + val context = LocalContext.current + var extracted by remember { mutableStateOf(Color.Transparent) } + + LaunchedEffect(coverUrl) { + if (coverUrl.isNullOrEmpty()) { + extracted = Color.Transparent + return@LaunchedEffect + } + val request = ImageRequest.Builder(context) + .data(coverUrl) + .allowHardware(false) + .build() + val result = SingletonImageLoader.get(context).execute(request) + // Keep the previous dominant on load failure rather than dropping. + if (result !is SuccessResult) return@LaunchedEffect + val bitmap = result.image.toBitmap() + val palette = withContext(Dispatchers.Default) { + Palette.from(bitmap).generate() + } + val swatch = palette.vibrantSwatch + ?: palette.mutedSwatch + ?: palette.dominantSwatch + if (swatch != null) { + extracted = Color(swatch.rgb) + } + } + + val animated by animateColorAsState( + targetValue = extracted, + animationSpec = androidx.compose.animation.core.tween(GRADIENT_TWEEN_MS), + label = "now-playing-dominant", + ) + return animated +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt new file mode 100644 index 00000000..5296f338 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt @@ -0,0 +1,282 @@ +package com.fabledsword.minstrel.player.ui + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +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.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.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Music +import com.composables.icons.lucide.Pause +import com.composables.icons.lucide.Play +import com.composables.icons.lucide.SkipBack +import com.composables.icons.lucide.SkipForward +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER +import com.fabledsword.minstrel.nav.LocalAnimatedContentScope +import com.fabledsword.minstrel.nav.LocalSharedTransitionScope +import com.fabledsword.minstrel.shared.widgets.LikeButton +import com.fabledsword.minstrel.shared.widgets.ServerImage +import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton +import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel +import com.fabledsword.minstrel.theme.FabledSwordFlatTokens + +private const val MINI_BAR_HEIGHT_DP = 80 +private const val MINI_BAR_TONAL_ELEVATION_DP = 4 +private const val COVER_SIZE_DP = 48 +private const val SCRUBBER_ROW_HEIGHT_DP = 4 + +// Upward flick speed (dp/s) that expands the bar into NowPlaying. +// Mirrors player_bar.dart's 200 px/s threshold; expressed in dp and +// converted via density so the gesture feels the same across screens. +private const val SWIPE_UP_VELOCITY_DP = 200 + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun MiniCover(coverUrl: String, contentDescription: String) { + val sharedScope = LocalSharedTransitionScope.current + val animatedScope = LocalAnimatedContentScope.current + val heroModifier = if (sharedScope != null && animatedScope != null) { + with(sharedScope) { + Modifier.sharedElement( + sharedContentState = rememberSharedContentState( + key = HERO_KEY_NOW_PLAYING_COVER, + ), + animatedVisibilityScope = animatedScope, + ) + } + } else { + Modifier + } + Box( + modifier = heroModifier + .size(COVER_SIZE_DP.dp) + .clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + // Cross-fade the artwork when the track changes so the cover + // dissolves into the next instead of hard-swapping. + Crossfade(targetState = coverUrl, label = "mini-cover") { url -> + ServerImage( + url = url, + contentDescription = contentDescription, + modifier = Modifier.size(COVER_SIZE_DP.dp), + ) { + Icon( + imageVector = Lucide.Music, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** + * Collapsed player bar shown above the bottom nav. Returns nothing + * when no track is loaded so it doesn't take screen space on a + * fresh install. Tapping the body navigates to the full + * NowPlayingScreen via [onExpandClick]. + * + * Layout (Column): + * - Slim seek slider at the top (4dp track) + * - Row: cover | title/artist column | like | prev | play/pause | next | kebab + * + * The slider participates in pointer-events so tap/scrub works + * without the surface's onClick eating the gesture; the row's + * surface-level clickable still expands to NowPlaying on tap of + * the cover/title area. + */ +@Composable +fun MiniPlayer( + onExpandClick: () -> Unit, + onNavigateToAlbum: (String) -> Unit, + onNavigateToArtist: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: PlayerViewModel = hiltViewModel(), + trackActionsViewModel: TrackActionsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val track = state.currentTrack ?: return + val isLiked by trackActionsViewModel.isLikedFlow(track.id) + .collectAsStateWithLifecycle(initialValue = false) + + // Swipe up anywhere on the bar to expand into the full player — + // mirrors player_bar.dart. We only act on a clear upward flick + // (negative velocity past the threshold) so a slow tap-with-jitter + // doesn't accidentally open the screen. The horizontal seek slider + // keeps its own gestures; a vertical draggable only claims + // vertical-dominant drags. + val flickThresholdPx = with(LocalDensity.current) { SWIPE_UP_VELOCITY_DP.dp.toPx() } + Surface( + modifier = modifier + .fillMaxWidth() + .height(MINI_BAR_HEIGHT_DP.dp) + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { }, + onDragStopped = { velocity -> if (velocity < -flickThresholdPx) onExpandClick() }, + ), + color = MaterialTheme.colorScheme.surface, + tonalElevation = MINI_BAR_TONAL_ELEVATION_DP.dp, + ) { + Column { + MiniSeekBar( + positionMs = state.positionMs, + durationMs = state.durationMs, + onSeek = viewModel::seekTo, + ) + MiniRow( + track = track, + isPlaying = state.isPlaying, + isLiked = isLiked, + onExpandClick = onExpandClick, + onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() }, + onPrev = viewModel::skipToPrevious, + onNext = viewModel::skipToNext, + onToggleLike = { + trackActionsViewModel.toggleLike(track.id, isLiked) + }, + onNavigateToAlbum = onNavigateToAlbum, + onNavigateToArtist = onNavigateToArtist, + ) + } + } +} + +@Composable +private fun MiniSeekBar(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) { + val fraction = if (durationMs > 0) { + (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) + } else { + 0f + } + Slider( + value = fraction, + onValueChange = { v -> if (durationMs > 0) onSeek((v * durationMs).toLong()) }, + modifier = Modifier + .fillMaxWidth() + .height(SCRUBBER_ROW_HEIGHT_DP.dp), + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + // M3 default inactiveTrackColor is secondaryContainer, which the + // theme doesn't override — that's where the M3-baseline purple + // leaks in. Pin to surfaceVariant (= slate) for Flutter parity. + inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) +} + +@Composable +@Suppress("LongParameterList") +private fun MiniRow( + track: TrackRef, + isPlaying: Boolean, + isLiked: Boolean, + onExpandClick: () -> Unit, + onPlayPause: () -> Unit, + onPrev: () -> Unit, + onNext: () -> Unit, + onToggleLike: () -> Unit, + onNavigateToAlbum: (String) -> Unit, + onNavigateToArtist: (String) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Cover + title area expands to NowPlaying on tap, but the + // transport buttons + kebab don't (each IconButton handles + // its own clicks). Wrap only the cover-and-title region with + // the clickable modifier rather than the whole row. + Row( + modifier = Modifier + .weight(1f) + .clickable(onClick = onExpandClick), + verticalAlignment = Alignment.CenterVertically, + ) { + MiniCover(coverUrl = track.coverUrl, contentDescription = track.title) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = track.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = track.artistName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + LikeButton(liked = isLiked, onToggle = onToggleLike) + TransportButton(icon = Lucide.SkipBack, description = "Previous", onClick = onPrev) + TransportButton( + icon = if (isPlaying) Lucide.Pause else Lucide.Play, + description = if (isPlaying) "Pause" else "Play", + onClick = onPlayPause, + ) + TransportButton(icon = Lucide.SkipForward, description = "Next", onClick = onNext) + // hideQueueActions=true: the playing track is itself the + // queue entry, so Play next / Add to queue would be silly. + TrackActionsButton( + track = track, + hideQueueActions = true, + onNavigateToAlbum = onNavigateToAlbum, + onNavigateToArtist = onNavigateToArtist, + ) + } +} + +@Composable +private fun TransportButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + description: String, + onClick: () -> Unit, +) { + IconButton(onClick = onClick) { + Icon( + imageVector = icon, + contentDescription = description, + tint = MaterialTheme.colorScheme.onSurface, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt new file mode 100644 index 00000000..a1a7af98 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -0,0 +1,476 @@ +@file:Suppress("TooManyFunctions") // Compose screen + private helper composables + +package com.fabledsword.minstrel.player.ui + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.background +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.aspectRatio +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.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.composables.icons.lucide.ChevronDown +import com.composables.icons.lucide.ListMusic +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Music +import com.composables.icons.lucide.Pause +import com.composables.icons.lucide.Play +import com.composables.icons.lucide.Repeat +import com.composables.icons.lucide.Repeat1 +import com.composables.icons.lucide.Shuffle +import com.composables.icons.lucide.SkipBack +import com.composables.icons.lucide.SkipForward +import com.fabledsword.minstrel.player.RepeatMode +import com.fabledsword.minstrel.nav.AlbumDetail +import com.fabledsword.minstrel.nav.ArtistDetail +import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER +import com.fabledsword.minstrel.nav.LocalAnimatedContentScope +import com.fabledsword.minstrel.nav.LocalSharedTransitionScope +import com.fabledsword.minstrel.nav.NowPlaying +import com.fabledsword.minstrel.nav.Queue +import com.fabledsword.minstrel.shared.formatDuration +import com.fabledsword.minstrel.shared.millisToSeconds +import com.fabledsword.minstrel.shared.widgets.LikeButton +import com.fabledsword.minstrel.shared.widgets.ServerImage +import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton +import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel +import com.fabledsword.minstrel.theme.FabledSwordFlatTokens +import com.fabledsword.minstrel.theme.LocalActionColors + +private const val COVER_MAX_WIDTH_DP = 320 +private const val TRANSPORT_ICON_DP = 36 +private const val PLAY_PAUSE_ICON_DP = 56 +private const val POP_GRACE_MS = 500L + +// Vertical drag-down threshold (in pixels) past which the gesture +// pops the player. Matches Flutter's 80px threshold in spirit; +// using pointer-input pixels not dp because gesture velocity also +// participates in the dismiss decision. +private const val DRAG_DISMISS_THRESHOLD_PX = 200f + +/** + * Full-screen player. Shows the cover (placeholder until AlbumRef join + * lands), title + artist + album, a scrubber, and a transport row + * (prev / play-pause / next). Renders the EmptyState widget when no + * track is loaded. + */ +@Composable +fun NowPlayingScreen( + navController: NavHostController, + viewModel: PlayerViewModel = hiltViewModel(), + trackActionsViewModel: TrackActionsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(Unit) { + trackActionsViewModel.transientMessages.collect { msg -> + snackbarHostState.showSnackbar(msg) + } + } + val track = state.currentTrack + if (track == null) { + // Session torn down (queue finished + auto-stop, or user cleared + // the queue from elsewhere). Pop back to whichever shell screen + // launched NowPlaying rather than stranding the user on an + // EmptyState with no escape. A short delay swallows the + // momentary null during MediaController IPC bind on cold-mount. + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(POP_GRACE_MS) + if (viewModel.uiState.value.currentTrack == null) { + navController.popBackStack() + } + } + return + } + val dominant = rememberDominantColor(track.coverUrl) + Scaffold( + modifier = Modifier.fillMaxSize().nowPlayingDragDismiss(navController), + topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + containerColor = Color.Transparent, + ) { inner -> + Box( + modifier = Modifier + .fillMaxSize() + .background(dominantGradient(dominant)), + ) { + NowPlayingBody( + inner = inner, + state = state, + track = track, + navController = navController, + viewModel = viewModel, + trackActionsViewModel = trackActionsViewModel, + ) + } + } +} + +@Composable +private fun dominantGradient(top: Color): Brush { + val base = MaterialTheme.colorScheme.background + return Brush.verticalGradient( + 0f to top.copy(alpha = DOMINANT_TINT_TOP), + DOMINANT_TINT_MID_STOP to top.copy(alpha = DOMINANT_TINT_MID), + 1f to base, + ) +} + +private const val DOMINANT_TINT_TOP = 0.55f +private const val DOMINANT_TINT_MID = 0.18f +private const val DOMINANT_TINT_MID_STOP = 0.45f + +/** + * Drag-down to dismiss. Mirrors Flutter's modal-page gesture: a + * downward drag past the threshold pops the screen, returning the + * user to whichever shell route launched NowPlaying. Tap, swipe-up, + * and horizontal drags pass through to the underlying content + * (slider scrub, etc.). + */ +@Composable +private fun Modifier.nowPlayingDragDismiss(navController: NavHostController): Modifier = + this.pointerInput(Unit) { + var totalDragY = 0f + detectVerticalDragGestures( + onDragStart = { totalDragY = 0f }, + onDragEnd = { + if (totalDragY > DRAG_DISMISS_THRESHOLD_PX) { + navController.popBackStack() + } + totalDragY = 0f + }, + onDragCancel = { totalDragY = 0f }, + onVerticalDrag = { _, delta -> totalDragY += delta }, + ) + } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NowPlayingTopBar(onClose: () -> Unit) { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + imageVector = Lucide.ChevronDown, + contentDescription = "Close player", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + ), + ) +} + +@Composable +private fun NowPlayingBody( + inner: androidx.compose.foundation.layout.PaddingValues, + state: com.fabledsword.minstrel.player.PlayerUiState, + track: com.fabledsword.minstrel.models.TrackRef, + navController: NavHostController, + viewModel: PlayerViewModel, + trackActionsViewModel: TrackActionsViewModel, +) { + val isLiked by trackActionsViewModel.isLikedFlow(track.id) + .collectAsStateWithLifecycle(initialValue = false) + Column( + modifier = Modifier + .fillMaxSize() + .padding(inner) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + NowPlayingCover(coverUrl = track.coverUrl, contentDescription = track.title) + Spacer(Modifier.height(24.dp)) + TrackHeader(title = track.title, artist = track.artistName, album = track.albumTitle) + Spacer(Modifier.height(24.dp)) + // Action row (like, shuffle, repeat, queue, kebab) sits ABOVE the + // scrubber — Flutter's _SecondaryControls placement + // (now_playing_screen.dart:464). Android previously had it below + // the transport row. + BottomActionsRow( + navController = navController, + track = track, + shuffleEnabled = state.shuffleEnabled, + repeatMode = state.repeatMode, + isLiked = isLiked, + onToggleLike = { trackActionsViewModel.toggleLike(track.id, isLiked) }, + onToggleShuffle = viewModel::toggleShuffle, + onCycleRepeat = viewModel::cycleRepeat, + ) + Spacer(Modifier.height(8.dp)) + ScrubberRow( + positionMs = state.positionMs, + durationMs = state.durationMs, + onSeek = viewModel::seekTo, + ) + Spacer(Modifier.height(8.dp)) + TransportRow( + isPlaying = state.isPlaying, + onPrev = viewModel::skipToPrevious, + onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() }, + onNext = viewModel::skipToNext, + ) + } +} + +@Composable +private fun BottomActionsRow( + navController: NavHostController, + track: com.fabledsword.minstrel.models.TrackRef, + shuffleEnabled: Boolean, + repeatMode: RepeatMode, + isLiked: Boolean, + onToggleLike: () -> Unit, + onToggleShuffle: () -> Unit, + onCycleRepeat: () -> Unit, +) { + val accent = MaterialTheme.colorScheme.primary + val muted = MaterialTheme.colorScheme.onSurfaceVariant + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LikeButton(liked = isLiked, onToggle = onToggleLike) + IconButton(onClick = onToggleShuffle) { + Icon( + imageVector = Lucide.Shuffle, + contentDescription = if (shuffleEnabled) "Shuffle on" else "Shuffle off", + tint = if (shuffleEnabled) accent else muted, + ) + } + IconButton(onClick = onCycleRepeat) { + Icon( + imageVector = if (repeatMode == RepeatMode.ONE) Lucide.Repeat1 else Lucide.Repeat, + contentDescription = when (repeatMode) { + RepeatMode.OFF -> "Repeat off" + RepeatMode.ALL -> "Repeat all" + RepeatMode.ONE -> "Repeat one" + }, + tint = if (repeatMode == RepeatMode.OFF) muted else accent, + ) + } + ViewQueueButton(onClick = { navController.navigate(Queue) }) + TrackActionsButton( + track = track, + hideQueueActions = true, + // Single atomic transaction — navigate to the detail while + // popping NowPlaying off the back stack, so there's no + // intermediate frame on the shell tab underneath (Bug-5). + onNavigateToAlbum = { id -> + navController.navigate(AlbumDetail(id)) { + popUpTo(NowPlaying) { inclusive = true } + } + }, + onNavigateToArtist = { id -> + navController.navigate(ArtistDetail(id)) { + popUpTo(NowPlaying) { inclusive = true } + } + }, + ) + } +} + +@Composable +private fun ViewQueueButton(onClick: () -> Unit) { + IconButton(onClick = onClick) { + Icon( + imageVector = Lucide.ListMusic, + contentDescription = "View queue", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun NowPlayingCover(coverUrl: String, contentDescription: String) { + val sharedScope = LocalSharedTransitionScope.current + val animatedScope = LocalAnimatedContentScope.current + val heroModifier = if (sharedScope != null && animatedScope != null) { + with(sharedScope) { + Modifier.sharedElement( + sharedContentState = rememberSharedContentState( + key = HERO_KEY_NOW_PLAYING_COVER, + ), + animatedVisibilityScope = animatedScope, + ) + } + } else { + Modifier + } + Box( + modifier = heroModifier + .widthIn(max = COVER_MAX_WIDTH_DP.dp) + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(FabledSwordFlatTokens.radiusLg)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + ServerImage( + url = coverUrl, + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + ) { + Icon( + imageVector = Lucide.Music, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(96.dp), + ) + } + } +} + +@Composable +private fun TrackHeader(title: String, artist: String, album: String) { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = artist, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (album.isNotEmpty()) { + Text( + text = album, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) { + val accent = MaterialTheme.colorScheme.primary + val fraction = if (durationMs > 0) { + (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) + } else { + 0f + } + Slider( + value = fraction, + onValueChange = { newFraction -> + if (durationMs > 0) onSeek((newFraction * durationMs).toLong()) + }, + modifier = Modifier.fillMaxWidth(), + colors = SliderDefaults.colors( + thumbColor = accent, + activeTrackColor = accent, + // M3 inactive default is secondaryContainer (purple baseline); + // pin to surfaceVariant (= slate) for Flutter parity. + inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = formatDuration(positionMs.millisToSeconds()), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatDuration(durationMs.millisToSeconds()), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun TransportRow( + isPlaying: Boolean, + onPrev: () -> Unit, + onPlayPause: () -> Unit, + onNext: () -> Unit, +) { + val actionColors = LocalActionColors.current + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onPrev) { + Icon( + imageVector = Lucide.SkipBack, + contentDescription = "Previous", + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.size(TRANSPORT_ICON_DP.dp), + ) + } + IconButton(onClick = onPlayPause) { + Icon( + imageVector = if (isPlaying) Lucide.Pause else Lucide.Play, + contentDescription = if (isPlaying) "Pause" else "Play", + tint = actionColors.primary, + modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp), + ) + } + IconButton(onClick = onNext) { + Icon( + imageVector = Lucide.SkipForward, + contentDescription = "Next", + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.size(TRANSPORT_ICON_DP.dp), + ) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlaybackErrorViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlaybackErrorViewModel.kt new file mode 100644 index 00000000..2c0c479e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlaybackErrorViewModel.kt @@ -0,0 +1,21 @@ +package com.fabledsword.minstrel.player.ui + +import androidx.lifecycle.ViewModel +import com.fabledsword.minstrel.player.PlaybackErrorReporter +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +/** + * Hilt-injectable wrapper exposing [PlaybackErrorReporter.messages] + * to ShellScaffold. The reporter itself is an app-scoped singleton; + * this VM just bridges its Flow into a `hiltViewModel()`-resolvable + * surface so ShellScaffold can collect it without an EntryPoint + * accessor. + */ +@HiltViewModel +class PlaybackErrorViewModel @Inject constructor( + reporter: PlaybackErrorReporter, +) : ViewModel() { + val messages: Flow = reporter.messages +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt new file mode 100644 index 00000000..b9a32e3f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt @@ -0,0 +1,36 @@ +package com.fabledsword.minstrel.player.ui + +import androidx.lifecycle.ViewModel +import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.player.PlayerUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +/** + * Thin ViewModel wrapping the singleton [PlayerController] so + * Composables can `hiltViewModel()` it via the standard Compose + * pattern. Both MiniPlayer and NowPlayingScreen instantiate one of + * these; the underlying PlayerController is a process-singleton, so + * each VM exposes the same `uiState`. + * + * Transport methods are thin pass-throughs — the indirection earns + * its keep by giving testing seams later (a fake PlayerController + * stub-test for ViewModel-level logic when it grows). + */ +@HiltViewModel +class PlayerViewModel @Inject constructor( + private val controller: PlayerController, +) : ViewModel() { + + val uiState: StateFlow = controller.uiState + + fun play() = controller.play() + fun pause() = controller.pause() + fun seekTo(positionMs: Long) = controller.seekTo(positionMs) + fun skipToNext() = controller.skipToNext() + fun skipToPrevious() = controller.skipToPrevious() + fun seekToIndex(index: Int) = controller.seekToIndex(index) + fun toggleShuffle() = controller.toggleShuffle() + fun cycleRepeat() = controller.cycleRepeat() +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueScreen.kt new file mode 100644 index 00000000..71e2f64d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueScreen.kt @@ -0,0 +1,153 @@ +package com.fabledsword.minstrel.player.ui + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.composables.icons.lucide.ArrowLeft +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Volume2 +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.shared.formatDuration +import com.fabledsword.minstrel.shared.widgets.EmptyState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QueueScreen( + navController: NavHostController, + viewModel: PlayerViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Queue") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Lucide.ArrowLeft, contentDescription = "Back") + } + }, + ) + }, + ) { inner -> + Box(modifier = Modifier.fillMaxSize().padding(inner)) { + if (state.queue.isEmpty()) { + EmptyState( + title = "Queue is empty", + body = "Pick a track from the library and it'll show up here.", + ) + } else { + QueueList( + tracks = state.queue, + currentIndex = state.queueIndex, + onJumpTo = viewModel::seekToIndex, + ) + } + } + } +} + +@Composable +private fun QueueList( + tracks: List, + currentIndex: Int, + onJumpTo: (Int) -> Unit, +) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track -> + QueueRow( + track = track, + isCurrent = index == currentIndex, + onClick = { onJumpTo(index) }, + ) + HorizontalDivider() + } + } +} + +@Composable +private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) { + val highlight = if (isCurrent) { + MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA) + } else { + Color.Transparent + } + Row( + modifier = Modifier + .fillMaxWidth() + .background(highlight) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (isCurrent) { + Icon( + Lucide.Volume2, + contentDescription = "Now playing", + tint = MaterialTheme.colorScheme.primary, + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = track.title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val subtitle = queueSubtitle(track) + if (subtitle.isNotEmpty()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (track.durationSec > 0) { + Text( + text = formatDuration(track.durationSec), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** "Artist · Album" — collapses gracefully when either is missing. */ +private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, track.albumTitle) + .filter { it.isNotEmpty() } + .joinToString(" · ") + +private const val HIGHLIGHT_ALPHA = 0.12f diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt new file mode 100644 index 00000000..5bbd26a7 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt @@ -0,0 +1,255 @@ +package com.fabledsword.minstrel.playlists.data + +import com.fabledsword.minstrel.api.endpoints.AppendTracksRequest +import com.fabledsword.minstrel.api.endpoints.PlaylistsApi +import com.fabledsword.minstrel.auth.AuthController +import retrofit2.HttpException +import java.net.HttpURLConnection +import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao +import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao +import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity +import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity +import com.fabledsword.minstrel.cache.mutations.MutationQueue +import com.fabledsword.minstrel.models.PlaylistRef +import com.fabledsword.minstrel.models.PlaylistTrackRef +import com.fabledsword.minstrel.models.wire.PlaylistDetailWire +import com.fabledsword.minstrel.models.wire.PlaylistTrackWire +import com.fabledsword.minstrel.models.wire.PlaylistWire +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import retrofit2.Retrofit +import retrofit2.create +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Cache-first reads of the caller's playlists plus per-playlist track + * lists. Mirrors the Flutter `playlistsListProvider` + `playlistDetail` + * pattern: Room is the source of truth for UI observation; refresh* + * methods push wire data into Room and the Flow emissions propagate. + * + * Track-list hydration: `cached_playlist_tracks` only stores ordered + * (playlistId, trackId, position) triples. The full per-track display + * fields come back from `/api/playlists/{id}` in `PlaylistTrackWire` + * — we cache the membership rows in Room (so the playlist tracks list + * persists offline at the ID level) AND keep the freshest detail + * response in memory via the returned `PlaylistDetailRef`. A proper + * per-track hydration queue lands with the Phase 12 sync controller. + */ +@Singleton +class PlaylistsRepository @Inject constructor( + private val playlistDao: CachedPlaylistDao, + private val playlistTrackDao: CachedPlaylistTrackDao, + private val mutationQueue: MutationQueue, + private val authController: AuthController, + retrofit: Retrofit, +) { + private val api: PlaylistsApi = retrofit.create() + + // ── Reads (Flow, cache-first; the cache is the source of truth) ── + + /** Owned + public; UI splits by isSystem / userId comparison. */ + fun observeAll(): Flow> = + playlistDao.observeAll().map { rows -> rows.map { it.toDomain() } } + + /** User-owned playlists only (systemVariant IS NULL). */ + fun observeUserPlaylists(): Flow> = + playlistDao.observeUserPlaylists().map { rows -> rows.map { it.toDomain() } } + + /** Server-generated system playlists (for_you / discover / etc.). */ + fun observeSystemPlaylists(): Flow> = + playlistDao.observeSystemPlaylists().map { rows -> rows.map { it.toDomain() } } + + suspend fun getPlaylist(id: String): PlaylistRef? = playlistDao.getById(id)?.toDomain() + + // ── Server refreshes ────────────────────────────────────────────── + + /** + * Pulls `GET /api/playlists?kind=all` and replaces the local + * playlist cache. Per-playlist track rows come from refreshDetail. + */ + suspend fun refreshList() { + val wire = api.list(kind = "all") + val all = (wire.owned + wire.public).map { it.toEntity() } + val userId = authController.currentUser.value?.id + if (userId != null) { + // Reconcile: BuildSystemPlaylists rotates system-playlist + // UUIDs every rebuild, so upsert alone leaves stale rows + // whose detail fetch 404s ("That playlist no longer + // exists"). Mirrors playlists_provider.dart's deleteWhere. + playlistDao.replaceList( + userId = userId, + freshOwnedIds = wire.owned.map { it.id }, + all = all, + ) + } else { + playlistDao.upsertAll(all) + } + } + + /** + * Pulls `GET /api/playlists/{id}` and persists both the playlist + * row and its ordered track membership. Returns the freshly- + * hydrated detail (with full per-track display fields) for the + * detail screen to render immediately — Room only persists the + * track-membership IDs, not the display fields. + */ + suspend fun refreshDetail(id: String): PlaylistDetailRef { + val wire = try { + api.get(id) + } catch (e: HttpException) { + // Server says this playlist is gone — drop the stale cache + // row so the list stops showing it. Mirrors + // playlists_provider.dart's deleteWhere on detail failure. + if (e.code() == HttpURLConnection.HTTP_NOT_FOUND) { + playlistDao.deleteByIds(listOf(id)) + } + throw e + } + playlistDao.upsertAll(listOf(wire.toPlaylistEntity())) + playlistTrackDao.deleteByPlaylist(id) + playlistTrackDao.upsertAll( + wire.tracks.mapNotNull { row -> + row.trackId?.let { trackId -> + CachedPlaylistTrackEntity( + playlistId = id, + trackId = trackId, + position = row.position, + ) + } + }, + ) + return PlaylistDetailRef( + playlist = wire.toPlaylistRef(), + tracks = wire.tracks.map { it.toDomain() }, + ) + } + + /** + * Append a single track to a user-owned playlist. Follows the + * project's offline-first write contract: optimistic Room insert + * first (so the playlist detail screen shows the new row instantly), + * then fire `POST /api/playlists/{id}/tracks`. On transport failure + * the call is enqueued via MutationQueue for the next drain pass. + * + * The optimistic row's position is best-effort — server reasserts + * canonical position on the next playlist fetch. The (playlistId, + * trackId) composite PK guarantees no duplicate rows; if the track + * is already in the playlist, the IGNORE on conflict leaves the + * existing position unchanged. + */ + /** + * Rebuilds the caller's system playlist of [variant]. The server + * rotates the playlist's UUID on rebuild — the returned id (null + * when the library can't seed a build) is what callers should + * navigate to. List cache is refreshed so home-row tiles point at + * the fresh uuid. + */ + suspend fun refreshSystemPlaylist(variant: String): String? { + val response = api.refreshSystem(variant) + runCatching { refreshList() } + return response.playlistId + } + + suspend fun appendTrack(playlistId: String, trackId: String): AppendOutcome { + val nextPos = (playlistTrackDao.maxPosition(playlistId) ?: -1) + 1 + playlistTrackDao.insertOrIgnore( + CachedPlaylistTrackEntity( + playlistId = playlistId, + trackId = trackId, + position = nextPos, + ), + ) + return try { + api.appendTracks( + playlistId = playlistId, + body = AppendTracksRequest(trackIds = listOf(trackId)), + ) + AppendOutcome.Synced + } catch ( + @Suppress("SwallowedException") _: IOException, + ) { + mutationQueue.enqueuePlaylistAppend(playlistId, listOf(trackId)) + AppendOutcome.Queued + } + } +} + +enum class AppendOutcome { Synced, Queued } + +/** + * Snapshot of a playlist + its per-track display fields, as returned + * by `PlaylistsRepository.refreshDetail`. UI uses this directly for + * the initial render; offline fall-back reads playlist membership IDs + * from Room and renders them as greyed-out placeholders until the + * sync controller hydrates per-track details. + */ +data class PlaylistDetailRef( + val playlist: PlaylistRef, + val tracks: List, +) + +// ── Mappers (internal — keep Room + wire types out of the UI layer) ── + +private fun CachedPlaylistEntity.toDomain(): PlaylistRef = + PlaylistRef( + id = id, + userId = userId, + name = name, + description = description, + isPublic = isPublic, + systemVariant = systemVariant, + trackCount = trackCount, + coverUrl = coverPath ?: "", + ) + +private fun PlaylistWire.toEntity(): CachedPlaylistEntity = + CachedPlaylistEntity( + id = id, + userId = userId, + name = name, + description = description, + isPublic = isPublic, + coverPath = coverUrl.ifEmpty { null }, + trackCount = trackCount, + systemVariant = systemVariant, + ) + +private fun PlaylistDetailWire.toPlaylistEntity(): CachedPlaylistEntity = + CachedPlaylistEntity( + id = id, + userId = userId, + name = name, + description = description, + isPublic = isPublic, + coverPath = coverUrl.ifEmpty { null }, + trackCount = trackCount, + systemVariant = systemVariant, + ) + +private fun PlaylistDetailWire.toPlaylistRef(): PlaylistRef = + PlaylistRef( + id = id, + userId = userId, + name = name, + description = description, + isPublic = isPublic, + systemVariant = systemVariant, + trackCount = trackCount, + coverUrl = coverUrl, + ownerUsername = ownerUsername, + ) + +private fun PlaylistTrackWire.toDomain(): PlaylistTrackRef = + PlaylistTrackRef( + position = position, + trackId = trackId, + title = title, + albumId = albumId, + albumTitle = albumTitle, + artistId = artistId, + artistName = artistName, + durationSec = durationSec, + streamUrl = streamUrl, + ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt new file mode 100644 index 00000000..5b262c7f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt @@ -0,0 +1,622 @@ +@file:Suppress("TooManyFunctions") // Compose screen + private helper composables + +package com.fabledsword.minstrel.playlists.ui + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavHostController +import androidx.navigation.toRoute +import coil3.compose.AsyncImage +import com.composables.icons.lucide.ArrowLeft +import com.composables.icons.lucide.ListMusic +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Play +import com.composables.icons.lucide.RefreshCw +import com.composables.icons.lucide.Shuffle +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.events.EventsStream +import com.fabledsword.minstrel.events.LiveEvent +import com.fabledsword.minstrel.models.PlaylistRef +import com.fabledsword.minstrel.models.PlaylistTrackRef +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.nav.AlbumDetail +import com.fabledsword.minstrel.nav.ArtistDetail +import com.fabledsword.minstrel.nav.DetailSeedCache +import com.fabledsword.minstrel.nav.PlaylistDetail +import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.likes.data.LikesRepository +import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef +import com.fabledsword.minstrel.playlists.data.PlaylistsRepository +import com.fabledsword.minstrel.shared.formatDuration +import com.fabledsword.minstrel.shared.widgets.TrackRow +import com.fabledsword.minstrel.shared.widgets.LikeButton +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold +import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow +import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb +import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton +import com.fabledsword.minstrel.theme.FabledSwordFlatTokens +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import javax.inject.Inject + +private const val SHARE_STOP_TIMEOUT_MS = 5_000L + +// ─── State ─────────────────────────────────────────────────────────── + +sealed interface PlaylistDetailUiState { + data class Loading(val seed: PlaylistRef? = null) : PlaylistDetailUiState + data class Success(val detail: PlaylistDetailRef) : PlaylistDetailUiState + data class Error(val message: String) : PlaylistDetailUiState +} + +// ─── ViewModel ─────────────────────────────────────────────────────── + +@HiltViewModel +class PlaylistDetailViewModel @Inject constructor( + private val repository: PlaylistsRepository, + private val likes: LikesRepository, + private val player: PlayerController, + private val eventsStream: EventsStream, + private val seedCache: DetailSeedCache, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val route: PlaylistDetail = savedStateHandle.toRoute() + val playlistId: String = route.id + + private val internal = + MutableStateFlow(PlaylistDetailUiState.Loading()) + val uiState: StateFlow = internal.asStateFlow() + + /** + * One-shot signal: this playlist was deleted (server-side or from + * another client). The screen collects this and pops back so the + * user doesn't sit on a now-404 detail. + */ + private val deletedChannel = Channel(Channel.BUFFERED) + val deleted: Flow = deletedChannel.receiveAsFlow() + + /** + * One-shot signal: a system playlist regenerate landed and the + * server rotated the uuid. Screen replaces the current detail + * route with the new id so the user sees the fresh list. + */ + private val regeneratedChannel = Channel(Channel.BUFFERED) + val regenerated: Flow = regeneratedChannel.receiveAsFlow() + + val likedTrackIds: StateFlow> = + likes.observeLikedTracks() + .map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = emptySet(), + ) + + init { + refresh() + viewModelScope.launch { + eventsStream.events + .filter { it.kind.startsWith("playlist.") } + .collect { handlePlaylistEvent(it) } + } + } + + /** + * Filters playlist.* events to those touching this screen's id. + * delete → pop the screen; updated / tracks_changed → re-fetch. + * Other kinds (e.g. playlist.created for a different list) are + * ignored — the playlists-list screen handles those. + */ + private fun handlePlaylistEvent(event: LiveEvent) { + val eventPlaylistId = event.data["playlist_id"]?.jsonPrimitive?.contentOrNull + if (eventPlaylistId != playlistId) return + when (event.kind) { + "playlist.deleted" -> deletedChannel.trySend(Unit) + "playlist.updated", "playlist.tracks_changed" -> refresh() + } + } + + fun toggleLikeTrack(trackId: String) { + val desired = trackId !in likedTrackIds.value + viewModelScope.launch { + likes.toggleLike(LikesRepository.ENTITY_TRACK, trackId, desired) + } + } + + fun refresh(): Job = viewModelScope.launch { + try { + val carried = when (val cur = internal.value) { + is PlaylistDetailUiState.Loading -> cur.seed + is PlaylistDetailUiState.Success -> cur.detail.playlist + is PlaylistDetailUiState.Error -> null + } + internal.value = PlaylistDetailUiState.Loading( + carried ?: seedCache.peekPlaylist(playlistId), + ) + val detail = repository.refreshDetail(playlistId) + internal.value = PlaylistDetailUiState.Success(detail) + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.value = PlaylistDetailUiState.Error( + ErrorCopy.fromThrowable(e), + ) + } + } + + /** + * Server-side rebuild of this system playlist. No-op when the + * playlist isn't a refreshable system variant. On success the + * server rotates the playlist uuid; the new id arrives on + * [regenerated] for the screen to navigate-replace to. + */ + fun regenerate() { + val playlist = (internal.value as? PlaylistDetailUiState.Success) + ?.detail?.playlist ?: return + val variant = playlist.systemVariant + ?.takeIf { playlist.refreshable } ?: return + viewModelScope.launch { + runCatching { repository.refreshSystemPlaylist(variant) } + .onSuccess { newId -> + if (!newId.isNullOrEmpty()) { + regeneratedChannel.trySend(newId) + } + } + } + } + + /** Play the available tracks starting at [startTrackId] (or first available). */ + fun play(tracks: List, startTrackId: String?) { + val playable = tracks.filter { it.isAvailable && !it.streamUrl.isNullOrEmpty() } + if (playable.isEmpty()) return + val refs = playable.map { it.toTrackRef() } + val startIndex = refs.indexOfFirst { it.id == startTrackId }.coerceAtLeast(0) + player.setQueue(refs, initialIndex = startIndex, source = "playlist:$playlistId") + } +} + +// ─── Screen ────────────────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlaylistDetailScreen( + navController: NavHostController, + viewModel: PlaylistDetailViewModel = hiltViewModel(), + playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsState() + LaunchedEffect(Unit) { + viewModel.deleted.collect { navController.popBackStack() } + } + LaunchedEffect(Unit) { + viewModel.regenerated.collect { newId -> + // Replace the current detail route with the regenerated + // playlist's new uuid; pop the old entry so back doesn't + // land on a 404'd page. + navController.navigate(PlaylistDetail(newId)) { + popUpTo(PlaylistDetail(viewModel.playlistId)) { inclusive = true } + } + } + } + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(currentTitle(state)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Lucide.ArrowLeft, contentDescription = "Back") + } + }, + ) + }, + ) { inner -> + PullToRefreshScaffold( + onRefresh = { viewModel.refresh().join() }, + modifier = Modifier.fillMaxSize().padding(inner), + ) { + PlaylistDetailContent( + state = state, + viewModel = viewModel, + playerViewModel = playerViewModel, + navController = navController, + ) + } + } +} + +@Composable +private fun PlaylistDetailContent( + state: PlaylistDetailUiState, + viewModel: PlaylistDetailViewModel, + playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel, + navController: NavHostController, +) { + Crossfade(targetState = state, label = "playlist-detail") { s -> when (s) { + is PlaylistDetailUiState.Loading -> + s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList() + is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh) + is PlaylistDetailUiState.Success -> { + val likedTrackIds by viewModel.likedTrackIds.collectAsState() + val playerState by playerViewModel.uiState.collectAsState() + PlaylistDetailBody( + detail = s.detail, + likedTrackIds = likedTrackIds, + playingTrackId = playerState.currentTrack?.id, + onPlayAll = { viewModel.play(s.detail.tracks, startTrackId = null) }, + onShuffleAll = { + viewModel.play(s.detail.tracks.shuffled(), startTrackId = null) + }, + onRegenerate = viewModel::regenerate, + onTrackClick = { id -> + viewModel.play(s.detail.tracks, startTrackId = id) + }, + onToggleTrackLike = viewModel::toggleLikeTrack, + onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) }, + onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) }, + ) + } + } } +} + +private fun currentTitle(state: PlaylistDetailUiState): String = when (state) { + is PlaylistDetailUiState.Success -> state.detail.playlist.name + is PlaylistDetailUiState.Loading -> state.seed?.name ?: "Playlist" + is PlaylistDetailUiState.Error -> "Playlist" +} + +// ─── Body composables ──────────────────────────────────────────────── + +@Composable +private fun PlaylistDetailBody( + detail: PlaylistDetailRef, + likedTrackIds: Set, + playingTrackId: String?, + onPlayAll: () -> Unit, + onShuffleAll: () -> Unit, + onRegenerate: () -> Unit, + onTrackClick: (String) -> Unit, + onToggleTrackLike: (String) -> Unit, + onNavigateToAlbum: (String) -> Unit, + onNavigateToArtist: (String) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 140.dp), + ) { + item { PlaylistHeader(detail.playlist, onPlayAll, onShuffleAll, onRegenerate) } + item { HorizontalDivider() } + items(items = detail.tracks, key = { it.position }) { row -> + TrackRow( + row = row, + liked = row.trackId != null && row.trackId in likedTrackIds, + nowPlaying = row.trackId != null && row.trackId == playingTrackId, + onClick = { row.trackId?.let(onTrackClick) }, + onToggleLike = { row.trackId?.let(onToggleTrackLike) }, + onNavigateToAlbum = onNavigateToAlbum, + onNavigateToArtist = onNavigateToArtist, + ) + HorizontalDivider() + } + if (detail.tracks.isEmpty()) { + item { + Text( + text = "No tracks in this playlist yet.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(24.dp), + ) + } + } + } +} + +@Composable +private fun PlaylistHeader( + playlist: PlaylistRef, + onPlayAll: () -> Unit, + onShuffleAll: () -> Unit, + onRegenerate: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PlaylistCover(playlist) + Column(modifier = Modifier.weight(1f)) { + Text( + text = playlist.name, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (playlist.description.isNotEmpty()) { + Text( + text = playlist.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = "${playlist.trackCount} tracks", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + PlaylistHeaderActions( + refreshable = playlist.refreshable, + onPlayAll = onPlayAll, + onShuffleAll = onShuffleAll, + onRegenerate = onRegenerate, + ) + } +} + +@Composable +private fun PlaylistHeaderActions( + refreshable: Boolean, + onPlayAll: () -> Unit, + onShuffleAll: () -> Unit, + onRegenerate: () -> Unit, +) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = onPlayAll, colors = ButtonDefaults.buttonColors()) { + Icon(Lucide.Play, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Play") + } + OutlinedButton(onClick = onShuffleAll) { + Icon(Lucide.Shuffle, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Shuffle") + } + if (refreshable) { + OutlinedButton(onClick = onRegenerate) { + Icon(Lucide.RefreshCw, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Regenerate") + } + } + } +} + +@Composable +private fun PlaylistCover(playlist: PlaylistRef) { + Box( + modifier = Modifier + .size(120.dp) + .clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + if (playlist.coverUrl.isEmpty()) { + Icon( + imageVector = Lucide.ListMusic, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(56.dp), + ) + } else { + AsyncImage( + model = playlist.coverUrl, + contentDescription = playlist.name, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Composable +private fun TrackRow( + row: PlaylistTrackRef, + liked: Boolean, + nowPlaying: Boolean, + onClick: () -> Unit, + onToggleLike: () -> Unit, + onNavigateToAlbum: (String) -> Unit, + onNavigateToArtist: (String) -> Unit, +) { + val enabled = row.isAvailable + val rowAlpha = if (enabled) 1f else UNAVAILABLE_ALPHA + TrackRow( + title = row.title, + artist = row.artistName.ifEmpty { row.albumTitle }, + trackId = row.trackId.orEmpty(), + onClick = onClick, + nowPlaying = nowPlaying, + enabled = enabled, + contentAlpha = rowAlpha, + leading = { + TrackCoverThumb( + coverUrl = row.coverUrl, + contentDescription = row.title, + modifier = Modifier.padding(end = 12.dp), + ) + }, + trailing = { + Text( + text = formatDuration(row.durationSec, zero = "--:--"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = rowAlpha), + modifier = Modifier.padding(start = 12.dp), + ) + if (enabled) { + LikeButton(liked = liked, onToggle = onToggleLike) + TrackActionsButton( + track = row.toTrackRef(), + onNavigateToAlbum = onNavigateToAlbum, + onNavigateToArtist = onNavigateToArtist, + ) + } + }, + ) +} + +private const val SKELETON_PLAYLIST_ROWS = 8 + +@Composable +private fun SkeletonPlaylistTrackList() { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(SKELETON_PLAYLIST_ROWS) { SkeletonTrackRow() } + } +} + +/** + * Loading body that renders the playlist header from the navigation + * seed (cover + name + description + count) with skeleton track rows + * below. Play / Shuffle / Regenerate omitted — they need the loaded + * track list. + */ +@Composable +private fun SeededPlaylistLoading(seed: PlaylistRef) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PlaylistCover(seed) + Column(modifier = Modifier.weight(1f)) { + Text( + text = seed.name, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (seed.description.isNotEmpty()) { + Text( + text = seed.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = "${seed.trackCount} tracks", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + item { HorizontalDivider() } + items(SKELETON_PLAYLIST_ROWS) { SkeletonTrackRow() } + } +} + +@Composable +private fun ErrorBlock(message: String, onRetry: () -> Unit) { + var retried by remember { mutableStateOf(false) } + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(Modifier.height(12.dp)) + OutlinedButton( + onClick = { + if (!retried) { + retried = true + onRetry() + } + }, + ) { Text("Retry") } + } +} + +// ─── Helpers ───────────────────────────────────────────────────────── + +private const val UNAVAILABLE_ALPHA = 0.4f + +/** + * Lossy mapping: PlaylistTrackRef → TrackRef so the player can take it. + * Caller must filter `isAvailable && streamUrl != null` first. + */ +private fun PlaylistTrackRef.toTrackRef(): TrackRef = TrackRef( + id = trackId.orEmpty(), + title = title, + albumId = albumId.orEmpty(), + artistId = artistId.orEmpty(), + albumTitle = albumTitle, + artistName = artistName, + durationSec = durationSec, + streamUrl = streamUrl.orEmpty(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt new file mode 100644 index 00000000..ee730e76 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt @@ -0,0 +1,169 @@ +@file:Suppress("MatchingDeclarationName") // VM is colocated with its Screen per project convention + +package com.fabledsword.minstrel.playlists.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavHostController +import com.fabledsword.minstrel.events.EventsStream +import com.fabledsword.minstrel.models.PlaylistRef +import com.fabledsword.minstrel.nav.PlaylistDetail +import com.fabledsword.minstrel.nav.Playlists +import com.fabledsword.minstrel.playlists.data.PlaylistsRepository +import com.fabledsword.minstrel.shared.UiState +import com.fabledsword.minstrel.shared.asCacheFirstStateFlow +import com.fabledsword.minstrel.playlists.widgets.PlaylistCard +import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.LoadingCentered +import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject + +// ─── State ─────────────────────────────────────────────────────────── + +// ─── ViewModel ─────────────────────────────────────────────────────── + +@HiltViewModel +class PlaylistsListViewModel @Inject constructor( + private val repository: PlaylistsRepository, + private val eventsStream: EventsStream, +) : ViewModel() { + + init { + refresh() + // Live updates: a playlist created/updated/deleted from another + // client should reflect here without a manual refresh. + viewModelScope.launch { + eventsStream.events + .filter { it.kind.startsWith("playlist.") } + .collect { refresh() } + } + } + + /** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */ + fun refresh(): Job = viewModelScope.launch { + runCatching { repository.refreshList() } + } + + val uiState: StateFlow>> = + repository.observeAll() + .map { list -> + if (list.isEmpty()) { + UiState.Empty + } else { + UiState.Success(list) + } + } + .asCacheFirstStateFlow(viewModelScope) +} + +// ─── Screen ────────────────────────────────────────────────────────── + +@Composable +fun PlaylistsListScreen( + navController: NavHostController, + viewModel: PlaylistsListViewModel = hiltViewModel(), +) { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + MinstrelTopAppBar( + title = "Playlists", + navController = navController, + currentRouteName = Playlists::class.qualifiedName, + ) + }, + ) { inner -> + val state by viewModel.uiState.collectAsStateWithLifecycle() + PullToRefreshScaffold( + onRefresh = { viewModel.refresh().join() }, + modifier = Modifier.fillMaxSize().padding(inner), + ) { + when (val s = state) { + UiState.Loading -> LoadingCentered() + UiState.Empty -> EmptyState( + title = "No playlists yet", + body = "System playlists (For You / Discover / Songs like…) " + + "appear once your library has enough plays. Create your " + + "own playlist from any album or track in the meantime.", + ) + is UiState.Error -> EmptyState( + title = "Couldn't load playlists", + body = s.message, + ) + is UiState.Success -> PlaylistsGrid( + playlists = s.data, + onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) }, + ) + } + } + } +} + +@Composable +private fun PlaylistsGrid( + playlists: List, + onPlaylistClick: (String) -> Unit, +) { + val systemPlaylists = playlists.filter { it.isSystem } + val userPlaylists = playlists.filter { !it.isSystem } + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 176.dp), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (systemPlaylists.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + SectionHeader("System playlists") + } + items(items = systemPlaylists, key = { it.id }) { playlist -> + PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) }) + } + } + if (userPlaylists.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + SectionHeader("Your playlists") + } + items(items = userPlaylists, key = { it.id }) { playlist -> + PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) }) + } + } + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp), + ) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/OfflinePoolCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/OfflinePoolCard.kt new file mode 100644 index 00000000..37476989 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/OfflinePoolCard.kt @@ -0,0 +1,74 @@ +package com.fabledsword.minstrel.playlists.widgets + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.shape.RoundedCornerShape +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.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.fabledsword.minstrel.theme.FabledSwordFlatTokens + +/** + * Home tile for an offline cache-backed pool ("Recently played" / + * "Liked"). Tapping shuffles + plays that pool from the local cache. + * Sized to match [PlaylistCard] so the Playlists row stays visually + * consistent. Mirrors Flutter's `_OfflinePoolCard`. + */ +@Composable +fun OfflinePoolCard( + label: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .width(176.dp) + .clickable(onClick = onClick) + .padding(horizontal = 8.dp), + ) { + Box( + modifier = Modifier + .size(144.dp) + .clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + } + Spacer(Modifier.height(8.dp)) + Text( + text = label, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "Offline · shuffle", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt new file mode 100644 index 00000000..cae8abe5 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt @@ -0,0 +1,129 @@ +package com.fabledsword.minstrel.playlists.widgets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.composables.icons.lucide.ListMusic +import com.composables.icons.lucide.Lucide +import com.fabledsword.minstrel.models.PlaylistRef +import com.fabledsword.minstrel.nav.LocalDetailSeedCache +import com.fabledsword.minstrel.shared.widgets.CoverTile +import com.fabledsword.minstrel.theme.FabledSwordFlatTokens + +/** + * One playlist tile. Sized to match `AlbumCard` (176dp wide, 144dp + * square cover) so they line up in the Home Playlists carousel. + * Mirrors `flutter_client/lib/playlists/widgets/playlist_card.dart`. + * + * System playlists carry a small "For You" / "Discover" / etc. label + * subtitle under the name (substituting for the artist-name line on + * AlbumCard). + */ +@Composable +fun PlaylistCard( + playlist: PlaylistRef, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val seedCache = LocalDetailSeedCache.current + Surface( + modifier = modifier + .width(176.dp) + .clickable { + seedCache.stashPlaylist(playlist) + onClick() + }, + color = Color.Transparent, + ) { + Column(modifier = Modifier.padding(horizontal = 8.dp)) { + CoverTile( + url = playlist.coverUrl, + contentDescription = playlist.name, + size = 144.dp, + fallback = { + Icon( + imageVector = Lucide.ListMusic, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(56.dp), + ) + }, + overlay = { + if (playlist.isSystem) { + VariantPill( + label = systemLabelFor(playlist.systemVariant), + modifier = Modifier + .align(Alignment.TopStart) + .padding(6.dp), + ) + } + }, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = playlist.name, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitleFor(playlist), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +/** Small label chip overlaid on a system playlist's cover. */ +@Composable +private fun VariantPill(label: String, modifier: Modifier = Modifier) { + if (label.isEmpty()) return + Surface( + modifier = modifier, + shape = RoundedCornerShape(FabledSwordFlatTokens.radiusSm), + color = MaterialTheme.colorScheme.primary.copy(alpha = PILL_BG_ALPHA), + ) { + Text( + text = label, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + ) + } +} + +private const val PILL_BG_ALPHA = 0.85f + +private fun subtitleFor(playlist: PlaylistRef): String = when { + playlist.trackCount > 0 -> "${playlist.trackCount} tracks" + playlist.isSystem -> systemLabelFor(playlist.systemVariant) + else -> " " +} + +private fun systemLabelFor(variant: String?): String = when (variant) { + "for_you" -> "For You" + "discover" -> "Discover" + "songs_like_artist" -> "Songs like…" + "todays_mix" -> "Today's mix" + null -> "" + else -> variant.replace('_', ' ').replaceFirstChar { it.uppercase() } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistPlaceholderCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistPlaceholderCard.kt new file mode 100644 index 00000000..272abda5 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistPlaceholderCard.kt @@ -0,0 +1,115 @@ +package com.fabledsword.minstrel.playlists.widgets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.composables.icons.lucide.ListMusic +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.RefreshCw +import com.composables.icons.lucide.TriangleAlert +import com.fabledsword.minstrel.theme.FabledSwordFlatTokens + +/** + * Placeholder tile for a system playlist that hasn't generated yet. + * Mirrors `flutter_client/lib/playlists/widgets/playlist_placeholder_card.dart`. + * Sized to match [PlaylistCard] so the Home Playlists row stays + * visually consistent. + * + * [variant] is one of building / failed / pending / seed-needed, + * derived by the caller from the system-playlists build status: + * - building → a run is in flight + * - failed → the last run errored + * - seed-needed → songs-like slot with no seed artist liked yet + * - pending → never built / waiting for the next scheduled run + */ +@Composable +fun PlaylistPlaceholderCard( + label: String, + variant: String, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.width(176.dp).padding(horizontal = 8.dp)) { + Box( + modifier = Modifier + .size(144.dp) + .clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = iconFor(variant), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(40.dp), + ) + Surface( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(6.dp), + shape = RoundedCornerShape(FabledSwordFlatTokens.radiusSm), + color = MaterialTheme.colorScheme.surface.copy(alpha = STATUS_BG_ALPHA), + ) { + Text( + text = statusLabelFor(variant), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + Spacer(Modifier.height(8.dp)) + Text( + text = label, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitleFor(variant), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private const val STATUS_BG_ALPHA = 0.85f + +private fun iconFor(variant: String) = when (variant) { + "failed" -> Lucide.TriangleAlert + "building" -> Lucide.RefreshCw + else -> Lucide.ListMusic +} + +private fun statusLabelFor(variant: String): String = when (variant) { + "building" -> "Building" + "failed" -> "Failed" + "seed-needed" -> "Needs a seed" + else -> "Pending" +} + +private fun subtitleFor(variant: String): String = when (variant) { + "building" -> "Generating now…" + "failed" -> "Last build failed" + "seed-needed" -> "Like an artist to seed this" + else -> "Coming soon" +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/quarantine/data/QuarantineRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/data/QuarantineRepository.kt new file mode 100644 index 00000000..f67b0d28 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/data/QuarantineRepository.kt @@ -0,0 +1,150 @@ +package com.fabledsword.minstrel.quarantine.data + +import com.fabledsword.minstrel.api.endpoints.FlagRequest +import com.fabledsword.minstrel.api.endpoints.QuarantineApi +import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao +import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity +import com.fabledsword.minstrel.cache.mutations.MutationQueue +import com.fabledsword.minstrel.models.QuarantineRef +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.models.wire.QuarantineMineWire +import com.fabledsword.minstrel.shared.secondsToMillis +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.datetime.Clock +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Outcome of an `unflag` call — mirrors LikesRepository.toggleLike's + * RequestOutcome flavor. ACCEPTED = server confirmed; QUEUED = the + * call landed in the MutationQueue for later replay. + */ +enum class UnflagOutcome { ACCEPTED, QUEUED } + +/** + * Outcome of a `flag` call. Same semantics as [UnflagOutcome] — + * ACCEPTED = server confirmed; QUEUED = the call landed in the + * MutationQueue for later replay. + */ +enum class FlagOutcome { ACCEPTED, QUEUED } + +/** + * Cache-first accessor for the caller's quarantine list + offline- + * first flag / unflag writes. Mirrors Flutter's `MyQuarantineController` + * (drift `cached_quarantine_mine` + SWR + optimistic mutations) using + * the native Room + Flow idiom: + * + * - [observeMine] emits the cached rows, so the Hidden tab paints + * from disk instantly and survives connectivity loss. + * - [refresh] full-replaces the table from the server snapshot in a + * single transaction (no delete-then-insert flicker). + * - [flag] / [unflag] mutate the cache optimistically (the Flow + * re-emits immediately), call the server, and enqueue on failure + * so the intent persists offline. The optimistic row is NOT rolled + * back on failure — same as Flutter. + */ +@Singleton +class QuarantineRepository @Inject constructor( + private val dao: CachedQuarantineDao, + private val mutationQueue: MutationQueue, + retrofit: Retrofit, +) { + private val api: QuarantineApi = retrofit.create() + + /** Cache-first stream of the caller's hidden tracks (newest first). */ + fun observeMine(): Flow> = + dao.observeAll().map { rows -> rows.map { it.toDomain() } } + + /** Pull the server snapshot and atomically replace the cached table. */ + suspend fun refresh() { + val fresh = api.listMine().map { it.toEntity() } + dao.replaceAll(fresh) + } + + /** Server snapshot without touching the cache — used by the TrackActions hidden check. */ + suspend fun listMine(): List = api.listMine().map { it.toDomain() } + + /** + * Flag [track] for quarantine with [reason] (bad_rip / wrong_file / + * wrong_tags / duplicate / other) and optional admin-visible + * [notes]. Optimistically inserts the cache row (the Hidden tab + + * isHidden checks update instantly), then calls the server; + * transport failure enqueues for replay (server re-flag is + * idempotent). + */ + suspend fun flag(track: TrackRef, reason: String, notes: String): FlagOutcome { + dao.upsert(track.toQuarantineEntity(reason, notes)) + return try { + api.flag(FlagRequest(trackId = track.id, reason = reason, notes = notes)) + FlagOutcome.ACCEPTED + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + mutationQueue.enqueueQuarantineFlag(track.id, reason, notes) + FlagOutcome.QUEUED + } + } + + suspend fun unflag(trackId: String): UnflagOutcome { + dao.deleteByTrackId(trackId) + return try { + api.unflag(trackId) + UnflagOutcome.ACCEPTED + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + mutationQueue.enqueueQuarantineUnflag(trackId) + UnflagOutcome.QUEUED + } + } +} + +// ── Mappers (internal — wire/entity stay out of UI) ── + +private fun QuarantineMineWire.toEntity(): CachedQuarantineEntity = CachedQuarantineEntity( + trackId = trackId, + reason = reason, + notes = notes, + createdAt = createdAt, + trackTitle = trackTitle, + trackDurationMs = trackDurationMs, + albumId = albumId, + albumTitle = albumTitle, + albumCoverArtPath = albumCoverArtPath, + artistId = artistId, + artistName = artistName, +) + +private fun CachedQuarantineEntity.toDomain(): QuarantineRef = QuarantineRef( + trackId = trackId, + reason = reason, + notes = notes, + createdAt = createdAt, + trackTitle = trackTitle, + trackDurationMs = trackDurationMs, + albumId = albumId, + albumTitle = albumTitle, + albumCoverArtPath = albumCoverArtPath, + artistId = artistId, + artistName = artistName, +) + +private fun QuarantineMineWire.toDomain(): QuarantineRef = toEntity().toDomain() + +private fun TrackRef.toQuarantineEntity(reason: String, notes: String): CachedQuarantineEntity = + CachedQuarantineEntity( + trackId = id, + reason = reason, + notes = notes.ifEmpty { null }, + createdAt = Clock.System.now().toString(), + trackTitle = title, + trackDurationMs = durationSec.secondsToMillis(), + albumId = albumId, + albumTitle = albumTitle, + albumCoverArtPath = null, + artistId = artistId, + artistName = artistName, + ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTab.kt new file mode 100644 index 00000000..bc30cc9f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTab.kt @@ -0,0 +1,153 @@ +package com.fabledsword.minstrel.quarantine.ui + +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AssistChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composables.icons.lucide.ArchiveRestore +import com.composables.icons.lucide.Lucide +import com.fabledsword.minstrel.models.QuarantineRef +import com.fabledsword.minstrel.shared.UiState +import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.LoadingCentered +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold +import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb + +@Composable +fun HiddenTab(viewModel: HiddenTabViewModel = hiltViewModel()) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) { + when (val s = state) { + UiState.Loading -> LoadingCentered() + UiState.Empty -> EmptyState( + title = "No hidden tracks", + body = "Flag a track from its menu (bad rip, wrong tags, etc.) " + + "and it'll show up here. The track stays out of system " + + "playlists until you unhide it.", + ) + is UiState.Error -> EmptyState( + title = "Couldn't load hidden tracks", + body = s.message, + ) + is UiState.Success -> HiddenList( + rows = s.data, + onUnflag = viewModel::unflag, + ) + } + } +} + +@Composable +private fun HiddenList(rows: List, onUnflag: (String) -> Unit) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(items = rows, key = { it.trackId }) { row -> + HiddenRow(row = row, onUnflag = { onUnflag(row.trackId) }) + HorizontalDivider() + } + } +} + +@Composable +private fun HiddenRow(row: QuarantineRef, onUnflag: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + TrackCoverThumb(coverUrl = row.coverUrl, contentDescription = row.trackTitle) + Column(modifier = Modifier.weight(1f)) { + Text( + text = row.trackTitle, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${row.artistName} · ${row.albumTitle}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 4.dp), + ) { + AssistChip( + onClick = {}, + enabled = false, + label = { Text(row.reasonLabel) }, + ) + if (row.createdAt.isNotEmpty()) { + Text( + text = relativeTime(row.createdAt), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + IconButton(onClick = onUnflag) { + Icon( + Lucide.ArchiveRestore, + contentDescription = "Unhide", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +// ─── Time helper (duplicated from HistoryTab to keep the widget local) ── + +private const val MINUTES_PER_HOUR = 60L +private const val HOURS_PER_DAY = 24L +private const val DAYS_PER_WEEK = 7L + +private fun relativeTime(iso: String): String { + val parsed = runCatching { java.time.OffsetDateTime.parse(iso) }.getOrNull() + ?: return iso + val local = parsed.atZoneSameInstant(java.time.ZoneId.systemDefault()) + val now = java.time.OffsetDateTime.now(java.time.ZoneId.systemDefault()) + val minutes = java.time.temporal.ChronoUnit.MINUTES.between(local, now) + val hours = java.time.temporal.ChronoUnit.HOURS.between(local, now) + val days = java.time.temporal.ChronoUnit.DAYS.between(local, now) + return when { + minutes < MINUTES_PER_HOUR -> "${minutes.coerceAtLeast(1)}m ago" + hours < HOURS_PER_DAY -> "${hours}h ago" + days < DAYS_PER_WEEK -> { + val dow = local.dayOfWeek.getDisplayName( + java.time.format.TextStyle.SHORT, + java.util.Locale.getDefault(), + ) + val time = local.toLocalTime() + .format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")) + "$dow $time" + } + else -> { + val pattern = if (local.year == now.year) "MMM d" else "MMM d, yyyy" + local.toLocalDate().format(java.time.format.DateTimeFormatter.ofPattern(pattern)) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTabViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTabViewModel.kt new file mode 100644 index 00000000..c7831433 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTabViewModel.kt @@ -0,0 +1,82 @@ +package com.fabledsword.minstrel.quarantine.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.events.EventsStream +import com.fabledsword.minstrel.models.QuarantineRef +import com.fabledsword.minstrel.shared.UiState +import com.fabledsword.minstrel.quarantine.data.QuarantineRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val SHARE_STOP_TIMEOUT_MS = 5_000L + +@HiltViewModel +class HiddenTabViewModel @Inject constructor( + private val repository: QuarantineRepository, + private val eventsStream: EventsStream, +) : ViewModel() { + + private val refreshError = MutableStateFlow(null) + + /** + * Cache-first: emits the cached `cached_quarantine_mine` rows + * instantly (paints from disk on cold open), SWR-refreshes + * underneath, and shows Error only when there's no cache AND the + * refresh failed. Optimistic flag/unflag write the cache, so the + * list updates without waiting on the server. + */ + val uiState: StateFlow>> = + combine(repository.observeMine(), refreshError) { rows, err -> + when { + rows.isNotEmpty() -> UiState.Success(rows) + err != null -> UiState.Error(err) + else -> UiState.Empty + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = UiState.Loading, + ) + + init { + refresh() + // Live cross-device updates: any quarantine.* event (hide / + // unhide / resolve / delete from another device) re-fetches the + // snapshot while the tab is open. Mirrors Flutter's + // LiveEventsDispatcher invalidating myQuarantineProvider; here + // the screen-scoped VM subscribes directly, per the Android + // dispatcher's documented pattern for screen-scoped state. + viewModelScope.launch { + eventsStream.events + .filter { it.kind.startsWith("quarantine.") } + .collect { refresh() } + } + } + + fun refresh(): Job = viewModelScope.launch { + refreshError.value = null + runCatching { repository.refresh() } + .onFailure { refreshError.value = ErrorCopy.fromThrowable(it) } + } + + /** + * Optimistic unhide — the repository drops the cache row first (the + * observeMine Flow re-emits so the list updates instantly), then + * calls the server, enqueueing on failure so the intent persists. + */ + fun unflag(trackId: String) { + viewModelScope.launch { + runCatching { repository.unflag(trackId) } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/requests/data/RequestsRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/requests/data/RequestsRepository.kt new file mode 100644 index 00000000..5aa09172 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/requests/data/RequestsRepository.kt @@ -0,0 +1,74 @@ +package com.fabledsword.minstrel.requests.data + +import com.fabledsword.minstrel.api.endpoints.RequestsApi +import com.fabledsword.minstrel.cache.mutations.MutationQueue +import com.fabledsword.minstrel.models.RequestRef +import com.fabledsword.minstrel.models.RequestStatus +import com.fabledsword.minstrel.models.wire.RequestWire +import retrofit2.Retrofit +import retrofit2.create +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Outcome of a [RequestsRepository.cancel] call. Mirrors the + * [com.fabledsword.minstrel.likes.data.UnflagOutcome] / etc. shape. + */ +enum class CancelOutcome { Synced, Queued } + +/** + * Read-through accessor for the caller's own Lidarr requests + + * cancel-while-pending write. No Room caching for v1 — same rationale + * as HistoryRepository; the offline-snapshot mirror lands with a + * later slice. Cancel is offline-first: direct REST on the happy + * path, MutationQueue fallback on transport failure so the user's + * intent persists across connectivity loss. + */ +@Singleton +class RequestsRepository @Inject constructor( + private val mutationQueue: MutationQueue, + retrofit: Retrofit, +) { + private val api: RequestsApi = retrofit.create() + + suspend fun listMine(): List = + api.listMine().map { it.toDomain() } + + /** + * Cancels a pending request. On 2xx, returns the canonical + * cancelled row from the server (status=rejected). On transport + * failure, enqueues a REQUEST_CANCEL mutation for later replay + * and returns null — the caller's optimistic UI removal already + * reflects the user's intent, and the next listMine() refetch + * (post-replay) will surface the canonical row. + */ + suspend fun cancel(id: String): Pair = try { + CancelOutcome.Synced to api.cancel(id).toDomain() + } catch ( + @Suppress("SwallowedException") _: IOException, + ) { + mutationQueue.enqueueRequestCancel(id) + CancelOutcome.Queued to null + } +} + +// ── Mappers (internal — wire stays out of UI) ── + +private fun RequestWire.toDomain(): RequestRef = RequestRef( + id = id, + userId = userId, + status = RequestStatus.fromWire(status), + kind = kind, + artistName = artistName, + albumTitle = albumTitle, + trackTitle = trackTitle, + requestedAt = requestedAt, + decidedAt = decidedAt, + notes = notes, + importedAlbumCount = importedAlbumCount, + importedTrackCount = importedTrackCount, + matchedTrackId = matchedTrackId, + matchedAlbumId = matchedAlbumId, + matchedArtistId = matchedArtistId, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt new file mode 100644 index 00000000..2059290d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt @@ -0,0 +1,310 @@ +@file:Suppress("TooManyFunctions") // Compose screen + private helper composables + +package com.fabledsword.minstrel.requests.ui + +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.composables.icons.lucide.Disc3 +import com.composables.icons.lucide.LibraryBig +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Music +import com.composables.icons.lucide.Play +import com.composables.icons.lucide.X +import com.fabledsword.minstrel.models.RequestRef +import com.fabledsword.minstrel.models.RequestStatus +import com.fabledsword.minstrel.nav.AlbumDetail +import com.fabledsword.minstrel.shared.UiState +import com.fabledsword.minstrel.nav.ArtistDetail +import com.fabledsword.minstrel.nav.Requests +import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.LoadingCentered +import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold + +@Composable +fun RequestsScreen( + navController: NavHostController, + viewModel: RequestsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + MinstrelTopAppBar( + title = "Your requests", + navController = navController, + currentRouteName = Requests::class.qualifiedName, + onBack = { navController.popBackStack() }, + ) + }, + ) { inner -> + PullToRefreshScaffold( + onRefresh = { viewModel.refresh().join() }, + modifier = Modifier.fillMaxSize().padding(inner), + ) { + when (val s = state) { + UiState.Loading -> LoadingCentered() + UiState.Empty -> EmptyState( + title = "Nothing requested yet", + body = "Use Discover to ask Lidarr for new music; your " + + "requests show up here.", + ) + is UiState.Error -> EmptyState( + title = "Couldn't load requests", + body = s.message, + ) + is UiState.Success -> RequestList( + rows = s.data, + onCancel = viewModel::cancel, + onListen = { id -> navController.navigate(id) }, + ) + } + } + } +} + +@Composable +private fun RequestList( + rows: List, + onCancel: (String) -> Unit, + onListen: (Any) -> Unit, +) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(items = rows, key = { it.id }) { req -> + RequestRow( + req = req, + onCancel = { onCancel(req.id) }, + onListen = req.listenRoute()?.let { route -> { onListen(route) } }, + ) + HorizontalDivider() + } + } +} + +@Composable +private fun RequestRow( + req: RequestRef, + onCancel: () -> Unit, + onListen: (() -> Unit)?, +) { + var showCancelConfirm by remember { mutableStateOf(false) } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + KindAvatar(kind = req.kind) + RequestRowBody(req = req, modifier = Modifier.weight(1f)) + RequestRowAction( + req = req, + onListen = onListen, + onAskCancel = { showCancelConfirm = true }, + ) + } + if (showCancelConfirm) { + CancelConfirmDialog( + displayName = req.displayName, + onConfirm = { + showCancelConfirm = false + onCancel() + }, + onDismiss = { showCancelConfirm = false }, + ) + } +} + +@Composable +private fun RequestRowBody(req: RequestRef, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + Text( + text = req.displayName, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + modifier = Modifier.padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + StatusPill(status = req.status) + KindPill(label = req.kind) + } + val progress = req.ingestProgressText() + if (progress != null) { + Text( + text = progress, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + if (req.status == RequestStatus.REJECTED && !req.notes.isNullOrEmpty()) { + Text( + text = req.notes, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun RequestRowAction( + req: RequestRef, + onListen: (() -> Unit)?, + onAskCancel: () -> Unit, +) { + when { + onListen != null -> OutlinedButton(onClick = onListen) { + Icon( + Lucide.Play, + contentDescription = null, + modifier = Modifier.size(16.dp).padding(end = 4.dp), + ) + Text("Listen") + } + req.status == RequestStatus.PENDING -> TextButton(onClick = onAskCancel) { + Icon(Lucide.X, contentDescription = null, modifier = Modifier.padding(end = 4.dp)) + Text("Cancel") + } + } +} + +@Composable +private fun CancelConfirmDialog( + displayName: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Cancel request?") }, + text = { Text("Cancel “$displayName”? Lidarr will stop searching for it.") }, + confirmButton = { + Button(onClick = onConfirm) { Text("Cancel request") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Keep") } + }, + ) +} + +@Composable +private fun KindAvatar(kind: String) { + // Flutter mapping: disc-3 for artist, library-big for album, + // music for track. Mirrors lib/requests/requests_screen.dart. + val icon = when (kind) { + "artist" -> Lucide.Disc3 + "album" -> Lucide.LibraryBig + "track" -> Lucide.Music + else -> Lucide.Music + } + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(28.dp), + ) +} + +/** + * Most-specific listenable detail-screen route for a completed + * request, or null when nothing's been matched / the status isn't + * completed. Track-level matches resolve to AlbumDetail since the + * client has no TrackDetail screen. + */ +private fun RequestRef.listenRoute(): Any? = when { + status != RequestStatus.COMPLETED -> null + !matchedAlbumId.isNullOrEmpty() -> AlbumDetail(matchedAlbumId) + !matchedArtistId.isNullOrEmpty() -> ArtistDetail(matchedArtistId) + else -> null +} + +/** + * "2 albums · 14 tracks ingested" — null when both counts are zero. + * Singularization matches Flutter ("1 album · 1 track ingested"). + */ +private fun RequestRef.ingestProgressText(): String? { + if (importedAlbumCount == 0 && importedTrackCount == 0) return null + val parts = buildList { + if (importedAlbumCount > 0) { + add( + "$importedAlbumCount ${if (importedAlbumCount == 1) "album" else "albums"}", + ) + } + if (importedTrackCount > 0) { + add( + "$importedTrackCount ${if (importedTrackCount == 1) "track" else "tracks"}", + ) + } + } + return "${parts.joinToString(" · ")} ingested" +} + +@Composable +private fun StatusPill(status: RequestStatus) { + val (label, isError) = when (status) { + RequestStatus.PENDING -> "pending" to false + RequestStatus.APPROVED -> "approved" to false + RequestStatus.COMPLETED -> "completed" to false + RequestStatus.REJECTED -> "rejected" to true + RequestStatus.FAILED -> "failed" to true + RequestStatus.UNKNOWN -> "unknown" to false + } + val color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + AssistChip( + onClick = {}, + enabled = false, + label = { Text(label, color = color) }, + ) +} + +@Composable +private fun KindPill(label: String) { + AssistChip( + onClick = {}, + enabled = false, + label = { + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt new file mode 100644 index 00000000..1b938506 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt @@ -0,0 +1,107 @@ +package com.fabledsword.minstrel.requests.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.events.EventsStream +import com.fabledsword.minstrel.models.RequestRef +import com.fabledsword.minstrel.requests.data.RequestsRepository +import com.fabledsword.minstrel.shared.UiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +private val RELEVANT_EVENT_KINDS = setOf("request.status_changed") + +@HiltViewModel +class RequestsViewModel @Inject constructor( + private val repository: RequestsRepository, + private val eventsStream: EventsStream, +) : ViewModel() { + + private val internal = MutableStateFlow>>(UiState.Loading) + val uiState: StateFlow>> = internal.asStateFlow() + + init { + refresh() + viewModelScope.launch { + eventsStream.events + .filter { it.kind in RELEVANT_EVENT_KINDS } + .collect { refresh() } + } + } + + fun refresh(): Job = viewModelScope.launch { + internal.value = UiState.Loading + try { + val rows = repository.listMine() + internal.value = if (rows.isEmpty()) { + UiState.Empty + } else { + UiState.Success(rows) + } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.value = UiState.Error(ErrorCopy.fromThrowable(e)) + } + } + + /** + * Optimistically removes [id] from the success list so the row + * disappears immediately; on server-side success the row is + * replaced by the canonical cancelled state from the return value. + * On error rolls back by refetching the list. + */ + fun cancel(id: String) { + val before = internal.value + if (before is UiState.Success) { + val filtered = before.data.filterNot { it.id == id } + internal.value = if (filtered.isEmpty()) { + UiState.Empty + } else { + UiState.Success(filtered) + } + } + viewModelScope.launch { + try { + val (_, updated) = repository.cancel(id) + if (updated != null) { + internal.update { state -> + if (state !is UiState.Success) { + // Mid-refresh — let the refresh result win, but + // splice the cancelled row in so it shows the new + // status if the user re-pulls. + UiState.Success(listOf(updated)) + } else { + // Re-insert the cancelled row in its old position + // would require remembering the prior ordering; + // simpler to refetch in the background. + state + } + } + } + // Refresh fetches the canonical list whether the cancel + // went through live (Synced) or got enqueued (Queued). + // The Queued case shows the optimistic removal until the + // replayer drains; the next list fetch surfaces the + // server's canonical view. + refresh() + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + // Roll back by re-fetching; the failed cancel might + // already be visible to the server (eventual consistency) + // or not at all (transport drop). The refetch makes + // local state match either outcome. + refresh() + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt new file mode 100644 index 00000000..c310a496 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt @@ -0,0 +1,29 @@ +package com.fabledsword.minstrel.search.data + +import com.fabledsword.minstrel.api.endpoints.SearchApi +import com.fabledsword.minstrel.library.data.toDomain +import com.fabledsword.minstrel.models.SearchResponseRef +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Thin Retrofit wrapper around `/api/search`. Debouncing lives in + * the ViewModel, not here, so the repository stays trivial. + */ +@Singleton +class SearchRepository @Inject constructor( + retrofit: Retrofit, +) { + private val api: SearchApi = retrofit.create() + + suspend fun search(query: String): SearchResponseRef { + val wire = api.search(query) + return SearchResponseRef( + artists = wire.artists.items.map { it.toDomain() }, + albums = wire.albums.items.map { it.toDomain() }, + tracks = wire.tracks.items.map { it.toDomain() }, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt new file mode 100644 index 00000000..4cb90dbb --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt @@ -0,0 +1,321 @@ +@file:Suppress("TooManyFunctions") +// SearchScreen legitimately owns the screen-shell composable + a handful +// of small section/row helpers (ResultsPane, ResultsList, three section +// builders, two row variants, hint, loader). Splitting into a widgets/ +// subdirectory for a single screen would be over-engineering. + +package com.fabledsword.minstrel.search.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.composables.icons.lucide.ArrowLeft +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.X +import com.fabledsword.minstrel.library.widgets.AlbumCard +import com.fabledsword.minstrel.library.widgets.ArtistCard +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.models.ArtistRef +import com.fabledsword.minstrel.models.SearchResponseRef +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.nav.AlbumDetail +import com.fabledsword.minstrel.nav.ArtistDetail +import com.fabledsword.minstrel.nav.Search as SearchRoute +import com.fabledsword.minstrel.shared.widgets.TrackRow +import com.fabledsword.minstrel.shared.widgets.LoadingCentered +import com.fabledsword.minstrel.shared.widgets.MainAppBarActions +import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb +import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchScreen( + navController: NavHostController, + viewModel: SearchViewModel = hiltViewModel(), + playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val playerState by playerViewModel.uiState.collectAsStateWithLifecycle() + val playingTrackId = playerState.currentTrack?.id + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.requestFocus() } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + SearchField( + query = state.query, + onQueryChange = viewModel::setQuery, + focusRequester = focusRequester, + ) + }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Lucide.ArrowLeft, contentDescription = "Back") + } + }, + actions = { + if (state.query.isNotEmpty()) { + IconButton(onClick = { viewModel.clear() }) { + Icon(Lucide.X, contentDescription = "Clear") + } + } + MainAppBarActions( + navController = navController, + currentRouteName = SearchRoute::class.qualifiedName, + ) + }, + ) + }, + ) { inner -> + Box(modifier = Modifier.fillMaxSize().padding(inner)) { + ResultsPane( + state = state.results, + playingTrackId = playingTrackId, + onArtistClick = { id -> navController.navigate(ArtistDetail(id)) }, + onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) }, + onTrackPlay = viewModel::playTrack, + onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) }, + onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) }, + ) + } + } +} + +@Composable +private fun SearchField( + query: String, + onQueryChange: (String) -> Unit, + focusRequester: FocusRequester, +) { + TextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + placeholder = { Text("Search artists, albums, tracks") }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) +} + +@Composable +private fun ResultsPane( + state: SearchResultsState, + playingTrackId: String?, + onArtistClick: (String) -> Unit, + onAlbumClick: (String) -> Unit, + onTrackPlay: (TrackRef) -> Unit, + onNavigateToAlbum: (String) -> Unit, + onNavigateToArtist: (String) -> Unit, +) { + when (state) { + SearchResultsState.Idle -> CenteredHint("Type to search your library.") + SearchResultsState.Loading -> LoadingCentered() + is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}") + is SearchResultsState.Loaded -> { + if (state.response.isEmpty) { + CenteredHint("No matches for that query.") + } else { + ResultsList( + response = state.response, + playingTrackId = playingTrackId, + onArtistClick = onArtistClick, + onAlbumClick = onAlbumClick, + onTrackPlay = onTrackPlay, + onNavigateToAlbum = onNavigateToAlbum, + onNavigateToArtist = onNavigateToArtist, + ) + } + } + } +} + +@Composable +private fun ResultsList( + response: SearchResponseRef, + playingTrackId: String?, + onArtistClick: (String) -> Unit, + onAlbumClick: (String) -> Unit, + onTrackPlay: (TrackRef) -> Unit, + onNavigateToAlbum: (String) -> Unit, + onNavigateToArtist: (String) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + artistsSection(response.artists, onArtistClick) + albumsSection(response.albums, onAlbumClick) + tracksSection( + response.tracks, + playingTrackId, + onTrackPlay, + onNavigateToAlbum, + onNavigateToArtist, + ) + } +} + +private fun LazyListScope.artistsSection( + artists: List, + onArtistClick: (String) -> Unit, +) { + if (artists.isEmpty()) return + item { SectionHeader("Artists", artists.size) } + item { + LazyRow( + contentPadding = PaddingValues(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items = artists, key = { "art-${it.id}" }) { artist -> + ArtistCard(artist = artist, onClick = { onArtistClick(artist.id) }) + } + } + } +} + +private fun LazyListScope.albumsSection( + albums: List, + onAlbumClick: (String) -> Unit, +) { + if (albums.isEmpty()) return + item { SectionHeader("Albums", albums.size) } + item { + LazyRow( + contentPadding = PaddingValues(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items = albums, key = { "alb-${it.id}" }) { album -> + AlbumCard(album = album, onClick = { onAlbumClick(album.id) }) + } + } + } +} + +private fun LazyListScope.tracksSection( + tracks: List, + playingTrackId: String?, + onTrackPlay: (TrackRef) -> Unit, + onNavigateToAlbum: (String) -> Unit, + onNavigateToArtist: (String) -> Unit, +) { + if (tracks.isEmpty()) return + item { SectionHeader("Tracks", tracks.size) } + items(items = tracks, key = { "trk-${it.id}" }) { track -> + TrackResultRow( + track = track, + nowPlaying = track.id == playingTrackId, + onClick = { onTrackPlay(track) }, + onNavigateToAlbum = onNavigateToAlbum, + onNavigateToArtist = onNavigateToArtist, + ) + HorizontalDivider() + } +} + +@Composable +private fun TrackResultRow( + track: TrackRef, + nowPlaying: Boolean, + onClick: () -> Unit, + onNavigateToAlbum: (String) -> Unit, + onNavigateToArtist: (String) -> Unit, +) { + TrackRow( + title = track.title, + artist = track.artistName.ifEmpty { track.albumTitle }, + trackId = track.id, + onClick = onClick, + nowPlaying = nowPlaying, + enabled = track.streamUrl.isNotEmpty(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + leading = { + TrackCoverThumb(coverUrl = track.coverUrl, contentDescription = track.title) + }, + trailing = { + TrackActionsButton( + track = track, + onNavigateToAlbum = onNavigateToAlbum, + onNavigateToArtist = onNavigateToArtist, + ) + }, + ) +} + +@Composable +private fun SectionHeader(label: String, count: Int) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = "$count", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun CenteredHint(text: String) { + Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + diff --git a/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchViewModel.kt new file mode 100644 index 00000000..86860f09 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchViewModel.kt @@ -0,0 +1,111 @@ +package com.fabledsword.minstrel.search.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.models.SearchResponseRef +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.search.data.SearchRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val DEBOUNCE_MS = 250L + +sealed interface SearchResultsState { + /** Empty query — screen shows "type to search" hint. */ + data object Idle : SearchResultsState + data object Loading : SearchResultsState + data class Loaded(val response: SearchResponseRef) : SearchResultsState + data class Error(val message: String) : SearchResultsState +} + +data class SearchState( + val query: String = "", + val results: SearchResultsState = SearchResultsState.Idle, +) + +@OptIn(FlowPreview::class) +@HiltViewModel +class SearchViewModel @Inject constructor( + private val repository: SearchRepository, + private val player: PlayerController, +) : ViewModel() { + + private val queryFlow = MutableStateFlow("") + private val internal = MutableStateFlow(SearchState()) + val state: StateFlow = internal.asStateFlow() + + init { + viewModelScope.launch { + queryFlow + .map { it.trim() } + .distinctUntilChanged() + .onEach { q -> + // Empty query collapses results without firing the + // network call below. + if (q.isEmpty()) { + internal.update { + it.copy(results = SearchResultsState.Idle) + } + } + } + .debounce(DEBOUNCE_MS) + .collect { q -> + if (q.isEmpty()) return@collect + runSearch(q) + } + } + } + + fun setQuery(value: String) { + internal.update { it.copy(query = value) } + queryFlow.value = value + } + + fun clear() { + setQuery("") + } + + /** + * Tapping a search result builds a queue from the full visible + * track-results list starting at the tapped entry, matching Flutter. + * Falls back to single-track playback when the loaded state isn't + * a Success (shouldn't happen in normal flow but keeps the call + * safe). + */ + fun playTrack(track: TrackRef) { + if (track.streamUrl.isEmpty()) return + val loaded = internal.value.results as? SearchResultsState.Loaded + val playable = loaded?.response?.tracks + ?.filter { it.streamUrl.isNotEmpty() } + ?.takeIf { it.isNotEmpty() } + ?: listOf(track) + val startIndex = playable.indexOfFirst { it.id == track.id }.coerceAtLeast(0) + player.setQueue(playable, initialIndex = startIndex, source = "search") + } + + private suspend fun runSearch(q: String) { + internal.update { it.copy(results = SearchResultsState.Loading) } + try { + val response = repository.search(q) + internal.update { it.copy(results = SearchResultsState.Loaded(response)) } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.update { + it.copy(results = SearchResultsState.Error(ErrorCopy.fromThrowable(e))) + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/data/MeRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/data/MeRepository.kt new file mode 100644 index 00000000..634f8483 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/data/MeRepository.kt @@ -0,0 +1,61 @@ +package com.fabledsword.minstrel.settings.data + +import com.fabledsword.minstrel.api.endpoints.ChangePasswordRequest +import com.fabledsword.minstrel.api.endpoints.ListenBrainzPutBody +import com.fabledsword.minstrel.api.endpoints.MeApi +import com.fabledsword.minstrel.api.endpoints.UpdateProfileRequest +import com.fabledsword.minstrel.models.ListenBrainzStatus +import com.fabledsword.minstrel.models.MyProfile +import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire +import com.fabledsword.minstrel.models.wire.MyProfileWire +import retrofit2.Retrofit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Thin facade over the `/api/me` endpoints for the Settings Profile + * and Password cards. No Room caching — the Settings card fetches + * on screen mount and writes go straight to the server. Network + * errors surface to the caller for UI snackbar; no offline-queue + * path because changing your own password offline doesn't make sense. + */ +@Singleton +class MeRepository @Inject constructor(retrofit: Retrofit) { + private val api: MeApi = retrofit.create(MeApi::class.java) + + suspend fun getProfile(): MyProfile = api.getProfile().toDomain() + + suspend fun updateProfile(displayName: String?, email: String?): MyProfile = + api.updateProfile( + UpdateProfileRequest(displayName = displayName, email = email), + ).toDomain() + + suspend fun changePassword(current: String, next: String) { + api.changePassword( + ChangePasswordRequest(currentPassword = current, newPassword = next), + ) + } + + suspend fun getListenBrainz(): ListenBrainzStatus = + api.getListenBrainz().toDomain() + + suspend fun setListenBrainzToken(token: String): ListenBrainzStatus = + api.setListenBrainz(ListenBrainzPutBody(token = token)).toDomain() + + suspend fun setListenBrainzEnabled(enabled: Boolean): ListenBrainzStatus = + api.setListenBrainz(ListenBrainzPutBody(enabled = enabled)).toDomain() +} + +private fun MyProfileWire.toDomain(): MyProfile = MyProfile( + id = id, + username = username, + displayName = displayName, + email = email, + isAdmin = isAdmin, +) + +private fun ListenBrainzStatusWire.toDomain(): ListenBrainzStatus = ListenBrainzStatus( + enabled = enabled, + tokenSet = tokenSet, + lastScrobbledAt = lastScrobbledAt, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/AboutCardViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/AboutCardViewModel.kt new file mode 100644 index 00000000..d6baff99 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/AboutCardViewModel.kt @@ -0,0 +1,104 @@ +package com.fabledsword.minstrel.settings.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.BuildConfig +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.models.UpdateInfo +import com.fabledsword.minstrel.update.data.ApkInstaller +import com.fabledsword.minstrel.update.data.UpdateRepository +import com.fabledsword.minstrel.update.data.isVersionNewer +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * One of three terminal states the Check-for-updates button surfaces. + * `Idle` is the pre-check state; `Latest` means the installed build + * matches or exceeds the server's bundled APK; `UpdateAvailable` + * surfaces an "Install vX.Y.Z" button that downloads + launches the + * system installer via [ApkInstaller]. + */ +sealed interface UpdateCheckResult { + data object Idle : UpdateCheckResult + data object Latest : UpdateCheckResult + data class UpdateAvailable(val info: UpdateInfo) : UpdateCheckResult + data class Error(val message: String) : UpdateCheckResult +} + +data class AboutUiState( + val installedVersion: String = BuildConfig.VERSION_NAME, + val isChecking: Boolean = false, + val isInstalling: Boolean = false, + val installMessage: String? = null, + val result: UpdateCheckResult = UpdateCheckResult.Idle, +) + +/** + * Backs the About card's update controls. "Check for updates" calls + * [UpdateRepository.getLatest], compares versus the build's + * VERSION_NAME via [isVersionNewer], and reports the terminal state. + * When an update is available, [install] downloads the APK via + * [ApkInstaller] and hands it to the system installer — routing the + * user to the "install unknown apps" settings page first when that + * permission hasn't been granted. + */ +@HiltViewModel +class AboutCardViewModel @Inject constructor( + private val repository: UpdateRepository, + private val installer: ApkInstaller, +) : ViewModel() { + + private val internal = MutableStateFlow(AboutUiState()) + val state: StateFlow = internal.asStateFlow() + + fun checkForUpdates() { + if (internal.value.isChecking) return + viewModelScope.launch { + internal.update { it.copy(isChecking = true, installMessage = null) } + val installed = internal.value.installedVersion + val result = runCatching { repository.getLatest() } + .map { latest -> + if (isVersionNewer(latest.version, installed)) { + UpdateCheckResult.UpdateAvailable(latest) + } else { + UpdateCheckResult.Latest + } + } + .getOrElse { e -> UpdateCheckResult.Error(ErrorCopy.fromThrowable(e)) } + internal.update { it.copy(isChecking = false, result = result) } + } + } + + fun install(info: UpdateInfo) { + if (internal.value.isInstalling) return + if (!installer.canInstall()) { + installer.requestInstallPermission() + internal.update { + it.copy(installMessage = "Allow installs for Minstrel, then tap Install again.") + } + return + } + viewModelScope.launch { + internal.update { it.copy(isInstalling = true, installMessage = null) } + runCatching { installer.downloadApk(info.apkUrl) } + .onSuccess { apk -> + installer.launchInstall(apk) + internal.update { it.copy(isInstalling = false) } + } + .onFailure { e -> + val why = ErrorCopy.fromThrowable(e) + internal.update { + it.copy( + isInstalling = false, + installMessage = "Couldn't download update: $why", + ) + } + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ListenBrainzCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ListenBrainzCard.kt new file mode 100644 index 00000000..89718579 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ListenBrainzCard.kt @@ -0,0 +1,164 @@ +package com.fabledsword.minstrel.settings.ui + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fabledsword.minstrel.models.ListenBrainzStatus + +@Composable +fun ListenBrainzCard(viewModel: ListenBrainzViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "ListenBrainz", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Send your plays to ListenBrainz so you can keep your " + + "scrobble history portable. Get a token at " + + "listenbrainz.org/profile — it's stored encrypted on " + + "the server and never read back.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (state.isLoading) { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + } else { + ListenBrainzForm( + status = state.status, + tokenInput = state.tokenInput, + isSaving = state.isSaving, + message = state.message, + onTokenChange = viewModel::setTokenInput, + onSaveToken = viewModel::saveToken, + onSetEnabled = viewModel::setEnabled, + ) + } + } + } +} + +@Composable +private fun ListenBrainzForm( + status: ListenBrainzStatus?, + tokenInput: String, + isSaving: Boolean, + message: String?, + onTokenChange: (String) -> Unit, + onSaveToken: () -> Unit, + onSetEnabled: (Boolean) -> Unit, +) { + TokenField( + tokenInput = tokenInput, + tokenSet = status?.tokenSet == true, + onChange = onTokenChange, + ) + Spacer(Modifier.height(4.dp)) + SaveTokenButton( + isSaving = isSaving, + enabled = !isSaving && tokenInput.isNotBlank(), + onClick = onSaveToken, + ) + EnabledRow(status = status, isSaving = isSaving, onSetEnabled = onSetEnabled) + if (message != null) { + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun TokenField(tokenInput: String, tokenSet: Boolean, onChange: (String) -> Unit) { + OutlinedTextField( + value = tokenInput, + onValueChange = onChange, + label = { Text(if (tokenSet) "Replace token" else "Token") }, + placeholder = { Text(if (tokenSet) "Token saved" else "") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + ) +} + +@Composable +private fun SaveTokenButton(isSaving: Boolean, enabled: Boolean, onClick: () -> Unit) { + Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + ) { + if (isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.size(8.dp)) + } + Text(if (isSaving) "Saving…" else "Save token") + } +} + +@Composable +private fun EnabledRow( + status: ListenBrainzStatus?, + isSaving: Boolean, + onSetEnabled: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.padding(end = 12.dp)) { + Text( + text = "Send my plays to ListenBrainz", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (status?.lastScrobbledAt != null) { + Text( + text = "Last scrobble: ${status.lastScrobbledAt}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Switch( + checked = status?.enabled == true, + onCheckedChange = onSetEnabled, + enabled = !isSaving && status?.tokenSet == true, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ListenBrainzViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ListenBrainzViewModel.kt new file mode 100644 index 00000000..6e7bcb41 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ListenBrainzViewModel.kt @@ -0,0 +1,114 @@ +package com.fabledsword.minstrel.settings.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.models.ListenBrainzStatus +import com.fabledsword.minstrel.settings.data.MeRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class ListenBrainzUiState( + val isLoading: Boolean = true, + val isSaving: Boolean = false, + val status: ListenBrainzStatus? = null, + val tokenInput: String = "", + val message: String? = null, +) + +/** + * Backs the ListenBrainz card in Settings. Loads the caller's state + * on init, then handles token-save and enabled-toggle. The token is + * never read back from the server — the UI shows "token saved" / + * "no token" based on [ListenBrainzStatus.tokenSet] without exposing + * the value. Submit clears [tokenInput] on success so the field + * acts as a write-only entry. + */ +@HiltViewModel +class ListenBrainzViewModel @Inject constructor( + private val repository: MeRepository, +) : ViewModel() { + + private val internal = MutableStateFlow(ListenBrainzUiState()) + val state: StateFlow = internal.asStateFlow() + + init { + refresh() + } + + fun setTokenInput(value: String) { + internal.update { it.copy(tokenInput = value) } + } + + fun refresh() { + viewModelScope.launch { + internal.update { it.copy(isLoading = true, message = null) } + try { + val s = repository.getListenBrainz() + internal.update { it.copy(isLoading = false, status = s) } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.update { + it.copy( + isLoading = false, + message = ErrorCopy.fromThrowable(e), + ) + } + } + } + } + + fun saveToken() { + val token = internal.value.tokenInput.trim() + if (token.isEmpty() || internal.value.isSaving) return + viewModelScope.launch { + internal.update { it.copy(isSaving = true, message = null) } + try { + val s = repository.setListenBrainzToken(token) + internal.update { + it.copy( + isSaving = false, + status = s, + tokenInput = "", + message = "Token saved.", + ) + } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.update { + it.copy( + isSaving = false, + message = ErrorCopy.fromThrowable(e), + ) + } + } + } + } + + fun setEnabled(enabled: Boolean) { + if (internal.value.isSaving) return + viewModelScope.launch { + internal.update { it.copy(isSaving = true, message = null) } + try { + val s = repository.setListenBrainzEnabled(enabled) + internal.update { it.copy(isSaving = false, status = s) } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.update { + it.copy( + isSaving = false, + message = ErrorCopy.fromThrowable(e), + ) + } + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordCard.kt new file mode 100644 index 00000000..f54e2b02 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordCard.kt @@ -0,0 +1,96 @@ +package com.fabledsword.minstrel.settings.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +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.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@Composable +fun PasswordCard(viewModel: PasswordViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Password", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + PasswordField( + value = state.current, + label = "Current password", + onChange = viewModel::setCurrent, + ) + PasswordField( + value = state.next, + label = "New password", + onChange = viewModel::setNext, + ) + PasswordField( + value = state.confirm, + label = "Confirm new password", + onChange = viewModel::setConfirm, + ) + Spacer(Modifier.height(4.dp)) + Button( + onClick = viewModel::change, + enabled = !state.isChanging, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.isChanging) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.size(8.dp)) + } + Text(if (state.isChanging) "Changing…" else "Change password") + } + if (state.message != null) { + Text( + text = state.message!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun PasswordField( + value: String, + label: String, + onChange: (String) -> Unit, +) { + OutlinedTextField( + value = value, + onValueChange = onChange, + label = { Text(label) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordViewModel.kt new file mode 100644 index 00000000..2745ea02 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordViewModel.kt @@ -0,0 +1,74 @@ +package com.fabledsword.minstrel.settings.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.settings.data.MeRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class PasswordUiState( + val current: String = "", + val next: String = "", + val confirm: String = "", + val isChanging: Boolean = false, + val message: String? = null, +) + +/** + * Backs the password-change card in Settings. Three-field form + * (current / new / confirm) with client-side new==confirm check + * before hitting [MeRepository.changePassword]. Inline status text + * for both success and error. + */ +@HiltViewModel +class PasswordViewModel @Inject constructor( + private val repository: MeRepository, +) : ViewModel() { + + private val internal = MutableStateFlow(PasswordUiState()) + val state: StateFlow = internal.asStateFlow() + + fun setCurrent(value: String) = internal.update { it.copy(current = value, message = null) } + fun setNext(value: String) = internal.update { it.copy(next = value, message = null) } + fun setConfirm(value: String) = internal.update { it.copy(confirm = value, message = null) } + + fun change() { + val s = internal.value + val validationError: String? = when { + s.isChanging -> "" // sentinel: silent no-op + s.current.isEmpty() || s.next.isEmpty() -> "All fields required." + s.next != s.confirm -> "New passwords do not match." + else -> null + } + if (validationError != null) { + if (validationError.isNotEmpty()) { + internal.update { it.copy(message = validationError) } + } + return + } + viewModelScope.launch { + internal.update { it.copy(isChanging = true, message = null) } + try { + repository.changePassword(current = s.current, next = s.next) + internal.update { + PasswordUiState(message = "Password changed.") + } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.update { + it.copy( + isChanging = false, + message = ErrorCopy.fromThrowable(e), + ) + } + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ProfileCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ProfileCard.kt new file mode 100644 index 00000000..f76661fd --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ProfileCard.kt @@ -0,0 +1,105 @@ +package com.fabledsword.minstrel.settings.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@Composable +fun ProfileCard(viewModel: ProfileViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Profile", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (state.isLoading) { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + } else { + ProfileForm( + displayName = state.displayName, + email = state.email, + isSaving = state.isSaving, + message = state.message, + onDisplayNameChange = viewModel::setDisplayName, + onEmailChange = viewModel::setEmail, + onSave = viewModel::save, + ) + } + } + } +} + +@Composable +private fun ProfileForm( + displayName: String, + email: String, + isSaving: Boolean, + message: String?, + onDisplayNameChange: (String) -> Unit, + onEmailChange: (String) -> Unit, + onSave: () -> Unit, +) { + OutlinedTextField( + value = displayName, + onValueChange = onDisplayNameChange, + label = { Text("Display name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = email, + onValueChange = onEmailChange, + label = { Text("Email (for password reset)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Spacer(Modifier.height(4.dp)) + Button( + onClick = onSave, + enabled = !isSaving, + modifier = Modifier.fillMaxWidth(), + ) { + if (isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.size(8.dp)) + } + Text(if (isSaving) "Saving…" else "Save profile") + } + if (message != null) { + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ProfileViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ProfileViewModel.kt new file mode 100644 index 00000000..d375a7f1 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ProfileViewModel.kt @@ -0,0 +1,112 @@ +package com.fabledsword.minstrel.settings.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.models.MyProfile +import com.fabledsword.minstrel.settings.data.MeRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class ProfileUiState( + val isLoading: Boolean = true, + val isSaving: Boolean = false, + val displayName: String = "", + val email: String = "", + val loadedProfile: MyProfile? = null, + val message: String? = null, +) + +/** + * Backs the Profile card in Settings. Fetches the caller's profile + * on init via [MeRepository.getProfile]; submit calls + * [MeRepository.updateProfile] and re-hydrates the form fields from + * the canonical server response. + * + * Errors surface via [ProfileUiState.message] (inline status text + * below the Save button) rather than snackbar so the card stays + * self-contained — Settings doesn't host a screen-level Scaffold + * snackbar for the form cards. + */ +@HiltViewModel +class ProfileViewModel @Inject constructor( + private val repository: MeRepository, +) : ViewModel() { + + private val internal = MutableStateFlow(ProfileUiState()) + val state: StateFlow = internal.asStateFlow() + + init { + refresh() + } + + fun setDisplayName(value: String) { + internal.update { it.copy(displayName = value) } + } + + fun setEmail(value: String) { + internal.update { it.copy(email = value) } + } + + fun refresh() { + viewModelScope.launch { + internal.update { it.copy(isLoading = true, message = null) } + try { + val profile = repository.getProfile() + internal.update { + it.copy( + isLoading = false, + loadedProfile = profile, + displayName = profile.displayName.orEmpty(), + email = profile.email.orEmpty(), + ) + } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.update { + it.copy( + isLoading = false, + message = ErrorCopy.fromThrowable(e), + ) + } + } + } + } + + fun save() { + if (internal.value.isSaving) return + viewModelScope.launch { + internal.update { it.copy(isSaving = true, message = null) } + try { + val updated = repository.updateProfile( + displayName = internal.value.displayName.trim(), + email = internal.value.email.trim(), + ) + internal.update { + it.copy( + isSaving = false, + loadedProfile = updated, + displayName = updated.displayName.orEmpty(), + email = updated.email.orEmpty(), + message = "Profile saved.", + ) + } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.update { + it.copy( + isSaving = false, + message = ErrorCopy.fromThrowable(e), + ) + } + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt new file mode 100644 index 00000000..943b723e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt @@ -0,0 +1,424 @@ +@file:Suppress("TooManyFunctions") // Settings screen + per-card helper composables + +package com.fabledsword.minstrel.settings.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.composables.icons.lucide.ChevronRight +import com.composables.icons.lucide.ListMusic +import com.composables.icons.lucide.LogOut +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Shield +import com.fabledsword.minstrel.BuildConfig +import com.fabledsword.minstrel.nav.Admin +import com.fabledsword.minstrel.nav.Requests +import com.fabledsword.minstrel.nav.Settings as SettingsRoute +import com.fabledsword.minstrel.nav.ServerUrl +import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar +import com.fabledsword.minstrel.theme.ThemeMode +import com.fabledsword.minstrel.theme.ThemePreferenceViewModel + +@Composable +fun SettingsScreen( + navController: NavHostController, + viewModel: SettingsViewModel = hiltViewModel(), + themeVm: ThemePreferenceViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val themeMode by themeVm.themeMode.collectAsStateWithLifecycle() + var showSignOutConfirm by remember { mutableStateOf(false) } + + LaunchedEffect(state.signedOut) { + if (state.signedOut) { + navController.navigate(ServerUrl) { + popUpTo(0) { inclusive = true } + } + viewModel.consumeSignedOut() + } + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + MinstrelTopAppBar( + title = "Settings", + navController = navController, + currentRouteName = SettingsRoute::class.qualifiedName, + ) + }, + ) { inner -> + SettingsList( + inner = inner, + state = state, + themeMode = themeMode, + onPickTheme = themeVm::setThemeMode, + onNavToRequests = { navController.navigate(Requests) }, + onNavToAdmin = { navController.navigate(Admin) }, + onSignOutClick = { showSignOutConfirm = true }, + ) + } + if (showSignOutConfirm) { + SignOutConfirmDialog( + onConfirm = { + showSignOutConfirm = false + viewModel.signOut() + }, + onDismiss = { showSignOutConfirm = false }, + ) + } +} + +@Composable +private fun SettingsList( + inner: androidx.compose.foundation.layout.PaddingValues, + state: SettingsState, + themeMode: ThemeMode, + onPickTheme: (ThemeMode) -> Unit, + onNavToRequests: () -> Unit, + onNavToAdmin: () -> Unit, + onSignOutClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(inner) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + AccountCard( + username = state.username, + isAdmin = state.isAdmin, + serverUrl = state.serverUrl, + ) + NavTile( + icon = Lucide.ListMusic, + title = "My requests", + subtitle = "Track what you've asked Minstrel to add", + onClick = onNavToRequests, + ) + if (state.isAdmin) { + NavTile( + icon = Lucide.Shield, + title = "Admin", + subtitle = "Manage requests, quarantine, and users", + onClick = onNavToAdmin, + ) + } + ProfileCard() + PasswordCard() + ListenBrainzCard() + AppearanceCard(themeMode = themeMode, onPick = onPickTheme) + StorageCard() + AboutCard() + SignOutButton(isSigningOut = state.isSigningOut, onSignOut = onSignOutClick) + } +} + +@Composable +private fun SignOutConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Sign out?") }, + text = { + Text( + "Sign out of this server? Your downloaded music and " + + "settings on this device will be removed.", + ) + }, + confirmButton = { + Button( + onClick = onConfirm, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { Text("Sign out") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +@Composable +private fun NavTile( + icon: ImageVector, + title: String, + subtitle: String, + onClick: () -> Unit, +) { + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.size(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Lucide.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun AccountCard(username: String, isAdmin: Boolean, serverUrl: String) { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Account", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + val adminTag = if (isAdmin) " · admin" else "" + Text( + text = when { + username.isEmpty() -> "Signed in" + else -> "Signed in as $username$adminTag" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Server", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = serverUrl, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AppearanceCard(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) { + val options = ThemeMode.entries + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Appearance", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "System follows your device setting.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + options.forEachIndexed { index, mode -> + SegmentedButton( + selected = mode == themeMode, + onClick = { onPick(mode) }, + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = options.size, + ), + ) { Text(displayName(mode)) } + } + } + } + } +} + +private fun displayName(mode: ThemeMode): String = when (mode) { + ThemeMode.SYSTEM -> "System" + ThemeMode.LIGHT -> "Light" + ThemeMode.DARK -> "Dark" +} + +@Composable +private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "About", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Installed: Minstrel ${state.installedVersion} " + + "(${BuildConfig.VERSION_CODE})", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + UpdateControls(state = state, viewModel = viewModel) + } + } +} + +@Composable +private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) { + UpdateCheckLine(result = state.result) + Button( + onClick = viewModel::checkForUpdates, + enabled = !state.isChecking && !state.isInstalling, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.isChecking) { + ButtonSpinner() + } + Text(if (state.isChecking) "Checking…" else "Check for updates") + } + val available = state.result as? UpdateCheckResult.UpdateAvailable + if (available != null) { + InstallButton( + version = available.info.version, + isInstalling = state.isInstalling, + onClick = { viewModel.install(available.info) }, + ) + } + if (state.installMessage != null) { + Text( + text = state.installMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun InstallButton(version: String, isInstalling: Boolean, onClick: () -> Unit) { + Button( + onClick = onClick, + enabled = !isInstalling, + modifier = Modifier.fillMaxWidth(), + ) { + if (isInstalling) { + ButtonSpinner() + } + Text(if (isInstalling) "Downloading…" else "Install $version") + } +} + +@Composable +private fun ButtonSpinner() { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.size(8.dp)) +} + +@Composable +private fun UpdateCheckLine(result: UpdateCheckResult) { + val text = when (result) { + UpdateCheckResult.Idle -> return + UpdateCheckResult.Latest -> "You're on the latest version." + is UpdateCheckResult.UpdateAvailable -> + "Update available: ${result.info.version}" + is UpdateCheckResult.Error -> "Couldn't check: ${result.message}" + } + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun SignOutButton(isSigningOut: Boolean, onSignOut: () -> Unit) { + Button( + onClick = onSignOut, + enabled = !isSigningOut, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { + if (isSigningOut) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onErrorContainer, + strokeWidth = 2.dp, + ) + } else { + Box(modifier = Modifier.padding(end = 4.dp)) { + Icon(Lucide.LogOut, contentDescription = null) + } + Text("Sign out") + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsViewModel.kt new file mode 100644 index 00000000..ebe2f563 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsViewModel.kt @@ -0,0 +1,75 @@ +package com.fabledsword.minstrel.settings.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.auth.AuthController +import com.fabledsword.minstrel.auth.AuthStore +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class SettingsState( + val serverUrl: String = "", + val username: String = "", + val isAdmin: Boolean = false, + val isSigningOut: Boolean = false, + val signedOut: Boolean = false, +) + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val authController: AuthController, + authStore: AuthStore, +) : ViewModel() { + + private val transient = MutableStateFlow(TransientState()) + private val internal: MutableStateFlow = MutableStateFlow(SettingsState()) + val state: StateFlow = internal.asStateFlow() + + init { + // Compose the user-visible state from the auth observables + // (so cross-restart rehydration shows the right username) plus + // the local sign-out spinner. Snapshotting currentUser at + // construction time would miss the rehydration that arrives + // microseconds after; collecting it keeps the card live. + viewModelScope.launch { + combine( + authStore.baseUrl, + authController.currentUser, + transient, + ) { url, user, t -> + SettingsState( + serverUrl = url, + username = user?.username.orEmpty(), + isAdmin = user?.isAdmin == true, + isSigningOut = t.isSigningOut, + signedOut = t.signedOut, + ) + }.collect { internal.value = it } + } + } + + fun signOut() { + if (transient.value.isSigningOut) return + viewModelScope.launch { + transient.update { it.copy(isSigningOut = true) } + authController.signOut() + transient.update { it.copy(isSigningOut = false, signedOut = true) } + } + } + + fun consumeSignedOut() { + transient.update { it.copy(signedOut = false) } + } + + /** Local-only screen state that isn't owned by AuthStore/Controller. */ + private data class TransientState( + val isSigningOut: Boolean = false, + val signedOut: Boolean = false, + ) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageCard.kt new file mode 100644 index 00000000..351eb31b --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageCard.kt @@ -0,0 +1,242 @@ +package com.fabledsword.minstrel.settings.ui + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +private const val GIB: Long = 1024L * 1024 * 1024 +private const val MIB: Long = 1024L * 1024 +private const val KIB: Long = 1024L + +private val CAP_OPTIONS: List> = listOf( + 1L * GIB to "1 GB", + 5L * GIB to "5 GB", + 10L * GIB to "10 GB", + 25L * GIB to "25 GB", + 0L to "Unlimited", +) + +@Composable +fun StorageCard(viewModel: StorageViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + var showClearConfirm by remember { mutableStateOf(false) } + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Storage", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + UsageRow(usedBytes = state.usedBytes) + // Per-bucket caps + prefetch toggle + cache-liked toggle + // intentionally omitted in v1. The underlying systems + // (per-bucket SimpleCache eviction, MetadataPrefetcher, + // pin-on-like flow) don't exist on Android yet — surfacing + // controls that silently do nothing is worse than hiding + // them. Only the rolling cap is wired + // (PlayerFactory.kt:43-47 uses rollingCapBytes only); the + // Liked cap was a no-op. CacheSettings fields stay + // persisted so the controls return when the systems land. + CapDropdown( + label = "Cache limit", + value = state.settings.rollingCapBytes, + onSet = viewModel::setRollingCapBytes, + ) + Text( + text = "Takes effect on the next app launch.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + StorageActions( + isSyncing = state.isSyncing, + isClearing = state.isClearing, + onSync = viewModel::syncNow, + onClear = { showClearConfirm = true }, + ) + } + } + if (showClearConfirm) { + ClearCacheConfirmDialog( + onConfirm = { + showClearConfirm = false + viewModel.clearCache() + }, + onDismiss = { showClearConfirm = false }, + ) + } +} + +@Composable +private fun UsageRow(usedBytes: Long) { + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Used", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Text( + text = formatBytes(usedBytes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun CapDropdown(label: String, value: Long, onSet: (Long) -> Unit) { + val selectedLabel = CAP_OPTIONS.firstOrNull { it.first == value }?.second + ?: formatBytes(value) + LabeledDropdown( + label = label, + selectedLabel = selectedLabel, + options = CAP_OPTIONS, + onSelect = onSet, + ) +} + +/** + * Label on the left, button-with-current-value on the right that opens + * a DropdownMenu of [options]. Used by the cap selectors. + */ +@Composable +private fun LabeledDropdown( + label: String, + selectedLabel: String, + options: List>, + onSelect: (T) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + OutlinedButton(onClick = { expanded = true }) { + Text(selectedLabel) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + options.forEach { (value, name) -> + DropdownMenuItem( + text = { Text(name) }, + onClick = { + expanded = false + onSelect(value) + }, + ) + } + } + } + } +} + +@Composable +private fun StorageActions( + isSyncing: Boolean, + isClearing: Boolean, + onSync: () -> Unit, + onClear: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = onClear, + enabled = !isClearing, + ) { + if (isClearing) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.size(8.dp)) + } + Text(if (isClearing) "Clearing…" else "Clear cache") + } + OutlinedButton( + onClick = onSync, + enabled = !isSyncing, + ) { + if (isSyncing) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.size(8.dp)) + } + Text(if (isSyncing) "Syncing…" else "Sync now") + } + } +} + +@Composable +private fun ClearCacheConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Clear cache?") }, + text = { + Text( + "This deletes all cached audio. The next play of any track " + + "re-downloads from the server.", + ) + }, + confirmButton = { + Button( + onClick = onConfirm, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { Text("Clear") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +private fun formatBytes(n: Long): String { + if (n <= 0L) return "0 B" + return when { + n < KIB -> "$n B" + n < MIB -> "${"%.1f".format(n.toDouble() / KIB)} KB" + n < GIB -> "${"%.1f".format(n.toDouble() / MIB)} MB" + else -> "${"%.2f".format(n.toDouble() / GIB)} GB" + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageViewModel.kt new file mode 100644 index 00000000..e87a60ac --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageViewModel.kt @@ -0,0 +1,124 @@ +package com.fabledsword.minstrel.settings.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.auth.AuthStore +import com.fabledsword.minstrel.cache.audiocache.CacheSettings +import com.fabledsword.minstrel.cache.sync.SyncController +import com.fabledsword.minstrel.player.PlayerFactory +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +data class StorageUiState( + val settings: CacheSettings = CacheSettings.DEFAULT, + val usedBytes: Long = 0L, + val isSyncing: Boolean = false, + val isClearing: Boolean = false, +) + +/** + * Backs the Storage section of the Settings screen. Exposes the + * persisted CacheSettings + the current SimpleCache usage, plus + * actions for "Sync now" and "Clear cache". + * + * Settings changes go through AuthStore.setCacheSettings (write- + * through to Room). The cap settings only take effect on next app + * launch because SimpleCache is constructed once per process; the + * UI surfaces that caveat (next commit). + */ +@HiltViewModel +class StorageViewModel @Inject constructor( + private val authStore: AuthStore, + private val playerFactory: PlayerFactory, + private val syncController: SyncController, +) : ViewModel() { + + private val internal = MutableStateFlow(StorageUiState()) + val state: StateFlow = internal.asStateFlow() + + init { + viewModelScope.launch { + authStore.cacheSettings.collect { s -> + internal.update { it.copy(settings = s) } + } + } + refreshUsage() + } + + fun setLikedCapBytes(bytes: Long) { + val current = authStore.cacheSettings.value + authStore.setCacheSettings(current.copy(likedCapBytes = bytes)) + } + + fun setRollingCapBytes(bytes: Long) { + val current = authStore.cacheSettings.value + authStore.setCacheSettings(current.copy(rollingCapBytes = bytes)) + } + + fun setPrefetchWindow(n: Int) { + val clamped = n.coerceIn(1, MAX_PREFETCH_WINDOW) + val current = authStore.cacheSettings.value + authStore.setCacheSettings(current.copy(prefetchWindow = clamped)) + } + + fun setCacheLikedTracks(on: Boolean) { + val current = authStore.cacheSettings.value + authStore.setCacheSettings(current.copy(cacheLikedTracks = on)) + } + + fun syncNow() { + if (internal.value.isSyncing) return + viewModelScope.launch { + internal.update { it.copy(isSyncing = true) } + try { + syncController.syncSafe() + } finally { + internal.update { it.copy(isSyncing = false) } + } + } + } + + fun clearCache() { + if (internal.value.isClearing) return + viewModelScope.launch { + internal.update { it.copy(isClearing = true) } + try { + withContext(Dispatchers.IO) { + // SimpleCache.getKeys() + removeResource is the safe + // path — releasing the cache mid-flight would crash + // any in-progress playback. Iterating + removing + // works while the cache is in use. + val cache = playerFactory.simpleCache + val keys = cache.keys.toList() + for (k in keys) { + runCatching { cache.removeResource(k) } + } + } + refreshUsage() + } finally { + internal.update { it.copy(isClearing = false) } + } + } + } + + /** Recomputes used bytes from SimpleCache. Cheap; just reads a long. */ + private fun refreshUsage() { + viewModelScope.launch { + val used = withContext(Dispatchers.IO) { + runCatching { playerFactory.simpleCache.cacheSpace }.getOrDefault(0L) + } + internal.update { it.copy(usedBytes = used) } + } + } + + companion object { + private const val MAX_PREFETCH_WINDOW = 10 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/CacheFirstStateFlow.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/CacheFirstStateFlow.kt new file mode 100644 index 00000000..39cd22e2 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/CacheFirstStateFlow.kt @@ -0,0 +1,31 @@ +package com.fabledsword.minstrel.shared + +import com.fabledsword.minstrel.api.ErrorCopy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.stateIn + +private const val SHARE_STOP_TIMEOUT_MS = 5_000L + +/** + * Terminal operator for the cache-first VM idiom: a Flow that maps + * cache emissions to `UiState.Success` / `UiState.Empty` becomes a + * subscriber-active StateFlow that starts in [UiState.Loading] and + * routes any upstream throwable through [ErrorCopy] into + * [UiState.Error]. + * + * Replaces the catch + stateIn(viewModelScope, WhileSubscribed, + * Loading) tail that every cache-first ViewModel writes today. + */ +fun Flow>.asCacheFirstStateFlow( + scope: CoroutineScope, +): StateFlow> = this + .catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) } + .stateIn( + scope = scope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = UiState.Loading, + ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/Formatting.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/Formatting.kt new file mode 100644 index 00000000..effba8d9 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/Formatting.kt @@ -0,0 +1,23 @@ +package com.fabledsword.minstrel.shared + +const val MILLIS_PER_SECOND = 1000 + +private const val SECONDS_PER_MINUTE = 60 + +/** + * Formats [seconds] as m:ss. Returns [zero] when [seconds] <= 0 — pass + * "--:--" for unknown track durations, "0:00" (default) for playback + * position/duration. The single mm:ss formatter for the client. + */ +fun formatDuration(seconds: Int, zero: String = "0:00"): String = + if (seconds <= 0) { + zero + } else { + "%d:%02d".format(seconds / SECONDS_PER_MINUTE, seconds % SECONDS_PER_MINUTE) + } + +fun Int.secondsToMillis(): Int = this * MILLIS_PER_SECOND + +fun Int.millisToSeconds(): Int = this / MILLIS_PER_SECOND + +fun Long.millisToSeconds(): Int = (this / MILLIS_PER_SECOND).toInt() diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/ServerUrls.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/ServerUrls.kt new file mode 100644 index 00000000..1caeb7b1 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/ServerUrls.kt @@ -0,0 +1,26 @@ +package com.fabledsword.minstrel.shared + +/** + * Resolves a relative server URL into something a Coil / OkHttp client + * can actually load. + * + * The server returns relative paths (e.g. `/api/albums/{id}/cover`, + * `/api/tracks/{id}/stream`) — host-less URIs that `OkHttpDataSource` + * and Coil's `AsyncImage` can't resolve on their own. Prepending the + * `placeholder.invalid` host lets `BaseUrlInterceptor` rewrite the + * request to the live server with the auth cookie. Absolute `http(s)` + * URLs pass through unchanged; blank input returns null so callers + * render their own fallback (or, for playback, the queue entry fails + * fast rather than handing Media3 a malformed URI). + * + * This is the single place a server URL becomes loadable, mirroring + * Flutter's `ServerImage._resolve` (which prepends the base URL). + * Any code handing a server-provided URL to a network client must + * route through here — covers, stream URLs, anything `/api/...`. + */ +fun resolveServerUrl(raw: String?): String? = when { + raw.isNullOrBlank() -> null + raw.startsWith("http://") || raw.startsWith("https://") -> raw + raw.startsWith("/") -> "http://placeholder.invalid$raw" + else -> "http://placeholder.invalid/$raw" +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/UiState.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/UiState.kt new file mode 100644 index 00000000..43ee2c44 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/UiState.kt @@ -0,0 +1,22 @@ +package com.fabledsword.minstrel.shared + +/** + * Shared cache-first UI state for a screen / tab that resolves to a + * single payload. Mirrors the shape every screen had reinvented: + * - [Loading] cold start, before either the cache or the refresh + * has emitted anything + * - [Empty] the cache + refresh both succeeded with no data + * - [Success] the cache (or refresh) has a payload — render it + * - [Error] no cached data + the refresh failed; UI surfaces + * [message] from `ErrorCopy.fromThrowable` + * + * Each screen / tab parameterizes [T] with whatever it actually + * renders (`HomeSections`, `List`, etc.), so a single + * generic replaces the per-screen sealed interface declarations. + */ +sealed interface UiState { + data object Loading : UiState + data object Empty : UiState + data class Success(val data: T) : UiState + data class Error(val message: String) : UiState +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/AppBarActionsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/AppBarActionsViewModel.kt new file mode 100644 index 00000000..e93242e6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/AppBarActionsViewModel.kt @@ -0,0 +1,33 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.auth.AuthController +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +private const val SHARE_STOP_TIMEOUT_MS = 5_000L + +/** + * Backs [MainAppBarActions] with the current user's admin flag so the + * overflow "Admin" item appears for every admin without each shell + * screen having to plumb the value through. Sourced from + * [AuthController.currentUser] so it stays live across sign-in, + * sign-out, and role changes pushed via rehydration. + */ +@HiltViewModel +class AppBarActionsViewModel @Inject constructor( + authController: AuthController, +) : ViewModel() { + val isAdmin: StateFlow = authController.currentUser + .map { it?.isAdmin == true } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = false, + ) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/CachedDot.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/CachedDot.kt new file mode 100644 index 00000000..8405f5a6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/CachedDot.kt @@ -0,0 +1,40 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.composables.icons.lucide.CircleCheckBig +import com.composables.icons.lucide.Lucide +import com.fabledsword.minstrel.theme.LocalFabledSwordTheme + +/** + * Track ids with audio cached locally, provided once at the app root + * from `CachedTrackIds`. Defaults to empty so any unwrapped preview / + * test simply shows no dots rather than crashing. + */ +val LocalCachedTrackIds = staticCompositionLocalOf { emptySet() } + +/** + * Small accent download glyph shown next to a track row when the track + * is cached locally. Ports Flutter's `CachedIndicator` + * (`circle_check_big`, size 14, accent, 4px left pad) — renders nothing + * when the track isn't cached. Reads the reactive set from + * [LocalCachedTrackIds], so the dot appears/disappears as the cache + * fills and evicts without per-row queries. + */ +@Composable +fun CachedDot(trackId: String, modifier: Modifier = Modifier) { + if (trackId !in LocalCachedTrackIds.current) return + Icon( + imageVector = Lucide.CircleCheckBig, + contentDescription = "Downloaded", + tint = LocalFabledSwordTheme.current.accent, + modifier = modifier + .padding(start = 4.dp) + .size(14.dp), + ) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/CoverTile.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/CoverTile.kt new file mode 100644 index 00000000..b51ef75f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/CoverTile.kt @@ -0,0 +1,56 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +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.graphics.Shape +import androidx.compose.ui.unit.Dp +import com.fabledsword.minstrel.theme.FabledSwordFlatTokens + +/** + * Shared cover artwork tile: clipped, optionally backgrounded `Box` + * with a [ServerImage] inside and a per-caller [fallback] icon. Used + * by `AlbumCard`, `ArtistCard`, `PlaylistCard` — they vary on size / + * shape / fallback icon / background, but the surrounding Box + + * ServerImage + fallback structure was identical at all three sites. + * + * [overlay] is a `BoxScope` slot for things drawn on top of the cover + * (e.g. the `VariantPill` system-playlist label) — callers can use + * `Modifier.align(...)` inside it. + */ +@Composable +fun CoverTile( + url: String?, + contentDescription: String?, + size: Dp, + fallback: @Composable () -> Unit, + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(FabledSwordFlatTokens.radiusSm), + background: Color = Color.Transparent, + overlay: @Composable BoxScope.() -> Unit = {}, +) { + Box( + modifier = modifier + .size(size) + .clip(shape) + .background(background), + contentAlignment = Alignment.Center, + ) { + ServerImage( + url = url, + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + ) { + fallback() + } + overlay() + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/EmptyState.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/EmptyState.kt new file mode 100644 index 00000000..26694390 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/EmptyState.kt @@ -0,0 +1,76 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.material3.Icon +import androidx.compose.ui.graphics.vector.ImageVector +import com.composables.icons.lucide.Inbox +import com.composables.icons.lucide.Lucide + +/** + * Shared empty-state widget. Used wherever a screen / section has no + * content to show — Library before first sync, Quarantine when no + * tracks are hidden, search results, etc. + * + * Per the FabledSword design system: sentence case copy, understated + * tone. The icon defaults to Lucide's `Inbox`; callers pass their own + * when a more topical icon helps. + * + * Renders inside a single-item LazyColumn so the widget participates + * in nested-scroll dispatch when wrapped by PullToRefreshBox — + * pull-to-refresh fires on Empty / Error states without the content + * actually scrolling. The fillParentMaxSize on the inner Box keeps + * the centered visual identical to the pre-LazyColumn layout. + */ +@Composable +fun EmptyState( + title: String, + body: String, + modifier: Modifier = Modifier, + icon: ImageVector = Lucide.Inbox, +) { + LazyColumn(modifier = modifier.fillMaxSize()) { + item { + Box( + modifier = Modifier + .fillParentMaxSize() + .padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = title, + modifier = Modifier.padding(top = 16.dp), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Text( + text = body, + modifier = Modifier.padding(top = 8.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ErrorRetry.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ErrorRetry.kt new file mode 100644 index 00000000..a77f3aeb --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ErrorRetry.kt @@ -0,0 +1,66 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.TriangleAlert +import com.fabledsword.minstrel.theme.LocalActionColors + +/** + * Shared error-with-retry widget. Surfaces a brief message and a + * primary action button. Per the design system, the button uses + * `LocalActionColors.primary` (Moss) — NEVER the accent. + */ +@Composable +fun ErrorRetry( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, + retryLabel: String = "Retry", +) { + val actionColors = LocalActionColors.current + Column( + modifier = modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Lucide.TriangleAlert, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.error, + ) + Text( + text = message, + modifier = Modifier.padding(top = 16.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Button( + onClick = onRetry, + modifier = Modifier.padding(top = 16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = actionColors.primary, + contentColor = actionColors.onAction, + ), + ) { + Text(retryLabel) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/HorizontalScrollRow.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/HorizontalScrollRow.kt new file mode 100644 index 00000000..d1e4642f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/HorizontalScrollRow.kt @@ -0,0 +1,50 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.foundation.layout.Arrangement +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.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +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 + +/** + * Labeled horizontal-scroll section used throughout the Home screen. + * Mirrors `flutter_client/lib/library/widgets/horizontal_scroll_row.dart` + * — a Fraunces section title at 16dp gutter, then a horizontal LazyRow + * with the same gutter and 8dp inter-item spacing. + * + * Pass empty `title` to render a continuation row directly under a + * previously-titled one (used by Rediscover when it has both album and + * artist sub-rows, and by chunked sections like Recently-added). + * + * The `content` lambda receives a `LazyListScope` so callers use + * `items(list, key = ...) { ... }` — preserves lazy item rendering. + */ +@Composable +fun HorizontalScrollRow( + title: String, + modifier: Modifier = Modifier, + content: LazyListScope.() -> Unit, +) { + Column(modifier = modifier.fillMaxWidth()) { + if (title.isNotEmpty()) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + content = content, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/LikeButton.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/LikeButton.kt new file mode 100644 index 00000000..3ec7722f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/LikeButton.kt @@ -0,0 +1,38 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.composables.icons.lucide.Heart +import com.composables.icons.lucide.Lucide + +/** + * Heart toggle. Callers own the state — pass `liked: Boolean` + + * `onToggle: () -> Unit`. The optimistic-write rule lives in the + * caller's ViewModel (typically observeIsLiked + toggleLike from + * LikesRepository), not here. + * + * Flutter equivalent is `flutter_client/lib/likes/like_button.dart`. + * That one tints the heart with the accent color; we do the same via + * the M3 primary slot so light/dark mode tracks the theme. + */ +@Composable +fun LikeButton( + liked: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier, +) { + IconButton(onClick = onToggle, modifier = modifier) { + Icon( + imageVector = Lucide.Heart, + contentDescription = if (liked) "Unlike" else "Like", + tint = if (liked) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/LoadingCentered.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/LoadingCentered.kt new file mode 100644 index 00000000..fce7ec60 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/LoadingCentered.kt @@ -0,0 +1,36 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +/** + * Shared loading-spinner widget for screens that wrap their content in + * [PullToRefreshScaffold]. Rendered inside a single-item LazyColumn + * (mirroring [EmptyState]) so the widget participates in nested-scroll + * dispatch — pull-to-refresh fires on the Loading branch, letting users + * recover from a stuck cold-load via pull-down instead of having to + * kill the process. + * + * Replaces the per-screen `private fun LoadingCentered()` duplicates + * that used a bare `Box(fillMaxSize)` and silently swallowed the + * pull-to-refresh gesture. + */ +@Composable +fun LoadingCentered(modifier: Modifier = Modifier) { + LazyColumn(modifier = modifier.fillMaxSize()) { + item { + Box( + modifier = Modifier.fillParentMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/MainAppBarActions.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/MainAppBarActions.kt new file mode 100644 index 00000000..a1707c59 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/MainAppBarActions.kt @@ -0,0 +1,99 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.setValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.composables.icons.lucide.House +import com.composables.icons.lucide.LibraryBig +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.EllipsisVertical +import com.composables.icons.lucide.Search as SearchIcon +import com.fabledsword.minstrel.nav.Admin +import com.fabledsword.minstrel.nav.Discover +import com.fabledsword.minstrel.nav.Home +import com.fabledsword.minstrel.nav.Library +import com.fabledsword.minstrel.nav.Playlists +import com.fabledsword.minstrel.nav.Search as SearchRoute +import com.fabledsword.minstrel.nav.Settings as SettingsRoute + +/** + * The universal navigation surface for every shell screen — Flutter's + * `MainAppBarActions`. There is no bottom nav, drawer, or rail; every + * top-level AppBar carries this row, and the kebab covers the rest. + * + * [Home] [Library] [Search] [ ⋮ Playlists / Discover / Settings / Admin ] + * + * The icon for the screen the user is currently on is suppressed so + * the action set looks contextual. The "Admin" overflow item is + * sourced internally from [AppBarActionsViewModel] so admins see it + * everywhere without each call site having to plumb the flag. + * + * `currentRouteName` is a soft string match against the destination + * route name (route classes serialize to their FQN by default in + * nav-compose); pass `Home::class.qualifiedName` etc. from the screen. + */ +@Composable +fun MainAppBarActions( + navController: NavHostController, + currentRouteName: String?, + viewModel: AppBarActionsViewModel = hiltViewModel(), +) { + val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle() + Row { + if (currentRouteName != Home::class.qualifiedName) { + IconButton(onClick = { navController.navigate(Home) }) { + Icon(Lucide.House, contentDescription = "Home") + } + } + if (currentRouteName != Library::class.qualifiedName) { + IconButton(onClick = { navController.navigate(Library) }) { + Icon(Lucide.LibraryBig, contentDescription = "Library") + } + } + if (currentRouteName != SearchRoute::class.qualifiedName) { + IconButton(onClick = { navController.navigate(SearchRoute) }) { + Icon(Lucide.SearchIcon, contentDescription = "Search") + } + } + OverflowMenu(navController = navController, isAdmin = isAdmin) + } +} + +@Composable +private fun OverflowMenu(navController: NavHostController, isAdmin: Boolean) { + var expanded by remember { mutableStateOf(false) } + IconButton(onClick = { expanded = true }) { + Icon(Lucide.EllipsisVertical, contentDescription = "More") + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text("Playlists") }, + onClick = { expanded = false; navController.navigate(Playlists) }, + ) + DropdownMenuItem( + text = { Text("Discover") }, + onClick = { expanded = false; navController.navigate(Discover) }, + ) + DropdownMenuItem( + text = { Text("Settings") }, + onClick = { expanded = false; navController.navigate(SettingsRoute) }, + ) + if (isAdmin) { + DropdownMenuItem( + text = { Text("Admin") }, + onClick = { expanded = false; navController.navigate(Admin) }, + ) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/MinstrelTopAppBar.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/MinstrelTopAppBar.kt new file mode 100644 index 00000000..7700766b --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/MinstrelTopAppBar.kt @@ -0,0 +1,50 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController +import com.composables.icons.lucide.ArrowLeft +import com.composables.icons.lucide.Lucide + +/** + * The standard Minstrel top app bar — a `Text(title)` on the left, the + * universal [MainAppBarActions] row on the right, optionally preceded + * by extra screen-specific actions and a back arrow. + * + * Replaces the TopAppBar + title + navigationIcon + MainAppBarActions + * sandwich that was duplicated across ~10 screens. Library wraps this + * in a `Column` with its tab row; drill-down screens pass [onBack] + * for the back arrow; Library passes its Shuffle button via [actions]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MinstrelTopAppBar( + title: String, + navController: NavHostController, + currentRouteName: String?, + onBack: (() -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {}, +) { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + if (onBack != null) { + IconButton(onClick = onBack) { + Icon(Lucide.ArrowLeft, contentDescription = "Back") + } + } + }, + actions = { + actions() + MainAppBarActions( + navController = navController, + currentRouteName = currentRouteName, + ) + }, + ) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/PullToRefreshScaffold.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/PullToRefreshScaffold.kt new file mode 100644 index 00000000..4a83d0ed --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/PullToRefreshScaffold.kt @@ -0,0 +1,55 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +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.Modifier +import kotlinx.coroutines.launch + +/** + * Wraps [content] in a Material3 PullToRefreshBox so the user can + * swipe-down to trigger [onRefresh]. The wrapper manages the + * `isRefreshing` indicator while [onRefresh] is in flight. + * + * [onRefresh] is suspend: pass `{ viewModel.refresh().join() }` so the + * indicator hides exactly when the underlying coroutine completes, + * not on a heuristic delay. ViewModels whose existing `refresh()` + * launched into viewModelScope just need to start returning the Job + * (`fun refresh(): Job = viewModelScope.launch { ... }`). + * + * Mirrors the Flutter `RefreshIndicator` wrapping that every list + + * grid has in the web/mobile client — Android v1 was missing this + * everywhere (audit #8). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PullToRefreshScaffold( + onRefresh: suspend () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + var isRefreshing by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { + scope.launch { + isRefreshing = true + try { + onRefresh() + } finally { + isRefreshing = false + } + } + }, + modifier = modifier.fillMaxSize(), + ) { + content() + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ServerImage.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ServerImage.kt new file mode 100644 index 00000000..f30b9732 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ServerImage.kt @@ -0,0 +1,33 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage +import com.fabledsword.minstrel.shared.resolveServerUrl + +/** + * Renders a server-hosted image, resolving relative URLs centrally so + * every cover surface loads consistently. Shows [fallback] when the URL + * is blank or unresolvable. + */ +@Composable +fun ServerImage( + url: String?, + contentDescription: String?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Crop, + fallback: @Composable () -> Unit = {}, +) { + val resolved = resolveServerUrl(url) + if (resolved == null) { + fallback() + } else { + AsyncImage( + model = resolved, + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt new file mode 100644 index 00000000..423a5e9d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt @@ -0,0 +1,89 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavHostController +import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner +import com.fabledsword.minstrel.nav.AlbumDetail +import com.fabledsword.minstrel.nav.ArtistDetail +import com.fabledsword.minstrel.player.ui.MiniPlayer +import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel +import com.fabledsword.minstrel.update.ui.UpdateBanner +import com.fabledsword.minstrel.update.ui.VersionTooOldBanner +import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel + +/** + * Outer shell wrapper for "in-app" routes — banners on top + * (conditional, none implemented yet), routed screen filling the + * middle, MiniPlayer pinned at the bottom (auto-hides when no track). + * A shell-scoped SnackbarHost above the MiniPlayer surfaces transient + * messages emitted by the MiniPlayer's TrackActionsButton (snackbars + * for in-screen kebabs continue to live on each screen's own Scaffold). + * + * Mirrors the Flutter `_ShellWithPlayerBar` (lib/shared/routing.dart). + * + * Top-level routes (Home / Library / Search / Discover / Playlists / + * Settings / Admin / detail screens) wrap themselves with this. + * Full-screen routes (NowPlaying / Queue / ServerUrl / Login) + * intentionally do NOT — they fill the entire screen, often with a + * back-only AppBar. + * + * [onExpandPlayer] is the navigate-to-NowPlaying callback passed + * through to MiniPlayer's tap-handler. [navController] is needed by + * the MiniPlayer kebab for go-to-album / go-to-artist nav. + */ +@Composable +fun ShellScaffold( + navController: NavHostController, + onExpandPlayer: () -> Unit, + modifier: Modifier = Modifier, + trackActionsViewModel: TrackActionsViewModel = hiltViewModel(), + playbackErrorViewModel: PlaybackErrorViewModel = hiltViewModel(), + content: @Composable () -> Unit, +) { + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(Unit) { + trackActionsViewModel.transientMessages.collect { msg -> + snackbarHostState.showSnackbar(msg) + } + } + LaunchedEffect(Unit) { + playbackErrorViewModel.messages.collect { msg -> + snackbarHostState.showSnackbar(msg) + } + } + // Consume the status-bar inset once here so the banner stack sits + // below the status bar (mirrors Flutter's SafeArea(bottom:false)). + // statusBarsPadding consumes the inset for descendants, so the in- + // shell screens' app bars no longer re-pad for the status bar — + // avoiding a double inset while fixing banners drawing under it. + Column(modifier = modifier.fillMaxSize().statusBarsPadding()) { + // Banner slot. VersionTooOldBanner appears when the server's + // min_client_version exceeds our build; UpdateBanner softly + // nudges an available (newer-than-installed) APK; and + // ConnectionErrorBanner shows when the device loses usable + // internet. + VersionTooOldBanner() + UpdateBanner() + ConnectionErrorBanner() + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + content() + } + SnackbarHost(hostState = snackbarHostState) + MiniPlayer( + onExpandClick = onExpandPlayer, + onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) }, + onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) }, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/Skeletons.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/Skeletons.kt new file mode 100644 index 00000000..5fe31a88 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/Skeletons.kt @@ -0,0 +1,143 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +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.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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp +import com.fabledsword.minstrel.theme.FabledSwordFlatTokens + +/** + * Skeleton placeholders for cold-load states. Mirror Flutter's + * `SkeletonAlbumTile` / `SkeletonArtistTile` / `_CompactTrackSkeleton` + * — same geometry as the real cards so the layout doesn't reflow when + * real data lands and the screen-level Crossfade reveals. + */ + +private const val PULSE_MIN_ALPHA = 0.3f +private const val PULSE_MAX_ALPHA = 0.55f +private const val PULSE_DURATION_MS = 800 + +/** + * Solid-color box with a slow alpha pulse. Building block for the + * tile-shape composites below. Mirrors the surfaceVariant fill the + * real cards use as their cover-loading background. + */ +@Composable +fun SkeletonBox( + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(FabledSwordFlatTokens.radiusSm), +) { + val transition = rememberInfiniteTransition(label = "skeleton-pulse") + val alpha by transition.animateFloat( + initialValue = PULSE_MIN_ALPHA, + targetValue = PULSE_MAX_ALPHA, + animationSpec = infiniteRepeatable( + animation = tween(PULSE_DURATION_MS, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "skeleton-alpha", + ) + Box( + modifier = modifier + .clip(shape) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha)), + ) +} + +/** + * Stand-in for AlbumCard / PlaylistCard (same 176dp×144dp cover + + * 2 caption lines geometry). + */ +@Composable +fun SkeletonAlbumTile(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .width(176.dp) + .padding(horizontal = 8.dp), + ) { + SkeletonBox(modifier = Modifier.size(144.dp)) + Spacer(Modifier.height(8.dp)) + SkeletonBox(modifier = Modifier.fillMaxWidth().height(14.dp)) + Spacer(Modifier.height(4.dp)) + SkeletonBox(modifier = Modifier.width(96.dp).height(12.dp)) + } +} + +/** + * Stand-in for ArtistCard (128dp circular cover + 1 name line). + */ +@Composable +fun SkeletonArtistTile(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .width(144.dp) + .padding(horizontal = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SkeletonBox( + modifier = Modifier.size(128.dp), + shape = CircleShape, + ) + Spacer(Modifier.height(8.dp)) + SkeletonBox(modifier = Modifier.width(96.dp).height(14.dp)) + } +} + +/** + * Stand-in for a compact track row (28dp cover thumb + title + subtitle). + */ +@Composable +fun SkeletonTrackRow(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SkeletonBox(modifier = Modifier.size(40.dp)) + Column( + modifier = Modifier + .weight(1f, fill = true), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + SkeletonBox(modifier = Modifier.fillMaxWidth().height(14.dp)) + SkeletonBox(modifier = Modifier.width(120.dp).height(12.dp)) + } + } +} + +/** Section-heading skeleton (~40% width title bar). */ +@Composable +fun SkeletonSectionHeader(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + SkeletonBox(modifier = Modifier.width(160.dp).height(20.dp)) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackCoverThumb.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackCoverThumb.kt new file mode 100644 index 00000000..14fbfb67 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackCoverThumb.kt @@ -0,0 +1,61 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.unit.Dp +import androidx.compose.ui.unit.dp +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Music + +/** + * Shared track-row cover thumbnail. Pulls the cover from the track's + * derived `coverUrl` (which Coil resolves through the shared OkHttp + * + BaseUrlInterceptor); falls back to a Lucide.Music glyph on a + * surfaceVariant-tinted background when the URL is empty (album + * cover hasn't loaded into the cache yet OR the track has no album + * binding). + * + * Used by LikedTab, PlaylistDetail, HistoryTab, and any other dense + * track list. Sizes default to 48dp — the four-corner radius and + * fallback-icon size scale with the outer dimension so callers can + * pass e.g. 56dp for a more prominent row without re-implementing. + */ +@Composable +fun TrackCoverThumb( + coverUrl: String, + contentDescription: String?, + modifier: Modifier = Modifier, + size: Dp = DEFAULT_SIZE, +) { + Box( + modifier = modifier + .size(size) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + ServerImage( + url = coverUrl, + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + ) { + Icon( + imageVector = Lucide.Music, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(size / 2), + ) + } + } +} + +private val DEFAULT_SIZE: Dp = 48.dp diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackRow.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackRow.kt new file mode 100644 index 00000000..0b5c95c1 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackRow.kt @@ -0,0 +1,83 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** + * Shared track-list row. Replaces the 5 per-screen `TrackRow`s — every + * track list shares the same row skeleton (clickable Row, title + artist + * column, CachedDot, nowPlaying highlight); only the leading element + * (track number vs cover thumb) and the trailing controls (duration, + * relative time, LikeButton, TrackActionsButton) vary. Callers fill + * those via the [leading] and [trailing] slots so each screen's row is + * a thin wrapper that no longer re-implements the Row layout. + * + * [enabled] gates the click (use for unavailable rows). [contentAlpha] + * dims the title + artist text colors — pass <1f for the + * playlist-track-unavailable case. The trailing slot is responsible + * for applying its own alpha if it should match (the row doesn't + * cascade because the trailing slot's content is the caller's, not + * ours). + */ +@Composable +fun TrackRow( + title: String, + artist: String, + trackId: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + nowPlaying: Boolean = false, + enabled: Boolean = true, + contentAlpha: Float = 1f, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + leading: @Composable () -> Unit = {}, + trailing: @Composable RowScope.() -> Unit = {}, +) { + val titleColor = if (nowPlaying) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + } + Row( + modifier = modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = horizontalArrangement, + ) { + leading() + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = titleColor.copy(alpha = contentAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (artist.isNotEmpty()) { + Text( + text = artist, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = contentAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + CachedDot(trackId) + trailing() + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/AddToPlaylistSheet.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/AddToPlaylistSheet.kt new file mode 100644 index 00000000..288a14a6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/AddToPlaylistSheet.kt @@ -0,0 +1,113 @@ +package com.fabledsword.minstrel.shared.widgets.trackactions + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composables.icons.lucide.ListMusic +import com.composables.icons.lucide.Lucide +import com.fabledsword.minstrel.models.PlaylistRef +import com.fabledsword.minstrel.shared.UiState + +/** + * ModalBottomSheet that lists the caller's user-owned playlists and + * pops back with the picked id via [onPick]. Mirrors Flutter's + * `AddToPlaylistSheet`. System playlists are filtered out — they're + * not appendable. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddToPlaylistSheet( + onPick: (playlistId: String) -> Unit, + onDismiss: () -> Unit, + viewModel: AddToPlaylistViewModel = hiltViewModel(), +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val state by viewModel.uiState.collectAsStateWithLifecycle() + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) { + Text( + text = "Add to playlist", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) + when (val s = state) { + UiState.Loading -> Box( + modifier = Modifier.fillMaxWidth().padding(24.dp), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) } + UiState.Empty -> Text( + text = "You haven't created any playlists yet.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + ) + is UiState.Error -> Text( + text = s.message, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + ) + is UiState.Success -> LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(items = s.data, key = { it.id }) { p -> + PlaylistPickRow(playlist = p, onClick = { onPick(p.id) }) + } + } + } + } + } +} + +@Composable +private fun PlaylistPickRow(playlist: PlaylistRef, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + Lucide.ListMusic, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = playlist.name, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${playlist.trackCount} ${if (playlist.trackCount == 1) "track" else "tracks"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/AddToPlaylistViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/AddToPlaylistViewModel.kt new file mode 100644 index 00000000..3175daed --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/AddToPlaylistViewModel.kt @@ -0,0 +1,41 @@ +package com.fabledsword.minstrel.shared.widgets.trackactions + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.models.PlaylistRef +import com.fabledsword.minstrel.playlists.data.PlaylistsRepository +import com.fabledsword.minstrel.shared.UiState +import com.fabledsword.minstrel.shared.asCacheFirstStateFlow +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Backs [AddToPlaylistSheet] with the caller's user-owned playlists. + * System playlists (For You / Discover / etc.) are filtered out at + * the repository layer — they're not appendable. + */ +@HiltViewModel +class AddToPlaylistViewModel @Inject constructor( + repository: PlaylistsRepository, +) : ViewModel() { + + val uiState: StateFlow>> = repository + .observeUserPlaylists() + .map { list -> + if (list.isEmpty()) UiState.Empty else UiState.Success(list) + } + .onStart { + // Best-effort refresh so the sheet reflects newly-created + // playlists from another device. Cached list shows first; + // refresh emission updates the state once Room reflects it. + viewModelScope.launch { + runCatching { repository.refreshList() } + } + emit(UiState.Loading) + } + .asCacheFirstStateFlow(viewModelScope) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/HideTrackSheet.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/HideTrackSheet.kt new file mode 100644 index 00000000..8ffd2cd0 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/HideTrackSheet.kt @@ -0,0 +1,115 @@ +package com.fabledsword.minstrel.shared.widgets.trackactions + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +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.unit.dp + +private data class HideReason(val wire: String, val label: String) + +private val HIDE_REASONS = listOf( + HideReason("bad_rip", "Bad rip"), + HideReason("wrong_file", "Wrong file"), + HideReason("wrong_tags", "Wrong tags"), + HideReason("duplicate", "Duplicate"), + HideReason("other", "Other"), +) + +/** + * ModalBottomSheet that collects a hide reason + optional notes and + * pops back via [onSubmit]. Mirrors Flutter's `HideTrackSheet`. The + * reason vocabulary matches the server wire values exactly. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun HideTrackSheet( + onSubmit: (reason: String, notes: String) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var reason by remember { mutableStateOf(HIDE_REASONS.first().wire) } + var notes by remember { mutableStateOf("") } + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + ) { + Text( + text = "Hide this track", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Pick a reason. Optional notes are visible to admins.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + HIDE_REASONS.forEach { r -> + FilterChip( + selected = reason == r.wire, + onClick = { reason = r.wire }, + label = { Text(r.label) }, + ) + } + } + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = notes, + onValueChange = { notes = it }, + label = { Text("Notes (optional)") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 2, + ) + Spacer(Modifier.height(16.dp)) + HideSheetButtons( + onCancel = onDismiss, + onSubmit = { onSubmit(reason, notes.trim()) }, + ) + Spacer(Modifier.height(8.dp)) + } + } +} + +@Composable +private fun HideSheetButtons(onCancel: () -> Unit, onSubmit: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onCancel) { Text("Cancel") } + Spacer(Modifier.padding(horizontal = 4.dp)) + Button(onClick = onSubmit) { Text("Hide") } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/TrackActionsButton.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/TrackActionsButton.kt new file mode 100644 index 00000000..45266fb3 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/TrackActionsButton.kt @@ -0,0 +1,49 @@ +package com.fabledsword.minstrel.shared.widgets.trackactions + +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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 com.composables.icons.lucide.EllipsisVertical +import com.composables.icons.lucide.Lucide +import com.fabledsword.minstrel.models.TrackRef + +/** + * Small kebab IconButton that opens [TrackActionsSheet] for [track]. + * Drop into any track row alongside (or in place of) a LikeButton. + * + * [hideQueueActions] suppresses Play next + Add to queue — set true + * on the NowPlaying caller where the menu's track IS the playing one. + * [onNavigateToAlbum] / [onNavigateToArtist] are invoked AFTER the + * sheet has dismissed, so callers that live on a full-screen overlay + * (e.g. NowPlaying) can pop themselves first inside the callback. + */ +@Composable +fun TrackActionsButton( + track: TrackRef, + hideQueueActions: Boolean = false, + onNavigateToAlbum: (albumId: String) -> Unit, + onNavigateToArtist: (artistId: String) -> Unit, +) { + var show by remember { mutableStateOf(false) } + IconButton(onClick = { show = true }) { + Icon( + imageVector = Lucide.EllipsisVertical, + contentDescription = "Track actions", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (show) { + TrackActionsSheet( + track = track, + hideQueueActions = hideQueueActions, + onDismiss = { show = false }, + onNavigateToAlbum = onNavigateToAlbum, + onNavigateToArtist = onNavigateToArtist, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/TrackActionsSheet.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/TrackActionsSheet.kt new file mode 100644 index 00000000..139e8b55 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/TrackActionsSheet.kt @@ -0,0 +1,203 @@ +package com.fabledsword.minstrel.shared.widgets.trackactions + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composables.icons.lucide.Disc3 +import com.composables.icons.lucide.Eye +import com.composables.icons.lucide.EyeOff +import com.composables.icons.lucide.Heart +import com.composables.icons.lucide.ListMusic +import com.composables.icons.lucide.ListPlus +import com.composables.icons.lucide.ListVideo +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Radio +import com.composables.icons.lucide.User +import com.fabledsword.minstrel.models.TrackRef + +/** + * The 7-item track-actions modal bottom sheet. Mirrors Flutter's + * `TrackActionsSheet`. Opens via [TrackActionsButton] (or any caller + * holding a `TrackRef`); closes via [onDismiss] after any item runs + * or when the user swipes down. + * + * The sheet pops first when an item is tapped (immediate visual + * feedback) and then runs the action. For Add to playlist… and Hide, + * the sub-sheet opens on top of this one; the parent stays mounted + * so the user can pop back out without losing context. + * + * [hideQueueActions] suppresses Play next + Add to queue — set true + * on the NowPlaying caller where the menu's track IS the playing one. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TrackActionsSheet( + track: TrackRef, + hideQueueActions: Boolean, + onDismiss: () -> Unit, + onNavigateToAlbum: (albumId: String) -> Unit, + onNavigateToArtist: (artistId: String) -> Unit, + viewModel: TrackActionsViewModel = hiltViewModel(), +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val isLiked by viewModel.isLikedFlow(track.id).collectAsStateWithLifecycle(initialValue = false) + val isHidden by viewModel.isHiddenFlow(track.id).collectAsStateWithLifecycle(initialValue = false) + var showAddToPlaylist by remember { mutableStateOf(false) } + var showHideSheet by remember { mutableStateOf(false) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + TrackActionMenuItems( + track = track, + hideQueueActions = hideQueueActions, + isLiked = isLiked, + isHidden = isHidden, + viewModel = viewModel, + onDismiss = onDismiss, + onNavigateToAlbum = onNavigateToAlbum, + onNavigateToArtist = onNavigateToArtist, + onShowAddToPlaylist = { showAddToPlaylist = true }, + onShowHideSheet = { showHideSheet = true }, + ) + } + TrackActionSubSheets( + track = track, + showAddToPlaylist = showAddToPlaylist, + showHideSheet = showHideSheet, + viewModel = viewModel, + onDismissParent = onDismiss, + onCloseAddToPlaylist = { showAddToPlaylist = false }, + onCloseHideSheet = { showHideSheet = false }, + ) +} + +@Composable +private fun TrackActionMenuItems( + track: TrackRef, + hideQueueActions: Boolean, + isLiked: Boolean, + isHidden: Boolean, + viewModel: TrackActionsViewModel, + onDismiss: () -> Unit, + onNavigateToAlbum: (String) -> Unit, + onNavigateToArtist: (String) -> Unit, + onShowAddToPlaylist: () -> Unit, + onShowHideSheet: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + if (!hideQueueActions) { + MenuItem(Lucide.ListVideo, "Play next") { + viewModel.playNext(track); onDismiss() + } + MenuItem(Lucide.ListMusic, "Add to queue") { + viewModel.enqueue(track); onDismiss() + } + HorizontalDivider() + } + MenuItem(Lucide.Heart, if (isLiked) "Unlike" else "Like") { + viewModel.toggleLike(track.id, isLiked); onDismiss() + } + MenuItem(Lucide.ListPlus, "Add to playlist…", onShowAddToPlaylist) + MenuItem(Lucide.Radio, "Start radio") { + viewModel.startRadio(track.id); onDismiss() + } + HorizontalDivider() + MenuItem(Lucide.Disc3, "Go to album") { + onDismiss() + if (track.albumId.isNotEmpty()) onNavigateToAlbum(track.albumId) + } + MenuItem(Lucide.User, "Go to artist") { + onDismiss() + if (track.artistId.isNotEmpty()) onNavigateToArtist(track.artistId) + } + HorizontalDivider() + MenuItem( + icon = if (isHidden) Lucide.Eye else Lucide.EyeOff, + label = if (isHidden) "Unhide" else "Hide", + ) { + if (isHidden) { + viewModel.unflag(track.id); onDismiss() + } else { + onShowHideSheet() + } + } + } +} + +@Composable +private fun TrackActionSubSheets( + track: TrackRef, + showAddToPlaylist: Boolean, + showHideSheet: Boolean, + viewModel: TrackActionsViewModel, + onDismissParent: () -> Unit, + onCloseAddToPlaylist: () -> Unit, + onCloseHideSheet: () -> Unit, +) { + if (showAddToPlaylist) { + AddToPlaylistSheet( + onPick = { playlistId -> + viewModel.appendToPlaylist(playlistId, track.id) + onCloseAddToPlaylist() + onDismissParent() + }, + onDismiss = onCloseAddToPlaylist, + ) + } + if (showHideSheet) { + HideTrackSheet( + onSubmit = { reason, notes -> + viewModel.flag(track, reason, notes) + onCloseHideSheet() + onDismissParent() + }, + onDismiss = onCloseHideSheet, + ) + } +} + +@Composable +private fun MenuItem(icon: ImageVector, label: String, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/TrackActionsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/TrackActionsViewModel.kt new file mode 100644 index 00000000..feabb80e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/TrackActionsViewModel.kt @@ -0,0 +1,140 @@ +package com.fabledsword.minstrel.shared.widgets.trackactions + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.events.EventsStream +import com.fabledsword.minstrel.likes.data.LikesRepository +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.playlists.data.PlaylistsRepository +import com.fabledsword.minstrel.quarantine.data.QuarantineRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Backs [TrackActionsSheet] with state observations + action callbacks. + * Scoped to the call-site screen via `hiltViewModel()`. The sheet hands + * a [TrackRef] into the action methods; the VM doesn't carry per-sheet + * trackId state — it exposes the flows + actions the sheet needs. + * + * Snackbar copy emits on [transientMessages] — call sites collect this + * and route to their existing SnackbarHostState. + * + * Hidden-state observation: [QuarantineRepository] doesn't yet expose + * a Flow for the caller's quarantine list (no Room cache; see the + * repository's class comment), so this VM holds its own snapshot of + * the hidden track-id set. Refreshed on init, after every flag / + * unflag action, AND on any `quarantine.*` SSE event so a hide/unhide + * from another device updates the sheet's Hide/Unhide label without + * a manual reload. + */ +@HiltViewModel +class TrackActionsViewModel @Inject constructor( + private val likes: LikesRepository, + private val quarantine: QuarantineRepository, + private val player: PlayerController, + private val playlists: PlaylistsRepository, + private val eventsStream: EventsStream, +) : ViewModel() { + + private val messages = MutableSharedFlow(extraBufferCapacity = 4) + val transientMessages: SharedFlow = messages.asSharedFlow() + + private val hiddenTrackIdsInternal = MutableStateFlow>(emptySet()) + val hiddenTrackIds: StateFlow> = hiddenTrackIdsInternal.asStateFlow() + + init { + refreshHidden() + viewModelScope.launch { + eventsStream.events + .filter { it.kind.startsWith("quarantine.") } + .collect { refreshHidden() } + } + } + + fun isLikedFlow(trackId: String): Flow = + likes.observeIsLiked(LikesRepository.ENTITY_TRACK, trackId) + + fun isHiddenFlow(trackId: String): Flow = + hiddenTrackIds.map { trackId in it } + + fun playNext(track: TrackRef) { + player.playNext(track) + messages.tryEmit("Added to queue (next)") + } + + fun enqueue(track: TrackRef) { + player.enqueue(track) + messages.tryEmit("Added to queue") + } + + fun toggleLike(trackId: String, currentlyLiked: Boolean) { + viewModelScope.launch { + runCatching { + likes.toggleLike( + entityType = LikesRepository.ENTITY_TRACK, + entityId = trackId, + desiredState = !currentlyLiked, + ) + } + } + } + + fun startRadio(trackId: String) { + viewModelScope.launch { + runCatching { player.startRadio(trackId) } + .onFailure { + messages.tryEmit("Couldn't start radio: ${ErrorCopy.fromThrowable(it)}") + } + } + } + + fun appendToPlaylist(playlistId: String, trackId: String) { + viewModelScope.launch { + runCatching { playlists.appendTrack(playlistId, trackId) } + .onSuccess { messages.tryEmit("Added to playlist") } + .onFailure { + messages.tryEmit("Couldn't add to playlist: ${ErrorCopy.fromThrowable(it)}") + } + } + } + + fun flag(track: TrackRef, reason: String, notes: String) { + viewModelScope.launch { + runCatching { quarantine.flag(track, reason, notes) } + .onSuccess { refreshHidden() } + .onFailure { messages.tryEmit("Couldn't hide: ${ErrorCopy.fromThrowable(it)}") } + } + } + + fun unflag(trackId: String) { + viewModelScope.launch { + runCatching { quarantine.unflag(trackId) } + .onSuccess { refreshHidden() } + .onFailure { messages.tryEmit("Couldn't unhide: ${ErrorCopy.fromThrowable(it)}") } + } + } + + private fun refreshHidden() { + viewModelScope.launch { + runCatching { quarantine.listMine() } + .onSuccess { rows -> + hiddenTrackIdsInternal.value = rows.map { it.trackId }.toSet() + } + // Failure leaves the previous snapshot; the sheet still + // shows "Hide" by default which is safe (server is + // idempotent on re-flag). + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/theme/ActionColors.kt b/android/app/src/main/java/com/fabledsword/minstrel/theme/ActionColors.kt new file mode 100644 index 00000000..ab0adf0c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/theme/ActionColors.kt @@ -0,0 +1,29 @@ +package com.fabledsword.minstrel.theme + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +/** + * Semantic action colors. The FabledSword design system reserves Moss/Bronze/ + * Oxblood for action buttons; the forest-teal accent (`FabledSwordFlatTokens.accent`) + * is for surfaces / highlights / non-action emphasis only and must NEVER be + * used as a button color. + * + * Composables that render action buttons should read from `LocalActionColors.current` + * rather than `MaterialTheme.colorScheme.primary`. + */ +data class ActionColors( + val primary: Color, // Moss — confirm, save, play + val secondary: Color, // Bronze — neutral, browse, accent action + val destructive: Color, // Oxblood — delete, unflag-as-destructive + val onAction: Color, // foreground / text on any of the above +) + +val LocalActionColors = staticCompositionLocalOf { + ActionColors( + primary = FabledSwordFlatTokens.moss, + secondary = FabledSwordFlatTokens.bronze, + destructive = FabledSwordFlatTokens.oxblood, + onAction = FabledSwordFlatTokens.onAction, + ) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTheme.kt b/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTheme.kt new file mode 100644 index 00000000..288c19bc --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTheme.kt @@ -0,0 +1,97 @@ +package com.fabledsword.minstrel.theme + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp + +/** + * Mode-resolved snapshot of the FabledSword design system, exposed via + * [LocalFabledSwordTheme]. Mirrors the Flutter `FabledSwordTheme` + * ThemeExtension — most Flutter widgets read `Theme.of(context).extension< + * FabledSwordTheme>()!.parchment` directly rather than via `colorScheme`, + * and the Android port follows the same convention to keep token + * semantics centralized rather than scattered across Material role mappings. + * + * UI code reads tokens via `LocalFabledSwordTheme.current.parchment`, + * `.iron`, `.accent`, etc. The MinstrelTheme composable swaps between + * the dark and light instances based on system mode. + */ +data class FabledSwordTheme( + // Surface ladder (lightest text → darkest bg). + val parchment: Color, + val vellum: Color, + val ash: Color, + val pewter: Color, + val slate: Color, + val iron: Color, + val obsidian: Color, + + // Mode-independent action + status colors. + val moss: Color, + val bronze: Color, + val oxblood: Color, + val warning: Color, + val error: Color, + val info: Color, + val accent: Color, + val onAction: Color, + + // Radii. + val radiusSm: Dp, + val radiusMd: Dp, + val radiusLg: Dp, + val radiusXl: Dp, +) { + companion object { + val Dark: FabledSwordTheme = FabledSwordTheme( + parchment = FabledSwordDarkTokens.parchment, + vellum = FabledSwordDarkTokens.vellum, + ash = FabledSwordDarkTokens.ash, + pewter = FabledSwordDarkTokens.pewter, + slate = FabledSwordDarkTokens.slate, + iron = FabledSwordDarkTokens.iron, + obsidian = FabledSwordDarkTokens.obsidian, + moss = FabledSwordFlatTokens.moss, + bronze = FabledSwordFlatTokens.bronze, + oxblood = FabledSwordFlatTokens.oxblood, + warning = FabledSwordFlatTokens.warning, + error = FabledSwordFlatTokens.error, + info = FabledSwordFlatTokens.info, + accent = FabledSwordFlatTokens.accent, + onAction = FabledSwordFlatTokens.onAction, + radiusSm = FabledSwordFlatTokens.radiusSm, + radiusMd = FabledSwordFlatTokens.radiusMd, + radiusLg = FabledSwordFlatTokens.radiusLg, + radiusXl = FabledSwordFlatTokens.radiusXl, + ) + + val Light: FabledSwordTheme = FabledSwordTheme( + parchment = FabledSwordLightTokens.parchment, + vellum = FabledSwordLightTokens.vellum, + ash = FabledSwordLightTokens.ash, + pewter = FabledSwordLightTokens.pewter, + slate = FabledSwordLightTokens.slate, + iron = FabledSwordLightTokens.iron, + obsidian = FabledSwordLightTokens.obsidian, + moss = FabledSwordFlatTokens.moss, + bronze = FabledSwordFlatTokens.bronze, + oxblood = FabledSwordFlatTokens.oxblood, + warning = FabledSwordFlatTokens.warning, + error = FabledSwordFlatTokens.error, + info = FabledSwordFlatTokens.info, + accent = FabledSwordFlatTokens.accent, + onAction = FabledSwordFlatTokens.onAction, + radiusSm = FabledSwordFlatTokens.radiusSm, + radiusMd = FabledSwordFlatTokens.radiusMd, + radiusLg = FabledSwordFlatTokens.radiusLg, + radiusXl = FabledSwordFlatTokens.radiusXl, + ) + } +} + +/** + * Reads as `LocalFabledSwordTheme.current.X` from anywhere inside a + * [MinstrelTheme] composable scope. Default = dark; the MinstrelTheme + * composable overrides this based on system mode. + */ +val LocalFabledSwordTheme = staticCompositionLocalOf { FabledSwordTheme.Dark } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTokens.kt b/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTokens.kt new file mode 100644 index 00000000..7309a465 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTokens.kt @@ -0,0 +1,99 @@ +package com.fabledsword.minstrel.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * Raw color values for the dark surface cohort. Source of truth: + * `flutter_client/shared/fabledsword.tokens.json` (dark block). + * + * Token names are **semantic roles**, not literal colors. In dark + * mode, `parchment` is light text on dark surfaces (#E8E4D8); in + * [FabledSwordLightTokens], `parchment` is dark text on light + * surfaces (#14171A). Consume tokens through [LocalFabledSwordTheme], + * never reference these raw objects from UI code — the mode-switch + * stops working otherwise. + */ +object FabledSwordDarkTokens { + val obsidian = Color(0xFF14171A) + val iron = Color(0xFF1E2228) + val slate = Color(0xFF2C313A) + val pewter = Color(0xFF3F4651) + val parchment = Color(0xFFE8E4D8) + val vellum = Color(0xFFC2BFB4) + val ash = Color(0xFF9C9A92) +} + +/** + * Raw color values for the light surface cohort. Mirrors + * `fabledsword.tokens.json`'s light block. + * + * Same names as [FabledSwordDarkTokens] but inverted semantics — see + * that doc-comment. + */ +object FabledSwordLightTokens { + val obsidian = Color(0xFFF8F5EE) + val iron = Color(0xFFECE6D5) + val slate = Color(0xFFDCD3BD) + val pewter = Color(0xFFB8AE94) + val parchment = Color(0xFF14171A) + val vellum = Color(0xFF2C313A) + val ash = Color(0xFF5C6068) +} + +/** + * Mode-independent (flat) tokens. Action colors live here; they're + * the same in light + dark. Per the design system: Moss / Bronze / + * Oxblood are action colors; the forest-teal accent is for + * highlights / non-action emphasis and must NEVER be used as a + * button color. + */ +object FabledSwordFlatTokens { + val moss = Color(0xFF4A5D3F) + val bronze = Color(0xFF8B7355) + val oxblood = Color(0xFF6B2118) + val warning = Color(0xFF8B6F1E) + val error = Color(0xFFC04A1F) + val info = Color(0xFF3D5A6E) + val accent = Color(0xFF4A6B5C) + val onAction = Color(0xFFE8E4D8) + + val radiusSm = 4.dp + val radiusMd = 8.dp + val radiusLg = 12.dp + val radiusXl = 16.dp +} + +/** + * Back-compat alias mirroring the Flutter `FabledSwordTokens` class + * (dark surfaces + flat). Existing call sites that read + * `FabledSwordTokens.iron` continue to work but bind to dark-mode + * values regardless of system mode. New code should read from + * `LocalFabledSwordTheme.current` instead. Removed in a follow-up + * once all widgets are migrated. + */ +@Deprecated( + "Use LocalFabledSwordTheme.current — see FabledSwordTheme.kt", + level = DeprecationLevel.WARNING, +) +object FabledSwordTokens { + val obsidian = FabledSwordDarkTokens.obsidian + val iron = FabledSwordDarkTokens.iron + val slate = FabledSwordDarkTokens.slate + val pewter = FabledSwordDarkTokens.pewter + val parchment = FabledSwordDarkTokens.parchment + val vellum = FabledSwordDarkTokens.vellum + val ash = FabledSwordDarkTokens.ash + val moss = FabledSwordFlatTokens.moss + val bronze = FabledSwordFlatTokens.bronze + val oxblood = FabledSwordFlatTokens.oxblood + val warning = FabledSwordFlatTokens.warning + val error = FabledSwordFlatTokens.error + val info = FabledSwordFlatTokens.info + val accent = FabledSwordFlatTokens.accent + val onAction = FabledSwordFlatTokens.onAction + val radiusSm = FabledSwordFlatTokens.radiusSm + val radiusMd = FabledSwordFlatTokens.radiusMd + val radiusLg = FabledSwordFlatTokens.radiusLg + val radiusXl = FabledSwordFlatTokens.radiusXl +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/theme/MinstrelTheme.kt b/android/app/src/main/java/com/fabledsword/minstrel/theme/MinstrelTheme.kt new file mode 100644 index 00000000..b68282f1 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/theme/MinstrelTheme.kt @@ -0,0 +1,98 @@ +package com.fabledsword.minstrel.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider + +/** + * The Flutter theme's `ColorScheme` role mapping (theme_data.dart): + * primary = accent (forest-teal) — same both modes + * onPrimary = onAction + * surface = iron + * onSurface = parchment + * secondary = moss + * error = error + * scaffoldBackground = obsidian + * + * Reproduced here for both modes. Note that token names ARE semantic + * roles — `iron` is "raised surface" in both modes (#1E2228 dark vs + * #ECE6D5 light), not literal "near-black". + */ +private fun darkMaterialColorScheme() = + darkColorScheme( + primary = FabledSwordFlatTokens.accent, + onPrimary = FabledSwordFlatTokens.onAction, + primaryContainer = FabledSwordFlatTokens.accent.copy(alpha = 0.25f), + onPrimaryContainer = FabledSwordDarkTokens.parchment, + secondary = FabledSwordFlatTokens.moss, + onSecondary = FabledSwordFlatTokens.onAction, + background = FabledSwordDarkTokens.obsidian, + onBackground = FabledSwordDarkTokens.parchment, + surface = FabledSwordDarkTokens.iron, + onSurface = FabledSwordDarkTokens.parchment, + surfaceVariant = FabledSwordDarkTokens.slate, + onSurfaceVariant = FabledSwordDarkTokens.ash, + error = FabledSwordFlatTokens.error, + onError = FabledSwordFlatTokens.onAction, + outline = FabledSwordDarkTokens.pewter, + outlineVariant = FabledSwordDarkTokens.slate, + ) + +private fun lightMaterialColorScheme() = + lightColorScheme( + primary = FabledSwordFlatTokens.accent, + onPrimary = FabledSwordFlatTokens.onAction, + primaryContainer = FabledSwordFlatTokens.accent.copy(alpha = 0.18f), + onPrimaryContainer = FabledSwordLightTokens.parchment, + secondary = FabledSwordFlatTokens.moss, + onSecondary = FabledSwordFlatTokens.onAction, + background = FabledSwordLightTokens.obsidian, + onBackground = FabledSwordLightTokens.parchment, + surface = FabledSwordLightTokens.iron, + onSurface = FabledSwordLightTokens.parchment, + surfaceVariant = FabledSwordLightTokens.slate, + onSurfaceVariant = FabledSwordLightTokens.ash, + error = FabledSwordFlatTokens.error, + onError = FabledSwordFlatTokens.onAction, + outline = FabledSwordLightTokens.pewter, + outlineVariant = FabledSwordLightTokens.slate, + ) + +/** + * Theme entry point. Selects dark vs light based on system mode and + * provides both the Material 3 [MaterialTheme] and the + * [LocalFabledSwordTheme] for token-level reads. [LocalActionColors] + * is kept for back-compat — same values; consumers should migrate to + * `LocalFabledSwordTheme.current.moss` etc. in a later cleanup. + * + * `darkOverride` lets callers force one mode (Phase 11 Settings will + * carry a user-controlled override). + */ +@Composable +fun MinstrelTheme( + darkOverride: Boolean? = null, + content: @Composable () -> Unit, +) { + val isDark = darkOverride ?: isSystemInDarkTheme() + val colorScheme = if (isDark) darkMaterialColorScheme() else lightMaterialColorScheme() + val fabledTheme = if (isDark) FabledSwordTheme.Dark else FabledSwordTheme.Light + + MaterialTheme( + colorScheme = colorScheme, + typography = MinstrelTypography, + ) { + CompositionLocalProvider( + LocalFabledSwordTheme provides fabledTheme, + LocalActionColors provides ActionColors( + primary = FabledSwordFlatTokens.moss, + secondary = FabledSwordFlatTokens.bronze, + destructive = FabledSwordFlatTokens.oxblood, + onAction = FabledSwordFlatTokens.onAction, + ), + content = content, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/theme/ThemeMode.kt b/android/app/src/main/java/com/fabledsword/minstrel/theme/ThemeMode.kt new file mode 100644 index 00000000..b821e67b --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/theme/ThemeMode.kt @@ -0,0 +1,36 @@ +package com.fabledsword.minstrel.theme + +/** + * User-selectable theme override. SYSTEM defers to + * `isSystemInDarkTheme()`; LIGHT and DARK pin the app to that mode + * regardless of the device setting. + * + * Stored as the wire string in `AuthSessionEntity.themeMode`. Null + * persistence value means "never set" which we treat as SYSTEM. + */ +enum class ThemeMode { + SYSTEM, LIGHT, DARK; + + /** Stable string identifier used for persistence. */ + val wire: String get() = name.lowercase() + + /** + * Maps to MinstrelTheme's `darkOverride: Boolean?` parameter: + * - SYSTEM → null (let isSystemInDarkTheme decide) + * - LIGHT → false + * - DARK → true + */ + fun toDarkOverride(): Boolean? = when (this) { + SYSTEM -> null + LIGHT -> false + DARK -> true + } + + companion object { + fun fromWire(wire: String?): ThemeMode = when (wire) { + "light" -> LIGHT + "dark" -> DARK + else -> SYSTEM + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/theme/ThemePreferenceViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/theme/ThemePreferenceViewModel.kt new file mode 100644 index 00000000..7234c90d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/theme/ThemePreferenceViewModel.kt @@ -0,0 +1,45 @@ +package com.fabledsword.minstrel.theme + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.auth.AuthStore +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +private const val SHARE_STOP_TIMEOUT_MS = 5_000L + +/** + * Surfaces AuthStore.themeMode as a typed [ThemeMode] StateFlow and + * lets callers write through. Used by: + * - MainActivity's App() to pick the darkOverride to wrap the + * NavGraph in. + * - SettingsScreen's Appearance section as the 3-way picker. + * + * Lives in the theme package rather than a UI package because it's + * tightly coupled to the theme tokens; SettingsScreen just consumes it. + */ +@HiltViewModel +class ThemePreferenceViewModel @Inject constructor( + private val authStore: AuthStore, +) : ViewModel() { + + val themeMode: StateFlow = + authStore.themeMode + .map { ThemeMode.fromWire(it) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = ThemeMode.fromWire(authStore.themeMode.value), + ) + + fun setThemeMode(mode: ThemeMode) { + // Persist null for SYSTEM so a "never set" row reads identically + // to an explicit "follow system" pick — keeps the wire/storage + // round-trip clean. + authStore.setThemeMode(if (mode == ThemeMode.SYSTEM) null else mode.wire) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/theme/Typography.kt b/android/app/src/main/java/com/fabledsword/minstrel/theme/Typography.kt new file mode 100644 index 00000000..9eafcfba --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/theme/Typography.kt @@ -0,0 +1,96 @@ +package com.fabledsword.minstrel.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.googlefonts.Font +import androidx.compose.ui.text.googlefonts.GoogleFont +import androidx.compose.ui.unit.sp +import com.fabledsword.minstrel.R + +/** + * Google Fonts provider — fetches font files via Play Services Fonts at + * runtime, caches them across launches. Matches the Flutter client's + * `google_fonts` package behaviour (no bundled .ttf files in either tree). + * + * Per FabledSword design system: + * - Fraunces — display + headline (mythic serif) + * - Inter — body + label (clean sans for UI text) + * - JetBrains Mono — technical / monospace + * Weights are restricted to 400 (regular) and 500 (medium) only. + */ +private val GoogleFontProvider = GoogleFont.Provider( + providerAuthority = "com.google.android.gms.fonts", + providerPackage = "com.google.android.gms", + certificates = R.array.com_google_android_gms_fonts_certs, +) + +private val FrauncesFont = GoogleFont("Fraunces") +private val InterFont = GoogleFont("Inter") +private val JetBrainsMonoFont = GoogleFont("JetBrains Mono") + +private val Fraunces = FontFamily( + Font( + googleFont = FrauncesFont, + fontProvider = GoogleFontProvider, + weight = FontWeight.W400, + style = FontStyle.Normal, + ), + Font( + googleFont = FrauncesFont, + fontProvider = GoogleFontProvider, + weight = FontWeight.W500, + style = FontStyle.Normal, + ), +) + +private val Inter = FontFamily( + Font( + googleFont = InterFont, + fontProvider = GoogleFontProvider, + weight = FontWeight.W400, + style = FontStyle.Normal, + ), + Font( + googleFont = InterFont, + fontProvider = GoogleFontProvider, + weight = FontWeight.W500, + style = FontStyle.Normal, + ), +) + +private val JetBrainsMono = FontFamily( + Font( + googleFont = JetBrainsMonoFont, + fontProvider = GoogleFontProvider, + weight = FontWeight.W400, + style = FontStyle.Normal, + ), +) + +/** + * FabledSword Material 3 Typography mapping. Display/headline slots use + * Fraunces; title/body/label use Inter; the smallest label slots step down + * to JetBrains Mono for technical/code surfaces. Sizes follow Material 3 + * defaults; the project's "Fraunces ≥18px" rule is satisfied because every + * Fraunces-bearing slot is 18sp or larger. + */ +val MinstrelTypography = Typography( + displayLarge = TextStyle(fontFamily = Fraunces, fontWeight = FontWeight.W500, fontSize = 32.sp), + displayMedium = TextStyle(fontFamily = Fraunces, fontWeight = FontWeight.W500, fontSize = 28.sp), + displaySmall = TextStyle(fontFamily = Fraunces, fontWeight = FontWeight.W500, fontSize = 24.sp), + headlineLarge = TextStyle(fontFamily = Fraunces, fontWeight = FontWeight.W500, fontSize = 22.sp), + headlineMedium = TextStyle(fontFamily = Fraunces, fontWeight = FontWeight.W500, fontSize = 20.sp), + headlineSmall = TextStyle(fontFamily = Fraunces, fontWeight = FontWeight.W500, fontSize = 18.sp), + titleLarge = TextStyle(fontFamily = Inter, fontWeight = FontWeight.W500, fontSize = 18.sp), + titleMedium = TextStyle(fontFamily = Inter, fontWeight = FontWeight.W500, fontSize = 16.sp), + titleSmall = TextStyle(fontFamily = Inter, fontWeight = FontWeight.W500, fontSize = 14.sp), + bodyLarge = TextStyle(fontFamily = Inter, fontWeight = FontWeight.W400, fontSize = 16.sp), + bodyMedium = TextStyle(fontFamily = Inter, fontWeight = FontWeight.W400, fontSize = 14.sp), + bodySmall = TextStyle(fontFamily = Inter, fontWeight = FontWeight.W400, fontSize = 12.sp), + labelLarge = TextStyle(fontFamily = Inter, fontWeight = FontWeight.W500, fontSize = 14.sp), + labelMedium = TextStyle(fontFamily = JetBrainsMono, fontWeight = FontWeight.W400, fontSize = 12.sp), + labelSmall = TextStyle(fontFamily = JetBrainsMono, fontWeight = FontWeight.W400, fontSize = 11.sp), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/api/ClientVersionApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/api/ClientVersionApi.kt new file mode 100644 index 00000000..b05c79f4 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/api/ClientVersionApi.kt @@ -0,0 +1,14 @@ +package com.fabledsword.minstrel.update.api + +import com.fabledsword.minstrel.models.wire.UpdateInfoWire +import retrofit2.http.GET + +/** + * Retrofit interface for `GET /api/client/version`. Returns the + * server-bundled APK version + download URL + size. Mirrors the + * Flutter `ClientUpdateController` shape. + */ +interface ClientVersionApi { + @GET("api/client/version") + suspend fun getVersion(): UpdateInfoWire +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/api/HealthzApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/api/HealthzApi.kt new file mode 100644 index 00000000..50e68480 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/api/HealthzApi.kt @@ -0,0 +1,28 @@ +package com.fabledsword.minstrel.update.api + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import retrofit2.http.GET + +/** + * Retrofit interface for `GET /healthz` — unauthenticated health probe + * returning the server's running version + the minimum client version + * it'll talk to. Used by [com.fabledsword.minstrel.update.data.VersionCheckController] + * to surface the VersionTooOld banner. + */ +interface HealthzApi { + @GET("healthz") + suspend fun check(): HealthzResponse +} + +/** + * Wire shape for /healthz. `minClientVersion` may be empty on older + * servers; the caller treats empty as "skip" rather than "fail" so a + * partial deploy doesn't gate the UI. + */ +@Serializable +data class HealthzResponse( + val status: String = "", + val version: String = "", + @SerialName("min_client_version") val minClientVersion: String = "", +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/ApkInstaller.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/ApkInstaller.kt new file mode 100644 index 00000000..b27f6cfd --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/ApkInstaller.kt @@ -0,0 +1,86 @@ +package com.fabledsword.minstrel.update.data + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.core.content.FileProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +private const val APK_FILENAME = "minstrel-update.apk" +private const val APK_MIME = "application/vnd.android.package-archive" + +/** + * Downloads the server-bundled APK and hands it to Android's package + * installer. Mirrors Flutter's `update/installer.dart` — the native + * side that the Flutter MethodChannel delegated to. + * + * The download goes through the shared [OkHttpClient] so it inherits + * the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is + * server-relative, e.g. `/api/client/apk`). The APK lands in the + * cache dir, exposed to the system installer via the app's + * FileProvider content:// URI. + * + * On Android O+ the user must have granted "install unknown apps" + * for Minstrel; [canInstall] reports it and [requestInstallPermission] + * opens the relevant settings screen. + */ +@Singleton +class ApkInstaller @Inject constructor( + @ApplicationContext private val context: Context, + private val okHttpClient: OkHttpClient, +) { + suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("http://placeholder.invalid$apkUrl") + .build() + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IOException("Download failed: HTTP ${response.code}") + } + val body = response.body ?: throw IOException("Empty download body") + val file = File(context.cacheDir, APK_FILENAME) + body.byteStream().use { input -> + file.outputStream().use { output -> input.copyTo(output) } + } + file + } + } + + /** True when the system will accept an install intent without a permission detour. */ + fun canInstall(): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.O || + context.packageManager.canRequestPackageInstalls() + + /** Hand the downloaded APK to the system installer's confirm dialog. */ + fun launchInstall(apk: File) { + val uri: Uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + apk, + ) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, APK_MIME) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } + + /** Open the "install unknown apps" settings page for Minstrel. */ + fun requestInstallPermission() { + val intent = Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${context.packageName}"), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateBannerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateBannerController.kt new file mode 100644 index 00000000..188bfff6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateBannerController.kt @@ -0,0 +1,63 @@ +package com.fabledsword.minstrel.update.data + +import com.fabledsword.minstrel.BuildConfig +import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.models.UpdateInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +private const val POLL_INTERVAL_MS = 24 * 60 * 60 * 1000L + +/** + * Drives the shell's soft "update available" banner. Polls + * `/api/client/version` at launch + every 24h and, when the bundled + * APK is strictly newer than this build, exposes its [UpdateInfo] so + * [com.fabledsword.minstrel.update.ui.UpdateBanner] can nudge an + * install. Mirrors Flutter's `ClientUpdateController`. + * + * Dismissals are kept in memory and keyed by version, so dismissing + * one release's banner still lets a later release re-surface — and a + * restart re-shows it, which is acceptable nudging for v1 (matches + * Flutter). Server 404 / network errors stay silent. Constructed at + * launch via the construct-the-singleton trick in `MinstrelApplication`. + */ +@Singleton +class UpdateBannerController @Inject constructor( + @ApplicationScope private val scope: CoroutineScope, + private val repository: UpdateRepository, +) { + private val latest = MutableStateFlow(null) + private val dismissed = MutableStateFlow>(emptySet()) + + /** The available update, or null when none / dismissed / unreachable. */ + val available: StateFlow = + combine(latest, dismissed) { info, seen -> + info?.takeIf { it.version !in seen } + }.stateIn(scope, SharingStarted.Eagerly, null) + + init { + scope.launch { + while (true) { + runOnce() + delay(POLL_INTERVAL_MS) + } + } + } + + fun dismiss(version: String) { + dismissed.value = dismissed.value + version + } + + private suspend fun runOnce() { + val info = runCatching { repository.getLatest() }.getOrNull() ?: return + latest.value = info.takeIf { isVersionNewer(it.version, BuildConfig.VERSION_NAME) } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateRepository.kt new file mode 100644 index 00000000..7a25ef45 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateRepository.kt @@ -0,0 +1,59 @@ +package com.fabledsword.minstrel.update.data + +import com.fabledsword.minstrel.models.UpdateInfo +import com.fabledsword.minstrel.models.wire.UpdateInfoWire +import com.fabledsword.minstrel.update.api.ClientVersionApi +import retrofit2.Retrofit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Thin facade over `/api/client/version`. Reused by the About card + * (Check for updates button) and — in a follow-up — a startup + * version banner. + */ +@Singleton +class UpdateRepository @Inject constructor(retrofit: Retrofit) { + private val api: ClientVersionApi = retrofit.create(ClientVersionApi::class.java) + + suspend fun getLatest(): UpdateInfo = api.getVersion().toDomain() +} + +private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo( + version = version, + apkUrl = apkUrl, + sizeBytes = sizeBytes, +) + +/** + * True when [server] is strictly newer than [installed]. Mirrors + * Flutter's `isVersionNewer` — splits both strings on `.`, parses + * each component as an int (non-numeric = 0), pads the shorter list + * with zeros, then compares component-wise. Handles our date-style + * versions (2026.05.10.1) which exceed semver's three-part shape, + * and treats "2026.05.10" as equal to "2026.05.10.0". + * + * Falls back to string inequality when neither side parsed any + * non-zero component (branch-name builds like "main" vs "dev") so + * a dev build still gets the banner. + */ +fun isVersionNewer(server: String, installed: String): Boolean { + val svr = parseVersion(server) + val ins = parseVersion(installed) + if (svr.all { it == 0 } && ins.all { it == 0 }) { + return server.removePrefix("v") != installed.removePrefix("v") + } + val n = maxOf(svr.size, ins.size) + val svrPadded = svr + List(n - svr.size) { 0 } + val insPadded = ins + List(n - ins.size) { 0 } + return compareComponentWise(svrPadded, insPadded) +} + +private fun parseVersion(v: String): List = + v.removePrefix("v").split('.').map { it.toIntOrNull() ?: 0 } + +private fun compareComponentWise(svr: List, ins: List): Boolean = + svr.zip(ins) + .firstOrNull { (a, b) -> a != b } + ?.let { (a, b) -> a > b } + ?: false diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt new file mode 100644 index 00000000..3ab9087d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt @@ -0,0 +1,67 @@ +package com.fabledsword.minstrel.update.data + +import com.fabledsword.minstrel.BuildConfig +import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.update.api.HealthzApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import retrofit2.Retrofit +import javax.inject.Inject +import javax.inject.Singleton + +private const val POLL_INTERVAL_MS = 5 * 60 * 1000L + +/** + * Result of the most recent /healthz version-compatibility check. + * `Skipped` means the server didn't include `min_client_version` + * (partial deploy or older server) and the UI should not gate. + */ +enum class VersionResult { OK, TOO_OLD, SKIPPED } + +/** + * Polls /healthz periodically and exposes the current + * [VersionResult] for the shell's VersionTooOld banner. Soft-fails + * on network errors — keeps the last-known result rather than + * showing a misleading "too old" on a connectivity blip. + * + * Constructed at app launch via the construct-the-singleton trick + * in [com.fabledsword.minstrel.MinstrelApplication]. + */ +@Singleton +class VersionCheckController @Inject constructor( + @ApplicationScope private val scope: CoroutineScope, + retrofit: Retrofit, +) { + private val api: HealthzApi = retrofit.create(HealthzApi::class.java) + + private val internal = MutableStateFlow(VersionResult.SKIPPED) + val result: StateFlow = internal.asStateFlow() + + init { + scope.launch { + while (true) { + runOnce() + delay(POLL_INTERVAL_MS) + } + } + } + + /** One-shot recheck, bypassing the poll cadence. Used by the banner's "Check now" button. */ + fun recheck() { + scope.launch { runOnce() } + } + + private suspend fun runOnce() { + val response = runCatching { api.check() }.getOrNull() ?: return + val min = response.minClientVersion + internal.value = when { + min.isEmpty() -> VersionResult.SKIPPED + isVersionNewer(min, BuildConfig.VERSION_NAME) -> VersionResult.TOO_OLD + else -> VersionResult.OK + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateBanner.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateBanner.kt new file mode 100644 index 00000000..c9477595 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateBanner.kt @@ -0,0 +1,138 @@ +package com.fabledsword.minstrel.update.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composables.icons.lucide.Download +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.X +import com.fabledsword.minstrel.models.UpdateInfo + +/** + * Shell-level soft banner that nudges an available update. Renders + * nothing until [UpdateBannerController] surfaces a newer bundled APK + * (and the user hasn't dismissed that version). Ports Flutter's + * `UpdateBanner`: a download glyph, "Update Minstrel · {version} + * available", an Install button (download → system installer), and a + * dismiss X. Uses an understated surface tone, not the alarming + * error color reserved for `VersionTooOldBanner`. + * + * Divergence: [ApkInstaller][com.fabledsword.minstrel.update.data.ApkInstaller] + * exposes no byte progress, so the downloading state is an + * indeterminate bar rather than Flutter's determinate one. + */ +@Composable +fun UpdateBanner(viewModel: UpdateBannerViewModel = hiltViewModel()) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val info = state.info + AnimatedVisibility( + visible = info != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + // info can flip to null during the exit animation; hold the last + // non-null so the collapsing banner keeps its content. + val shown = info ?: return@AnimatedVisibility + BannerBody( + info = shown, + stage = state.stage, + message = state.message, + onInstall = { viewModel.install(shown) }, + onDismiss = { viewModel.dismiss(shown.version) }, + ) + } +} + +@Composable +private fun BannerBody( + info: UpdateInfo, + stage: InstallStage, + message: String?, + onInstall: () -> Unit, + onDismiss: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp), + ) { + BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss) + if (stage == InstallStage.DOWNLOADING) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + color = MaterialTheme.colorScheme.primary, + ) + } + if (message != null) { + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + } +} + +@Composable +private fun BannerRow( + info: UpdateInfo, + stage: InstallStage, + onInstall: () -> Unit, + onDismiss: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Lucide.Download, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = if (stage == InstallStage.ERROR) { + "Update failed" + } else { + "Update Minstrel · ${info.version} available" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onInstall, enabled = stage != InstallStage.DOWNLOADING) { + Text(if (stage == InstallStage.DOWNLOADING) "Installing…" else "Install") + } + IconButton(onClick = onDismiss) { + Icon( + imageVector = Lucide.X, + contentDescription = "Dismiss", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateBannerViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateBannerViewModel.kt new file mode 100644 index 00000000..e48652a8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateBannerViewModel.kt @@ -0,0 +1,83 @@ +package com.fabledsword.minstrel.update.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.models.UpdateInfo +import com.fabledsword.minstrel.update.data.ApkInstaller +import com.fabledsword.minstrel.update.data.UpdateBannerController +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val SHARE_STOP_TIMEOUT_MS = 5_000L + +/** Install lifecycle for the banner's Install button. */ +enum class InstallStage { IDLE, DOWNLOADING, ERROR } + +data class UpdateBannerUiState( + val info: UpdateInfo? = null, + val stage: InstallStage = InstallStage.IDLE, + val message: String? = null, +) + +/** + * Thin VM over [UpdateBannerController]. Surfaces the available update + * and runs the download → system-install handoff via [ApkInstaller], + * mirroring the About card's flow (route to "install unknown apps" + * settings first when the permission is missing). + */ +@HiltViewModel +class UpdateBannerViewModel @Inject constructor( + private val controller: UpdateBannerController, + private val installer: ApkInstaller, +) : ViewModel() { + + private val installState = MutableStateFlow(IdleInstall) + + val uiState: StateFlow = + combine(controller.available, installState) { info, install -> + UpdateBannerUiState(info = info, stage = install.stage, message = install.message) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = UpdateBannerUiState(), + ) + + fun dismiss(version: String) = controller.dismiss(version) + + fun install(info: UpdateInfo) { + if (installState.value.stage == InstallStage.DOWNLOADING) return + if (!installer.canInstall()) { + installer.requestInstallPermission() + installState.value = InstallSnapshot( + InstallStage.IDLE, + "Allow installs for Minstrel, then tap Install again.", + ) + return + } + viewModelScope.launch { + installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null) + runCatching { installer.downloadApk(info.apkUrl) } + .onSuccess { apk -> + installer.launchInstall(apk) + installState.value = IdleInstall + } + .onFailure { e -> + installState.value = InstallSnapshot( + InstallStage.ERROR, + "Couldn't download update: ${ErrorCopy.fromThrowable(e)}", + ) + } + } + } +} + +private data class InstallSnapshot(val stage: InstallStage, val message: String?) + +private val IdleInstall = InstallSnapshot(InstallStage.IDLE, null) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/ui/VersionTooOldBanner.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/VersionTooOldBanner.kt new file mode 100644 index 00000000..ab1814f7 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/VersionTooOldBanner.kt @@ -0,0 +1,70 @@ +package com.fabledsword.minstrel.update.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.TriangleAlert +import com.fabledsword.minstrel.update.data.VersionResult + +/** + * Shell-level banner shown when the server reports `min_client_version` + * newer than our build. Non-blocking — locally cached content keeps + * working; the banner is the nudge to install the next APK. "Check + * now" forces a fresh /healthz probe. + */ +@Composable +fun VersionTooOldBanner(viewModel: VersionTooOldViewModel = hiltViewModel()) { + val result by viewModel.result.collectAsStateWithLifecycle() + AnimatedVisibility( + visible = result == VersionResult.TOO_OLD, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.errorContainer) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Lucide.TriangleAlert, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = "This app is older than the server requires. " + + "Cached content still works; install an update when ready.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { viewModel.recheck() }) { + Text( + text = "Check now", + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/ui/VersionTooOldViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/VersionTooOldViewModel.kt new file mode 100644 index 00000000..41f842e0 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/VersionTooOldViewModel.kt @@ -0,0 +1,22 @@ +package com.fabledsword.minstrel.update.ui + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.fabledsword.minstrel.update.data.VersionCheckController +import com.fabledsword.minstrel.update.data.VersionResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +/** + * Tiny VM that exposes the [VersionCheckController]'s current + * [VersionResult] plus its recheck trigger for the banner button. + */ +@HiltViewModel +class VersionTooOldViewModel @Inject constructor( + private val controller: VersionCheckController, + @Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle, +) : ViewModel() { + val result: StateFlow = controller.result + fun recheck() = controller.recheck() +} diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..db77bb4b Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..17987b79 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..09d43914 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..d5f1c8d3 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..4d6372ee Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values/font_certs.xml b/android/app/src/main/res/values/font_certs.xml new file mode 100644 index 00000000..66434243 --- /dev/null +++ b/android/app/src/main/res/values/font_certs.xml @@ -0,0 +1,16 @@ + + + + + @array/com_google_android_gms_fonts_certs_dev + @array/com_google_android_gms_fonts_certs_prod + + + MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpoyLcfobBPv6yyz8x1IxWWmF9c1IGN3vSL6BLNJEUyMEPzC2WZdwT4ZG2cuJTtzeETl6jWFKx68ETtZxNVHe9Iy9NMxEljDqVZ4y6+FlHaiYJqq3LcJpJVuKYz4kvOcyf3M0nDA8mUlVdfsOlw/H4uoNQ7VrAQUKB4kAyfxsKp/RZmnZSJ7+8Ag9aTC+oguTd1iFNuMqDUlpePo6CGuh73iKuq8mYvtdQQ0Yz+mF4j2YWB7Gj0R1k2cCAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs= + + + MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAwEAAaOB1zCB1DAdBgNVHQ4EFgQUhzkS9E6G+x8U7eIYZVgWyN4j2u4wgaQGA1UdIwSBnDCBmYAUhzkS9E6G+x8U7eIYZVgWyN4j2u6heKR2MHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZIIJAMLgh0ZkSjCNMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABywqUAtNkXf2EVQuRGiI3pnNvIYx7N5xj4LMtloEdEqMpEcMa6Qe87qDx2hsArOR1nzQAFGsT/8YIIfX0fAJjQuP1lAcExSxVKbFICEvFBaWuhGgOOZ7CYzfHB6tEzJFLR2DQHQrXLT2HKDDhxhe9hKzqIRDSc5Hjr3jY5MMzfYM5lFvKK9pLqEsP6/Ad9SDhupcVoOWVrSCNKfRb6jpJbZuxJhCnq8tmlV4iy5tEW0a3VBYzpRoBdAaORWqHQTUlt+iL3aH7C5OxhgN/JuxvxXBL/3kkc0wK1ZNuk+sb4lNXmHnVqQYTcyowQHRPCRsPzCCl4ANULRpZjxAd0xUgg= + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..c924a36f --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Minstrel + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..01500c8c --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 00000000..a1c2b2e8 --- /dev/null +++ b/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..a0f223f7 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..3a881b06 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt new file mode 100644 index 00000000..61a3c582 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt @@ -0,0 +1,102 @@ +package com.fabledsword.minstrel.api + +import com.fabledsword.minstrel.auth.AuthStore +import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class AuthCookieInterceptorTest { + private lateinit var server: MockWebServer + private lateinit var authStore: AuthStore + private lateinit var client: OkHttpClient + + @BeforeEach + fun setup() { + server = MockWebServer().apply { start() } + + // AuthStore is Room-backed in production (slice 10). The test only + // exercises the in-memory state mutations of set*; the DAO writes + // happen on the scope and don't affect the synchronous read path + // the interceptor uses. UnconfinedTestDispatcher executes the + // scope.launch blocks immediately so the mock DAO records the + // calls if we ever want to verify persistence. + val dao = + mockk { + every { observe() } returns flowOf(null) + coEvery { get() } returns null + coEvery { upsert(any()) } returns Unit + coEvery { setSessionCookie(any()) } returns Unit + coEvery { setBaseUrl(any()) } returns Unit + } + authStore = AuthStore(dao, TestScope(UnconfinedTestDispatcher())) + + client = + OkHttpClient.Builder() + .addInterceptor(AuthCookieInterceptor(authStore)) + .build() + } + + @AfterEach + fun teardown() { + server.shutdown() + } + + @Test + fun `attaches stored cookie to outgoing requests`() { + authStore.setSessionCookie("session=abc123") + server.enqueue(MockResponse().setResponseCode(200)) + + client.newCall(Request.Builder().url(server.url("/api/test")).build()).execute() + + val recorded = server.takeRequest() + assertEquals("session=abc123", recorded.getHeader("Cookie")) + } + + @Test + fun `clears cookie on 401 response`() { + authStore.setSessionCookie("session=expired") + server.enqueue(MockResponse().setResponseCode(401)) + + client.newCall(Request.Builder().url(server.url("/api/test")).build()).execute() + + assertNull(authStore.sessionCookie.value) + } + + @Test + fun `captures Set-Cookie on successful response`() { + server.enqueue( + MockResponse() + .setResponseCode(200) + .addHeader("Set-Cookie", "session=newvalue; Path=/; HttpOnly"), + ) + + client.newCall(Request.Builder().url(server.url("/api/login")).build()).execute() + + assertEquals("session=newvalue", authStore.sessionCookie.value) + } + + @Test + fun `does not overwrite cookie on a successful response without Set-Cookie`() { + authStore.setSessionCookie("session=existing") + server.enqueue(MockResponse().setResponseCode(200)) + + client.newCall(Request.Builder().url(server.url("/api/anything")).build()).execute() + + assertEquals("session=existing", authStore.sessionCookie.value) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/home/data/HomeRepositoryTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/home/data/HomeRepositoryTest.kt new file mode 100644 index 00000000..161cb8b6 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/home/data/HomeRepositoryTest.kt @@ -0,0 +1,93 @@ +package com.fabledsword.minstrel.home.data + +import app.cash.turbine.test +import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao +import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity +import com.fabledsword.minstrel.metadata.HomeArtistPrewarmer +import com.fabledsword.minstrel.metadata.MetadataProvider +import com.fabledsword.minstrel.models.AlbumRef +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import retrofit2.Retrofit +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class HomeRepositoryTest { + private lateinit var server: MockWebServer + private lateinit var retrofit: Retrofit + private val homeIndexDao = mockk(relaxed = true) + private val metadataProvider = mockk(relaxed = true) + private val prewarmer = mockk(relaxed = true) + + @BeforeEach + fun setup() { + server = MockWebServer().apply { start() } + retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .client(OkHttpClient.Builder().build()) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + } + + @AfterEach + fun teardown() { + server.shutdown() + } + + private companion object { + private val json = Json { ignoreUnknownKeys = true } + } + + private fun repo() = HomeRepository(homeIndexDao, metadataProvider, prewarmer, retrofit) + + @Test + fun `rediscover albums emit skeleton tiles then reveal as rows land`() = runTest { + val indexRow = CachedHomeIndexEntity( + section = HomeRepository.SECTION_REDISCOVER_ALBUMS, + position = 0, + entityType = "album", + entityId = "al1", + ) + every { + homeIndexDao.observeBySection(HomeRepository.SECTION_REDISCOVER_ALBUMS) + } returns flowOf(listOf(indexRow)) + val albumFlow = MutableStateFlow(null) + every { metadataProvider.observeAlbum("al1") } returns albumFlow + + repo().observeRediscoverAlbums().test { + val skeleton = awaitItem() + assertEquals(1, skeleton.size) + assertEquals("al1", skeleton[0].id) + assertNull(skeleton[0].value) // skeleton in position + + albumFlow.value = AlbumRef(id = "al1", title = "Geogaddi", artistId = "a1") + val revealed = awaitItem() + assertEquals("al1", revealed[0].id) + assertEquals("Geogaddi", revealed[0].value?.title) // tile reveals + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `empty section emits empty list`() = runTest { + every { + homeIndexDao.observeBySection(HomeRepository.SECTION_REDISCOVER_ALBUMS) + } returns flowOf(emptyList()) + + repo().observeRediscoverAlbums().test { + assertEquals(0, awaitItem().size) + awaitComplete() + } + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/library/data/LibraryRepositoryTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/library/data/LibraryRepositoryTest.kt new file mode 100644 index 00000000..c4c459af --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/library/data/LibraryRepositoryTest.kt @@ -0,0 +1,116 @@ +package com.fabledsword.minstrel.library.data + +import app.cash.turbine.test +import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao +import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao +import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao +import com.fabledsword.minstrel.cache.db.entities.CachedArtistEntity +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import retrofit2.Retrofit +import kotlinx.serialization.json.Json +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import okhttp3.MediaType.Companion.toMediaType +import kotlin.test.assertEquals + +class LibraryRepositoryTest { + private lateinit var server: MockWebServer + private lateinit var retrofit: Retrofit + private val artistDao = mockk(relaxed = true) + private val albumDao = mockk(relaxed = true) + private val trackDao = mockk(relaxed = true) + + @BeforeEach + fun setup() { + server = MockWebServer().apply { start() } + retrofit = + Retrofit.Builder() + .baseUrl(server.url("/")) + .client(OkHttpClient.Builder().build()) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + } + + private companion object { + private val json = Json { ignoreUnknownKeys = true } + } + + @AfterEach + fun teardown() { + server.shutdown() + } + + @Test + fun `observeArtists maps DAO rows to domain ArtistRefs`() = runTest { + every { artistDao.observeAll() } returns + flowOf( + listOf( + CachedArtistEntity(id = "a1", name = "Boards of Canada", sortName = "Boards of Canada"), + ), + ) + // observeArtists now combines with albums (to derive the tile + // cover from the first album); stub it so combine can emit. + every { albumDao.observeAll() } returns flowOf(emptyList()) + val repo = LibraryRepository(artistDao, albumDao, trackDao, retrofit) + + repo.observeArtists().test { + val first = awaitItem() + assertEquals(1, first.size) + assertEquals("a1", first[0].id) + assertEquals("Boards of Canada", first[0].name) + awaitComplete() + } + } + + @Test + fun `refreshArtistDetail upserts the artist + the embedded albums`() = runTest { + server.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody( + """ + { + "id": "a1", + "name": "Boards of Canada", + "sort_name": "Boards of Canada", + "album_count": 2, + "cover_url": "", + "albums": [ + {"id": "al1", "title": "Music Has the Right to Children", "artist_id": "a1", "sort_title": "Music Has the Right to Children"}, + {"id": "al2", "title": "Geogaddi", "artist_id": "a1", "sort_title": "Geogaddi"} + ] + } + """.trimIndent(), + ), + ) + coEvery { artistDao.upsertAll(any()) } returns Unit + coEvery { albumDao.upsertAll(any()) } returns Unit + val repo = LibraryRepository(artistDao, albumDao, trackDao, retrofit) + + repo.refreshArtistDetail("a1") + + coVerify { artistDao.upsertAll(match { it.size == 1 && it[0].id == "a1" }) } + coVerify { albumDao.upsertAll(match { it.size == 2 && it[0].id == "al1" && it[1].id == "al2" }) } + } + + @Test + fun `getArtist returns null when DAO is empty`() = runTest { + coEvery { artistDao.getById("missing") } returns null + val repo = LibraryRepository(artistDao, albumDao, trackDao, retrofit) + + val result = repo.getArtist("missing") + + assertEquals(null, result) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/library/ui/LibraryViewModelTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/library/ui/LibraryViewModelTest.kt new file mode 100644 index 00000000..d726121f --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/library/ui/LibraryViewModelTest.kt @@ -0,0 +1,109 @@ +package com.fabledsword.minstrel.library.ui + +import app.cash.turbine.test +import com.fabledsword.minstrel.cache.sync.SyncController +import com.fabledsword.minstrel.library.data.LibraryRepository +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.models.ArtistRef +import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.shared.UiState +import com.fabledsword.minstrel.testutil.MainDispatcherExtension +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import kotlin.test.assertEquals + +@ExtendWith(MainDispatcherExtension::class) +class LibraryViewModelTest { + + @Test + fun `initial state is Loading before any DAO emission`() = runTest { + val repo = mockk() + every { repo.observeArtists() } returns MutableSharedFlow() + every { repo.observeAlbums() } returns MutableSharedFlow() + + val vm = LibraryViewModel( + repo, + mockk(relaxed = true), + mockk(relaxed = true), + ) + + assertEquals(UiState.Loading, vm.uiState.value) + } + + // Note: tests below collect the resolved state directly. With + // UnconfinedTestDispatcher, stateIn's upstream Flow runs synchronously + // when the first subscriber attaches — so the `Loading` initialValue + // is replaced by the upstream emission before .test{} sees it. The + // observable behavior we care about is the resolved state, not the + // intermediate Loading. + + @Test + fun `Empty when DAOs emit empty lists`() = runTest { + val repo = mockk() + every { repo.observeArtists() } returns flowOf(emptyList()) + every { repo.observeAlbums() } returns flowOf(emptyList()) + + val vm = LibraryViewModel( + repo, + mockk(relaxed = true), + mockk(relaxed = true), + ) + + vm.uiState.test { + assertEquals(UiState.Empty, awaitItem()) + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun `Success when DAOs emit content`() = runTest { + val repo = mockk() + every { repo.observeArtists() } returns + flowOf(listOf(ArtistRef(id = "a1", name = "Boards of Canada", sortName = "Boards"))) + every { repo.observeAlbums() } returns + flowOf(listOf(AlbumRef(id = "al1", title = "Geogaddi", artistId = "a1"))) + + val vm = LibraryViewModel( + repo, + mockk(relaxed = true), + mockk(relaxed = true), + ) + + vm.uiState.test { + val success = awaitItem() as UiState.Success + assertEquals(1, success.data.artists.size) + assertEquals(1, success.data.albums.size) + assertEquals("a1", success.data.artists[0].id) + assertEquals("al1", success.data.albums[0].id) + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun `Error when an upstream Flow throws`() = runTest { + val repo = mockk() + every { repo.observeArtists() } returns + flow { throw IllegalStateException("DAO blew up") } + every { repo.observeAlbums() } returns flowOf(emptyList()) + + val vm = LibraryViewModel( + repo, + mockk(relaxed = true), + mockk(relaxed = true), + ) + + vm.uiState.test { + val error = awaitItem() as UiState.Error + // ErrorCopy maps a non-HTTP/non-IO throwable to the generic + // fallback rather than leaking the raw exception text. + assertEquals("Something went wrong.", error.message) + cancelAndConsumeRemainingEvents() + } + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/metadata/HomeArtistPrewarmerTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/metadata/HomeArtistPrewarmerTest.kt new file mode 100644 index 00000000..4aaf8bbb --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/metadata/HomeArtistPrewarmerTest.kt @@ -0,0 +1,25 @@ +package com.fabledsword.minstrel.metadata + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test + +class HomeArtistPrewarmerTest { + private val provider = mockk() + + @Test + fun `warms at most the top eight distinct ids, once per session`() { + every { provider.refreshArtist(any()) } returns mockk(relaxed = true) + val prewarmer = HomeArtistPrewarmer(provider) + + // 10 ids with a duplicate and a blank -> 8 distinct non-blank warmed. + prewarmer.warm(listOf("a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9", "")) + // Re-warm: already-warmed ids are skipped. + prewarmer.warm(listOf("a1", "a2")) + + (1..8).forEach { i -> verify(exactly = 1) { provider.refreshArtist("a$i") } } + verify(exactly = 0) { provider.refreshArtist("a9") } // past the top-8 cap + verify(exactly = 0) { provider.refreshArtist("") } // blank skipped + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/metadata/MetadataProviderTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/metadata/MetadataProviderTest.kt new file mode 100644 index 00000000..e9eddc06 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/metadata/MetadataProviderTest.kt @@ -0,0 +1,66 @@ +package com.fabledsword.minstrel.metadata + +import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao +import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao +import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao +import com.fabledsword.minstrel.library.data.LibraryRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.assertTrue + +class MetadataProviderTest { + private val albumDao = mockk(relaxed = true) + private val artistDao = mockk(relaxed = true) + private val trackDao = mockk(relaxed = true) + private val library = mockk(relaxed = true) + + @Test + fun `same missing id fetched once (dedup)`() = runTest { + val scope = this + coEvery { albumDao.observeById("a1") } returns flowOf(null) + val provider = MetadataProvider(albumDao, artistDao, trackDao, library, scope) + + provider.observeAlbum("a1").first() + provider.observeAlbum("a1").first() + testScheduler.advanceUntilIdle() + + coVerify(exactly = 1) { library.refreshAlbumDetail("a1") } + } + + @Test + fun `at most four fetches run concurrently`() = runTest { + val scope = this + val live = AtomicInteger(0) + val peak = AtomicInteger(0) + val gate = CompletableDeferred() + coEvery { library.refreshAlbumDetail(any()) } coAnswers { + val now = live.incrementAndGet() + peak.updateAndGet { p -> maxOf(p, now) } + gate.await() + live.decrementAndGet() + mockk(relaxed = true) + } + (1..10).forEach { i -> coEvery { albumDao.observeById("a$i") } returns flowOf(null) } + val provider = MetadataProvider(albumDao, artistDao, trackDao, library, scope) + + (1..10).forEach { i -> + launch(UnconfinedTestDispatcher(testScheduler)) { + provider.observeAlbum("a$i").first() + } + } + testScheduler.advanceUntilIdle() + + assertTrue(peak.get() <= 4, "peak concurrency was ${peak.get()}, expected <= 4") + gate.complete(Unit) + testScheduler.advanceUntilIdle() + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/models/CoverUrlsTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/models/CoverUrlsTest.kt new file mode 100644 index 00000000..ecbf67f7 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/models/CoverUrlsTest.kt @@ -0,0 +1,16 @@ +package com.fabledsword.minstrel.models + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +class CoverUrlsTest { + @Test + fun `albumCoverPath builds the placeholder cover URL`() { + assertEquals("http://placeholder.invalid/api/albums/a1/cover", albumCoverPath("a1")) + } + + @Test + fun `albumCoverPath is empty for a blank id`() { + assertEquals("", albumCoverPath("")) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/shared/FormattingTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/shared/FormattingTest.kt new file mode 100644 index 00000000..9713947e --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/shared/FormattingTest.kt @@ -0,0 +1,27 @@ +package com.fabledsword.minstrel.shared + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +class FormattingTest { + @Test + fun `formatDuration formats minutes and seconds`() { + assertEquals("0:05", formatDuration(5)) + assertEquals("2:05", formatDuration(125)) + assertEquals("10:00", formatDuration(600)) + } + + @Test + fun `formatDuration uses the zero string for non-positive input`() { + assertEquals("0:00", formatDuration(0)) + assertEquals("0:00", formatDuration(-3)) + assertEquals("--:--", formatDuration(0, zero = "--:--")) + } + + @Test + fun `conversions round-trip seconds and millis`() { + assertEquals(5000, 5.secondsToMillis()) + assertEquals(5, 5000.millisToSeconds()) + assertEquals(5, 5000L.millisToSeconds()) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/testutil/MainDispatcherExtension.kt b/android/app/src/test/java/com/fabledsword/minstrel/testutil/MainDispatcherExtension.kt new file mode 100644 index 00000000..803d2c17 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/testutil/MainDispatcherExtension.kt @@ -0,0 +1,40 @@ +package com.fabledsword.minstrel.testutil + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +/** + * JUnit 5 extension that swaps the kotlinx.coroutines `Dispatchers.Main` + * for a `TestDispatcher` for the duration of each test, then restores + * the original. Apply on a test class with: + * + * @ExtendWith(MainDispatcherExtension::class) + * class MyViewModelTest { ... } + * + * Without this, any ViewModel under test that schedules work on + * `viewModelScope` (which dispatches to Main by default) will hang or + * throw "Module with the Main dispatcher had failed to initialize". + * + * `UnconfinedTestDispatcher` is the default — coroutines run inline, + * which makes flow-based tests simpler to assert on. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherExtension( + private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : BeforeEachCallback, AfterEachCallback { + + override fun beforeEach(context: ExtensionContext) { + Dispatchers.setMain(testDispatcher) + } + + override fun afterEach(context: ExtensionContext) { + Dispatchers.resetMain() + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 00000000..b70485b0 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.android.application) apply false + // kotlin-android NOT registered: AGP 9 built-in Kotlin + KSP 2.3.x + // covers the Kotlin compilation path. Language-level Kotlin compiler + // plugins (serialization, compose-compiler) are still applied + // explicitly per module. + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.ktlint) apply false + alias(libs.plugins.detekt) apply false +} diff --git a/android/config/detekt.yml b/android/config/detekt.yml new file mode 100644 index 00000000..df20b76a --- /dev/null +++ b/android/config/detekt.yml @@ -0,0 +1,23 @@ +# Per-rule overrides layered on top of detekt's defaults +# (`buildUponDefaultConfig = true` in the :app `detekt {}` block). +# +# The pre-2.0 `build:` top-level was removed; failure-on-finding is +# controlled by the Gradle DSL's `failOnSeverity` option instead. + +naming: + # Composables conventionally use PascalCase function names. Allow it + # by ignoring functions annotated @Composable for the FunctionNaming + # rule. Matches every mainstream Compose codebase. + FunctionNaming: + ignoreAnnotated: + - "Composable" + +complexity: + # Room types naturally accumulate one method per query (Dao) or one + # accessor per entity (Database). Scope the exception to these + # annotations; the rule still applies to non-Room interfaces / classes + # where >11 methods IS a smell worth flagging. + TooManyFunctions: + ignoreAnnotated: + - "Dao" + - "Database" diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 00000000..d0549dd1 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,19 @@ +# --enable-native-access=ALL-UNNAMED silences the JDK 22+ "restricted +# method in java.lang.System has been called" warning that Gradle 9.1's +# bundled native-platform-0.22-milestone-28.jar trips via System.load(). +# Future JDKs will require this opt-in to allow native loads from +# unnamed modules; the jar itself doesn't yet declare native access in +# its manifest, so we opt in at the daemon level. +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 --enable-native-access=ALL-UNNAMED +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=true +android.useAndroidX=true +android.nonTransitiveRClass=true + +# detekt 2.0-alpha + ktlint Gradle plugin have intermittent +# configuration-cache compatibility holes. Warn rather than fail so the +# CC speedup applies where it can; revisit when both tools ship stable +# CC-clean releases. +org.gradle.configuration-cache.problems=warn +kotlin.code.style=official diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 00000000..8a2be34f --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,100 @@ +[versions] +# Pinned for JDK 25 + AGP 9 + KSP-built-in-Kotlin compat: +# - Gradle 9.1.0 supports JDK 25 (see gradle-wrapper.properties) +# - AGP 9.0.1 requires Gradle 9.1.0+ +# - Kotlin 2.3.x: AGP 9's built-in Kotlin path; the older kotlin-android +# plugin can't cast AGP 9's ApplicationExtension to the removed +# BaseExtension, and KSP 1.x isn't compatible with built-in Kotlin +# - KSP 2.3.x (PR #2674, merged Oct 2025): adds AGP 9 built-in Kotlin +# support, so we don't need the kotlin-android plugin at all +agp = "9.0.1" +kotlin = "2.3.21" +ksp = "2.3.8" +hilt = "2.59.2" +hilt-androidx = "1.2.0" +compose-bom = "2026.05.01" +nav-compose = "2.8.3" +room = "2.8.4" +retrofit = "2.11.0" +okhttp = "4.12.0" +kotlinx-serialization = "1.7.3" +kotlinx-coroutines = "1.9.0" +kotlinx-datetime = "0.6.1" +kotlinx-serialization-converter = "1.0.0" +media3 = "1.10.1" +coil = "3.0.0-rc02" +palette = "1.0.0" +timber = "5.0.1" +work = "2.10.0" +lifecycle = "2.8.7" +junit-jupiter = "5.11.3" +turbine = "1.2.0" +mockk = "1.13.13" +compose-test = "1.7.5" +ktlint-gradle = "12.1.1" +detekt = "2.0.0-alpha.3" + +[libraries] +androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.13.1" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.9.3" } +androidx-nav-compose = { module = "androidx.navigation:navigation-compose", version.ref = "nav-compose" } +androidx-hilt-nav-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-androidx" } +androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hilt-androidx" } +androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt-androidx" } +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } +compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } +compose-ui = { module = "androidx.compose.ui:ui" } +compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +compose-material3 = { module = "androidx.compose.material3:material3" } +compose-ui-text-google-fonts = { module = "androidx.compose.ui:ui-text-google-fonts" } +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } +room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +retrofit-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "kotlinx-serialization-converter" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } +okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttp" } +okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } +media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } +media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" } +media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" } +media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } +androidx-palette = { module = "androidx.palette:palette-ktx", version.ref = "palette" } +# CMP (Compose Multiplatform) variant — provides icons as ImageVector +# objects (e.g. `Icon(Lucide.Disc3, ...)`). The -android variant ships +# Vector Drawable XML accessed via painterResource(), which is less +# idiomatic for Compose code. CMP works fine in pure-Android Compose; +# "CMP" doesn't mean multiplatform-required. +icons-lucide = { module = "com.composables:icons-lucide-cmp", version = "2.2.1" } +timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version = "1.11.3" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +compose-ui-test = { module = "androidx.compose.ui:ui-test-junit4" } +compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +androidx-room = { id = "androidx.room", version.ref = "room" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" } +detekt = { id = "dev.detekt", version.ref = "detekt" } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..13372aef Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..2e111328 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 00000000..9d82f789 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 00000000..aec99730 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 00000000..ab2964aa --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,22 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "Minstrel" +include(":app")