diff --git a/.claude/commands/release.md b/.claude/commands/release.md index 9abe680..5c4d6eb 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -1,10 +1,10 @@ --- -description: Bump the app version (major/minor/patch or explicit semver), update CHANGELOG.md and What's New files, and commit the release. +description: Bump the app version (major/minor/patch or explicit semver), update CHANGELOG.md and the per-locale Play Store release notes, and commit the release. allowed-tools: Bash(git status:*), Bash(git pull:*), Bash(git diff:*), Bash(git add:*), Bash(git commit:*), Bash(git push:*), Read, Edit, Bash, AskUserQuestion, TodoWrite, TodoRead model: haiku --- -You are preparing a new release for the Coil Android app. Your job is to determine the new version number, update `app/build.gradle.kts`, restructure `CHANGELOG.md`, generate What's New files, and commit the result. +You are preparing a new release for the Coil Android app. Your job is to determine the new version number, update `app/build.gradle.kts`, restructure `CHANGELOG.md`, generate the per-locale Play Store release notes under `fastlane/metadata/android/`, verify their character limits, and commit the result. ## Step 0 — Check readiness @@ -56,9 +56,17 @@ Single question, two options: "Proceed" and "Cancel / change". If the user cance ## Step 4 — Apply changes -**4a. Create What's New files** +**4a. Create release-note (What's New) files** -Create `docs/whatsnew/X.Y.Z-{LOCALE}` files for Coil's launch locales: +Release notes live in the fastlane metadata tree, one file per locale, named after the **new versionCode** — not the versionName: + +``` +fastlane/metadata/android/{LOCALE}/changelogs/{NEW_VERSION_CODE}.txt +``` + +So bumping to versionCode 7 means creating `fastlane/metadata/android/en-US/changelogs/7.txt` and its four siblings. Getting this wrong is the most likely mistake in this step: a file named `0.2.0.txt` or one using the *old* versionCode will simply never be picked up, and the release ships with no notes. + +**All five launch locales are mandatory.** There is no English fallback for store text — a locale with no file gets a blank What's New in the Play Store for that language. `google-play.yml` fails the deploy if any locale is missing a file for the versionCode being released, so an incomplete set blocks the release rather than shipping quietly: - `en-US` (source language — write this one first, the others are translations of it) - `de-DE` @@ -66,21 +74,33 @@ Create `docs/whatsnew/X.Y.Z-{LOCALE}` files for Coil's launch locales: - `es-ES` - `nl-NL` -Each file should contain a short user-facing summary of the release (**max 300 characters** to leave margin for translations, which can expand the text). Base the content on the `## [Unreleased]` section of `CHANGELOG.md`, but write it in plain language for end users — not a technical log. Do not copy changelog bullet points verbatim. +Each file contains a short user-facing summary of the release. Aim for **max 300 characters in the English draft** to leave headroom for translation expansion; the hard limit is 500 per locale (see the check below). Base the content on the `## [Unreleased]` section of `CHANGELOG.md`, but write it in plain language for end users — not a technical log. Do not copy changelog bullet points verbatim. Only include changes that are visible or relevant to the user of the app itself. Exclude anything related to CI/CD workflows, GitHub Actions, GitHub Pages, the website, internal tooling, or other infrastructure — users don't see these. -**Translation quality:** per `AGENTS.md` and `README.md`, unreviewed machine translation is not acceptable for anything a user reads. Draft each locale's text yourself, but flag clearly to the user that the `de-DE`/`fr-FR`/`es-ES`/`nl-NL` drafts need a fluent-speaker review pass before the release actually ships — same standard as `res/values-/strings.xml`. +**Translation quality:** per `AGENTS.md` and `README.md`, unreviewed machine translation is not acceptable for anything a user reads. Draft each locale's text yourself, but flag clearly to the user that the `de-DE`/`fr-FR`/`es-ES`/`nl-NL` drafts need a fluent-speaker review pass before the release actually ships — same standard as `res/values-/strings.xml`. Store text deserves *more* care than in-app strings, not less: in-app strings fall back to English when missing, store text does not. + +**4b. Verify the character limits** + +Google Play's 500-character What's New limit is per locale and counted in characters. Translation length varies a lot by language (Romance/Germanic expand, CJK compress), so an in-limit English draft guarantees nothing about the rest. + +Run the validator with the **new** versionCode: + +``` +tools/check_store_metadata.sh {NEW_VERSION_CODE} +``` + +(Invoke it through `bash` — the repo is developed on Windows with `core.filemode=false`, so the script's executable bit is not recorded in git.) -**Translation note:** Keep English concise (max 300 chars) as a starting point, but the 300/500 numbers are guidance for the *English draft* only, not a guarantee for the rest. Translation length varies a lot by language (Romance/Germanic languages tend to expand, CJK languages tend to compress) — a translation that started from an in-limit English draft can still end up over the limit. +It checks all five locales for a present, non-empty, in-limit file and exits non-zero listing every problem. If a locale is over, re-trim *that locale's* text (summarize or combine points) and re-run until it passes. Do not skip this because the English source was short, and do not hand-count instead: `wc -m` counts bytes rather than characters unless the shell locale is UTF-8, so on Windows it reports 4 for `für` and makes accented locales look over-limit. The script counts correctly and is the only measurement to trust here. -**Mandatory per-locale check — do this for every locale, not just English:** after writing (or translating) each `docs/whatsnew/X.Y.Z-{LOCALE}` file, check that specific file's character count (e.g. `wc -m`, not `wc -c`, since `wc -c` undercounts multi-byte UTF-8 content). No locale file may exceed 500 characters — Google Play's hard "what's new" limit is per-locale, not per-release. If any file is over, re-trim that locale's text (summarize/combine bullets) and re-check — do not assume it's fine because the English source was short. +Treat a non-zero exit as blocking — the same check runs in CI and will fail the deploy. -**4b. Update `app/build.gradle.kts`** +**4c. Update `app/build.gradle.kts`** Replace the `versionCode` and `versionName` lines with the new values. Use Edit — do not rewrite the whole file. -**4c. Update `CHANGELOG.md`** +**4d. Update `CHANGELOG.md`** Get today's date via: `date +%Y-%m-%d` @@ -98,7 +118,7 @@ This preserves an empty Unreleased section for future work and stamps the releas Run: ``` -git add app/build.gradle.kts CHANGELOG.md docs/whatsnew/ +git add app/build.gradle.kts CHANGELOG.md fastlane/metadata/android/ git commit -m "chore(release): bump version to X.Y.Z" ``` diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0b60458 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# The wrapper script must stay LF whatever a contributor's core.autocrlf is set to: CRLF +# line endings make it unrunnable on the Linux CI runner. +gradlew text eol=lf +*.bat text eol=crlf +*.jar binary +# Golden screenshots. Marking them binary keeps Git from trying to diff or convert them, and +# keeps a merge conflict in one from producing an unopenable file. +*.png binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91b1b37..132d76f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,41 @@ jobs: path: app/build/reports/tests/ retention-days: 14 + screenshots: + name: Screenshots + needs: detect + if: needs.detect.outputs.ready == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - uses: gradle/actions/setup-gradle@v3 + + # Compares every screen against the committed golden. Goldens recorded on a developer's + # machine have been checked against this runner and came out byte-identical — Robolectric + # brings its own fonts and Skia — so a locally recorded golden is expected to pass here. + # + # The filter excludes StoreAssetTest. Those images are products, not baselines — they are + # flattened after capture and so never match byte for byte, and a UI change should fail + # one job rather than two. + - name: Verify screenshots + run: ./gradlew :app:verifyRoborazziDebug --tests '*ScreenshotTest' + + # The actual/expected/diff triptych for anything that moved — the point of the job is to + # be able to see what changed, not just that something did. + - name: Upload screenshot diffs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: screenshot-diffs + path: app/build/outputs/roborazzi/ + retention-days: 14 + lint: name: Lint needs: detect diff --git a/.github/workflows/google-play.yml b/.github/workflows/google-play.yml index 734c62f..3e85fa4 100644 --- a/.github/workflows/google-play.yml +++ b/.github/workflows/google-play.yml @@ -36,15 +36,43 @@ jobs: contents: read steps: + # Check out the tag being deployed, not the default branch: the versionCode below and the + # release notes keyed by it must come from the same commit the AAB was built from. - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.event.release.tag_name }} - name: Resolve tag and version id: resolve run: | TAG="${{ inputs.tag || github.event.release.tag_name }}" VERSION="${TAG#v}" + CODE=$(sed -n 's/.*versionCode[[:space:]]*=[[:space:]]*\([0-9][0-9]*\).*/\1/p' app/build.gradle.kts | head -1) + NAME=$(sed -n 's/.*versionName[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' app/build.gradle.kts | head -1) + + if [ -z "$CODE" ]; then + echo "::error::Could not read versionCode from app/build.gradle.kts" + exit 1 + fi + + # Guards against deploying a tag whose version bump never landed — that would pick + # the previous release's notes and upload them under the new version. + if [ "$NAME" != "$VERSION" ]; then + echo "::error::Tag $TAG implies versionName $VERSION but app/build.gradle.kts says $NAME" + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "code=$CODE" >> "$GITHUB_OUTPUT" + echo "Deploying $TAG — versionName $NAME, versionCode $CODE" + + # Fails the deploy if any locale is missing release notes for this versionCode, or if any + # locale's text is over Play's per-locale character limit. + # Invoked via `bash` deliberately: the repo is developed on Windows with core.filemode=false, + # so the script's executable bit is not recorded and `./tools/...` would fail here. + - name: Validate store metadata + run: tools/check_store_metadata.sh "${{ steps.resolve.outputs.code }}" - name: Download AAB and mapping from GitHub Release env: @@ -56,14 +84,19 @@ jobs: --pattern "mapping.txt" \ --dir release-assets + # Translate fastlane's layout (/changelogs/.txt) into the flat + # whatsnew- naming upload-google-play expects. The validate step above already + # guaranteed every launch locale has a file here. - name: Prepare what's new run: | - VERSION="${{ steps.resolve.outputs.version }}" + CODE="${{ steps.resolve.outputs.code }}" mkdir -p whatsnew - for FILE in docs/whatsnew/${VERSION}-*; do + for DIR in fastlane/metadata/android/*/; do + LOCALE=$(basename "$DIR") + FILE="${DIR}changelogs/${CODE}.txt" if [ -f "$FILE" ]; then - LOCALE=$(basename "$FILE" | sed "s/^${VERSION}-//") cp "$FILE" "whatsnew/whatsnew-${LOCALE}" + echo "release notes: $LOCALE" fi done diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml new file mode 100644 index 0000000..39e0739 --- /dev/null +++ b/.github/workflows/screenshots.yml @@ -0,0 +1,81 @@ +name: Record screenshots + +# Re-recording is how a visual change is *accepted*, which is a decision rather than something +# that should happen on every push. There are therefore two deliberate ways to ask for it +# (a local `recordRoborazziDebug` is equally valid — the files come out the same): +# +# 1. `workflow_dispatch` — pick the branch in the Actions tab. GitHub only offers this once +# the workflow exists on the default branch, which for this repo means after `screenshots.yml` +# has reached `main` at a release. +# 2. A push to `develop` whose commit message contains `[record-screenshots]`. That works +# today, needs no default-branch registration, and keeps the opt-in explicit — put the +# marker in the commit that changes the UI. +# +# Either way the run commits the regenerated files back to the branch it ran on. It cannot be +# used on `main`: that branch takes pull requests only, so the push would be rejected. +on: + workflow_dispatch: + push: + branches: [develop] + +permissions: + contents: write + +jobs: + record: + name: Record and commit + if: >- + github.event_name == 'workflow_dispatch' || + contains(github.event.head_commit.message, '[record-screenshots]') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - uses: gradle/actions/setup-gradle@v3 + + # Recording here rather than locally is about who is doing it, not about rendering: the + # two have been compared and produce identical files. This is the route for a change + # someone wants accepted without a local Android toolchain, and it keeps the baseline and + # the check in the same place by default. + - name: Record goldens + run: ./gradlew :app:recordRoborazziDebug --tests '*ScreenshotTest' + + # Same run, same renderer, so the store listing and the website never show an older UI + # than the goldens do. `recordRoborazziDebug` is finalised by `flattenStoreAssets`, which + # strips the alpha channel Play rejects and copies the phone set into the Pages site. + - name: Record store and website assets + run: ./gradlew :app:recordRoborazziDebug --tests '*StoreAssetTest' + + # The images are listing metadata, so they answer to the same validator as the listing + # text: format, alpha, dimensions, aspect ratio and count. + - name: Validate store images + run: tools/check_store_metadata.sh + + - name: Commit updated screenshots + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add app/src/testDebug/screenshots fastlane/metadata/android docs/pages/assets + if git diff --cached --quiet; then + echo "Screenshots unchanged — nothing to commit." + exit 0 + fi + git commit -m "test(screenshots): re-record screenshots on the CI runner" + git push + + # Uploaded whether or not anything changed, so the run itself is reviewable: the images + # are the result here, and a commit hash does not show you what the app looks like. + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: screenshots + path: | + app/src/testDebug/screenshots/ + fastlane/metadata/android/en-US/images/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index f60fa06..f264f3b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,12 @@ .gradle/ build/ .idea/ +.kotlin/ local.properties *.iml *.jks *.keystore + +# JVM crash dumps +hs_err_pid*.log +replay_pid*.log diff --git a/AGENTS.md b/AGENTS.md index 9930eab..f1858c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,22 +9,31 @@ Coil is a planned native Android remote control for [Phoniebox](https://github.c v3 (`future3/main`), talking to the box over its ZeroMQ RPC/PubSub interface on the local network. See `README.md` for the user-facing pitch and feature list. -**Current state: pre-implementation.** There is no `:app` module and no root Gradle build yet. -The repo currently holds: the full design/architecture spec, a standalone JVM transport spike, -a Python protocol probe, brand assets, and a static HTML mockup. Treat `docs/implementation-plan.md` -as the detailed spec for anything not covered below — this file is a condensed map of it, not a -replacement. +**Current state: implemented through the plan's phase 7, with most of phase 8 done.** The app +builds, tests and lints; what it has not had is a run against a real box. Treat +`docs/implementation-plan.md` as the detailed spec for anything not covered below — this file is a +condensed map of it, not a replacement. See "Implementation status" for what is and is not done. ## Repo layout | Path | Contents | |---|---| +| `app/` | Compose UI, navigation, screens, string resources for all five locales, adaptive and notification icons, `PlayShortcutActivity`. All translatable strings live here, in one file per locale. | +| `core-domain/` | Plain JVM module: models, repository interfaces, `PlayFavoriteUseCase`. No Android dependency. | +| `core-transport/` | ZeroMQ client: `ZmqRpcClient` (DEALER), `ZmqStatusSubscriber` (SUB), `PhonieboxSession` (watchdog, reconnect), `ConnectionManager` (active box, reference-counted lifetime), `Commands`, the payload parsers, `HostDiscovery` (mDNS), `BoxProbe`. | +| `core-data/` | Room database and DAOs, DataStore settings, repository implementations, settings export/import. | +| `feature-media/` | `PhonieboxPlayer` (`SimpleBasePlayer`), `PhonieboxMediaService` (`MediaSessionService`), notification handling, `AutoSessionStarter`, `BootReceiver`, `MediaSessionBinder`. | +| `feature-shortcuts/` | `ShortcutPublisher` (dynamic and pinned shortcuts), `ShortcutSynchronizer`, `PlayDeepLink` (`coil://play?box=…`). | +| `app/src/testDebug/` | Screenshot tests and their golden PNGs — the app module's only tests. In the *debug* unit test source set, not `test`, because they compose into a debug-only activity. See "Screenshot tests". | +| `app/src/debug/` | `HiltTestActivity` and the manifest entry for it: an empty, unexported Hilt entry point the screenshot tests compose into. Debug builds only. | | `docs/implementation-plan.md` | Full architecture/design spec — tech stack, module layout, transport design, data model, media session, multi-box design, branding, i18n rules, phased build plan. **Read before implementing anything non-trivial.** | | `docs/protocol-notes.md` | Condensed Phoniebox v3 ZMQ protocol reference, distilled from the upstream Python source. | -| `docs/pages/` | The GitHub Pages site (Jekyll), deployed by `pages.yml` — landing page and privacy policy, served at `coilforphoniebox.app` via the `CNAME` file in this folder. Kept separate from the planning docs above so the Pages *site* only contains user-facing content — the planning docs are still public in the repo, just not part of the deployed website. | +| `docs/pages/` | The GitHub Pages site (Jekyll), deployed by `pages.yml` — landing page (with generated screenshots in `assets/screenshots/`) and privacy policy, served at `coilforphoniebox.app` via the `CNAME` file in this folder. Kept separate from the planning docs above so the Pages *site* only contains user-facing content — the planning docs are still public in the repo, just not part of the deployed website. | | `spike/` | Standalone Gradle/Kotlin JVM project validating the transport approach (JeroMQ DEALER client) against a real box, independent of Android. | +| `tools/check_store_metadata.sh` | Validates `fastlane/metadata/android/` against Play's per-locale character limits (title 30, short 80, full 4000, release notes 500) **and its listing images** (24-bit PNG, no alpha, 320–3840 px a side, long side at most twice the short one, at least two per set). Pass a versionCode to also require release notes in every launch locale. Run by `/release` and by `google-play.yml`. Counts characters as bytes-minus-UTF-8-continuation-bytes rather than using `wc -m`, which silently counts bytes in a non-UTF-8 shell locale. | | `tools/probe_phoniebox.py` | Python/pyzmq script that does the same validation from the other side (REQ vs DEALER framing, pipelining, PubSub schema dump) — used to sanity-check protocol assumptions against a live box before trusting them in Kotlin. | -| `android/` | Reference bundle for theme (`theme/Color.kt`, `theme/Theme.kt`) and adaptive icon XML (`res/`) to be dropped into the real `:app` module once it exists. Not a buildable module on its own (no manifest, no build file). | +| `android/` | Original reference bundle for theme and adaptive icon XML. Now **copied into `app/`** and kept only as the source of those files' provenance; edit the copies under `app/src/main/`, not these. | +| `fastlane/metadata/android/` | All Play Store text and images, one folder per locale in `fastlane supply` layout: `title.txt`, `short_description.txt`, `full_description.txt`, release notes at `changelogs/{versionCode}.txt`, and screenshots under `en-US/images/` generated by `StoreAssetTest` (see "Store assets"). Written by `/release`, validated by `tools/check_store_metadata.sh`, consumed by `google-play.yml`. See that folder's `README.md`. | | `brand/` | Logo mark SVG. | | `mockup/` | Static HTML UI mockup (`coil-mockup.html`) — visual reference only, not implementation. | | `CHANGELOG.md` | [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)-format history. **Update the `## [Unreleased]` section as part of any user-facing change** — new feature, fix, or behaviour change. `release.yml` extracts release notes straight from the `## [x.y.z]` heading matching `versionName`, so headings must stay exact and the `Unreleased` section shouldn't go stale. | @@ -33,17 +42,26 @@ replacement. `.github/workflows/` mirrors the setup from a sibling project, adapted for Coil ahead of time: -- `ci.yml` / `codeql.yml` — build/test/lint and CodeQL analysis on PRs to `main`. Both start with a - `detect` job that checks for a root `./gradlew` and skip (not fail) everything else until it - exists — these are inert no-ops until Phase 1 (module skeleton) lands. +- `ci.yml` / `codeql.yml` — build/test/lint/screenshot verification and CodeQL analysis on PRs to + `main` or `develop`. Both start with a `detect` job that checks for a root `./gradlew`; that gate + is now satisfied, so these actually build, test and lint. +- `screenshots.yml` — re-records the golden screenshots *and* the store/website images on the + Linux runner and commits them back to the branch. Triggered from the Actions tab, or by a push + to `develop` whose commit message contains `[record-screenshots]`. See "Screenshot tests". - `release.yml` — tags `main` from `app/build.gradle.kts`'s `versionName`, builds a signed - APK/AAB, and cuts a GitHub Release with notes pulled from `CHANGELOG.md`. Gated the same way: - a no-op until `app/build.gradle.kts` exists. Needs `KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, + APK/AAB, and cuts a GitHub Release with notes pulled from `CHANGELOG.md`. Its gate + (`app/build.gradle.kts` exists) is now satisfied too. Needs `KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD` repo secrets before it can actually sign a build. Release notes come from `CHANGELOG.md`'s `## [x.y.z]` heading matching `versionName` — see that file's own note. - `google-play.yml` — reusable workflow (`workflow_call`/manual dispatch) that uploads a tagged release's AAB to Google Play, package `app.coilforphoniebox`. Needs a `SERVICE_ACCOUNT_JSON` - repo secret and an active Play Console listing. + repo secret and an active Play Console listing. Checks out the *tag* (not the default branch) + so the `versionCode` it reads from `app/build.gradle.kts` matches the AAB, and aborts if that + file's `versionName` disagrees with the tag. Release notes come from + `fastlane/metadata/android/{locale}/changelogs/{versionCode}.txt`, gated by + `tools/check_store_metadata.sh` — a missing or over-limit locale fails the deploy. It does + **not** upload listing text (title/descriptions) yet; `r0adkll/upload-google-play` handles only + the AAB, mapping and release notes. - `pages.yml` — deploys `docs/pages/` via Jekyll to GitHub Pages on push to `main` (path-filtered to `docs/pages/**`). GitHub Pages must be enabled in repo settings with source "GitHub Actions", and the `coilforphoniebox.app` DNS must point at GitHub Pages, for the `CNAME` file in @@ -53,6 +71,18 @@ replacement. `Build`/`Unit Tests`/`Lint`/`Analyze (Kotlin)` as required status checks (these currently pass by skipping, per the gate above). `ci.yml`/`codeql.yml` also trigger on PRs into `develop`. +## Branching model + +`main` represents **the released state of the app** — nothing else. `develop` is where all +ongoing work lands first: commits, feature branches, PRs, all of it. `develop` is intentionally +left unprotected (direct pushes are fine — the maintainer is a solo developer and doesn't want +extra ceremony there). `main` only ever advances by merging `develop` into it at release time, +which is what triggers `release.yml`'s tag/build/publish. + +**Never open a pull request targeting `main`.** Base every branch and PR on `develop` instead. +Promoting `develop` to `main` for a release is the maintainer's call to make, not something to +initiate unprompted. + ## Slash commands `.claude/commands/` — repo-specific Claude Code commands: @@ -61,14 +91,39 @@ by skipping, per the gate above). `ci.yml`/`codeql.yml` also trigger on PRs into `Unreleased` section when the change is user-facing, and offers to push (to a branch — `main` is protected). - `/release` — bumps `versionName`/`versionCode` in `app/build.gradle.kts`, stamps the - `CHANGELOG.md` `Unreleased` section with the new version, and generates - `docs/whatsnew/X.Y.Z-{locale}` files for `en-US`/`de-DE`/`fr-FR`/`es-ES`/`nl-NL` (the format - `google-play.yml` expects). Gated on `app/build.gradle.kts` existing — a no-op until Phase 1. + `CHANGELOG.md` `Unreleased` section with the new version, and generates release notes at + `fastlane/metadata/android/{locale}/changelogs/{versionCode}.txt` for all five launch locales, + then verifies each against Play's 500-character limit via `tools/check_store_metadata.sh`. + Note the filename is the **versionCode**, not the versionName — that is what fastlane and the + Play API key release notes by. Gated on `app/build.gradle.kts` existing, which it now does. ## Commands -There is no root build yet, so the README's `./gradlew assembleDebug` is aspirational until -Phase 1 (module skeleton) lands — see the phase plan in `docs/implementation-plan.md` §14. +```bash +./gradlew assembleDebug # debug APK +./gradlew test # unit tests (core-domain, core-transport, :app screenshot tests) +./gradlew lint # lint; HardcodedText is an error, MissingTranslation a warning +./gradlew assembleRelease # minified APK; unsigned unless KEYSTORE_PATH and friends are set + +./gradlew :app:verifyRoborazziDebug --tests '*ScreenshotTest' # compare against the goldens +./gradlew :app:recordRoborazziDebug --tests '*ScreenshotTest' # accept what the UI looks like now +./gradlew :app:recordRoborazziDebug --tests '*StoreAssetTest' # rebuild the Play/website images +tools/check_store_metadata.sh # validate listing text and images +``` + +The `--tests` filters are not optional: goldens are baselines to compare, store assets are +products to regenerate, and running one as the other produces confusing failures. If a store +image is missing after a record run, add `--rerun-tasks` — the test task's real outputs live +outside `build/`, so Gradle happily calls it up to date when the files are gone. + +Requires JDK 17 (CI uses Temurin 17) and Android SDK 36. `local.properties` with `sdk.dir` is +needed for a local build and is deliberately not checked in — note that lint rejects an unescaped +Windows drive letter there, so write `sdk.dir=C\:/Android/Sdk`, not `C:/Android/Sdk`. + +**Toolchain:** Gradle 8.13, AGP 8.13.2, `compileSdk`/`targetSdk` 36, `minSdk` 26. AGP 8.13 is the +newest 8.x and still takes Gradle 8.13, which is why `targetSdk` 36 needed no Gradle upgrade. +Moving to AGP 9.x would require Gradle 9.5 plus a DSL migration (`kotlinOptions` → `compilerOptions` +among others) and is deliberately a separate exercise. **Transport spike** (validates the DEALER/empty-delimiter approach against a real box): ```bash @@ -103,6 +158,28 @@ server in this repo. - Parse `playerstatus` leniently (`ignoreUnknownKeys = true`, tolerate stringified numbers and missing fields like `duration`/`album` on web radio streams). Field names are not a stable contract upstream. +- **Never send `as_thread`.** `jukebox/plugs.py` starts a daemon thread and returns the `Thread` + object instead of the function's result, so any call carrying it answers with something + unusable — it is fire-and-forget only. The implementation plan §6.2 recommends it for slow + calls; that advice is wrong for anything whose result you need, which is every call Coil makes. + The consequence is that a library call occupies the box's sequential RPC loop, and with it card + detection, for as long as it runs — so call them rarely and never on a timer. +- **Cover art takes two requests.** `get_single_coverart` / `get_album_coverart` answer the first + request for a song with `CACHE_PENDING` and queue the extraction on a worker thread + (`coverart_cache_manager.py`); an empty string means "no artwork". Only a later request returns a + file name, so a single call never yields a cover. `LibraryParser.coverArt` models the three + outcomes and both callers retry. +- **Never let cover art gate other state.** Every screen `combine`s the player state, and `combine` + emits nothing until *all* inputs have emitted once — so a cover flow whose first value needs a + round trip freezes the title, progress and controls with it. `PlayerRepository.coverUrl` is a + `StateFlow` that starts at null and is filled in by a background resolver for exactly this + reason. The same caution applies to any future flow that needs the network to produce its first + value. +- **There is no `core` RPC package.** `core.version`, `core.started_at` and `core.git_state` are + *published topics*; asking for `core.version` over RPC answers with an error. Use + `player.ctrl.playerstatus` as a reachability ping — it returns the poller's cached dict without + touching MPD. Note the box does not publish `core.version` at all, only `core.git_state`, so + Coil subscribes to the `core.` prefix and shows whichever it gets. - Never call `list_all_dirs` (unbounded memory on large libraries) — use `get_folder_content` one level at a time instead. - The RPC port is unauthenticated and unencrypted. Design is LAN-only; never add or expose @@ -115,8 +192,9 @@ These are settled decisions from `docs/implementation-plan.md`, not open questio - **Stack:** Kotlin + Jetpack Compose, MVVM/unidirectional state flow, JeroMQ for transport, androidx.media3 (`SimpleBasePlayer`) for the media session, kotlinx.serialization, Hilt, Room + - DataStore for persistence. Planned module split: `:app`, `:core-transport`, `:core-domain`, - `:core-data`, `:feature-media`, `:feature-shortcuts`. + DataStore for persistence. Module split: `:app`, `:core-transport`, `:core-domain`, `:core-data`, + `:feature-media`, `:feature-shortcuts`. Versions are pinned exactly in `gradle/libs.versions.toml` + — lint reports newer ones as warnings, and that is fine; reproducibility is the point (§13.3). - **No Google Play Services / proprietary dependencies, anywhere.** This is deliberate (keeps the F-Droid option open at effectively zero cost) and must hold even when a convenient library appears later. @@ -135,7 +213,236 @@ These are settled decisions from `docs/implementation-plan.md`, not open questio - **No custom fonts** — Material 3 default type scale only (keeps full Latin-1 coverage for all launch locales without APK cost). - **Dynamic colour (Material You) is off by default**, toggle in settings; brand colours are fixed - regardless of theme (see `android/theme/Color.kt` for the token set). + regardless of theme (the token set now lives in `app/src/main/kotlin/.../ui/theme/Color.kt`). + +## The sleep timer, and the timers Coil refuses + +The box's `timers` package holds four: `timer_stop_player`, `timer_shutdown`, +`timer_idle_shutdown` and `timer_fade_volume`. **Only `timer_stop_player` is reachable from Coil**, +because it is the only one that does not switch the box off. Coil promises never to send a command +that can take the box down (§1, §16); a delayed shutdown is still a shutdown, and on an +unauthenticated LAN port it is one that could be scheduled by anything on the network. That is why +`Commands.timer` is hard-wired to the `timer_stop_player` plugin rather than taking a plugin name, +why the SUB socket subscribes to that one topic instead of the `timers.` family, and why +`CommandsTest` asserts that no command in the catalogue mentions `shutdown`, `host.` or `reboot`. +Adding a shutdown timer is not a small change to make — it reverses a promise in the README. + +Protocol details, distilled from `jukebox/multitimer.py` and `components/timers/__init__.py`: + +- `start` takes **`wait_seconds`**, `cancel` takes nothing, `get_state` returns + `{enabled, remaining_seconds, wait_seconds, type}`. +- **`start` on a running timer is ignored** — `GenericTimerClass` logs "Ignoring start command" and + returns. Setting 30 minutes over a running 60 would silently keep the 60, so + `PlayerRepositoryImpl.startSleepTimer` cancels first when one is already running. +- State is published on the topic **`timers.timer_stop_player`** (`.`), but only + when it changes — there is no per-second tick. A box that has not been given a timer since booting + therefore has nothing in the last-value cache, which is why the timer sheet asks `get_state` once + when it opens, and why the countdown is interpolated locally from `remainingSecondsAt`, the same + way the progress bar interpolates elapsed time. + +The player screen's shuffle and repeat icons moved into one "playback options" menu alongside the +timer: three mode toggles flanking the transport controls is more than that screen can carry. The +menu's button is tinted when any of the three is active, and a running timer also shows a +"Stops in …" line under the transport row, because a countdown to silence should not be hidden +behind a tap. + +## Library search + +**There is no search command in the Phoniebox protocol.** The content RPCs are +`get_folder_content`, `list_albums` and `list_songs_by_artist_and_album`; `list_all_dirs` is off +limits. Search therefore runs entirely against the Room cache — instant, free, and working with the +box switched off — and is not a thin wrapper around something the box does. + +- **Matching is done on a folded column, not with `LIKE` on the raw text.** SQLite's `LIKE`, `lower()` + and `COLLATE NOCASE` are case-insensitive for ASCII only, so `bär` would not find `Bär` — and the + launch locales are full of umlauts. `SearchText` (in `:core-data`) lower-cases and strips accents + with `Locale.ROOT`, once on the way into `searchText` on every folder, track and album row, and + again for the query. Both sides must always use the same fold, which is why it is one object. + Known limit: `ß` is not decomposed, so "Strasse" does not find "Straße". +- Contains-matching cannot use an index, so the DAO queries carry a `LIMIT` instead of an index. + Wildcards in the query are escaped — `100%` is a search, not a match-everything. +- **What is searchable is what has been fetched**: all albums once the album tab has loaded, plus + folders and tracks from levels that were opened. The empty state says so rather than implying the + library is empty. +- **`indexLibrary` closes that gap and is opt-in for a reason.** It walks the tree one level at a + time with a pause between levels, refuses to start while the box is playing, is cancellable, and + reports every outcome — including hitting its own 5000-folder cap, which leaves search incomplete + and must not be presented as success. Never call it on a timer, at app start, or after a rescan: + unattended traffic on the shared RPC socket is exactly what §6 rules out. Schema version 3 added + the folded columns and **cleared the cached library rows** rather than backfilling them, since + accent folding cannot be expressed in SQL and that cache is the one disposable thing in the + database. + +## Screenshot tests + +The UI is captured as PNGs and compared on every PR. The tests live in +`app/src/testDebug/kotlin/.../screenshot/`, the goldens in `app/src/testDebug/screenshots/`, +one PNG per state, committed so a review sees the picture next to the diff that changed it. + +They run **on the JVM** — Robolectric renders the real Compose UI with native graphics, and +Roborazzi writes and compares the files. No emulator, no device, no box. + +Three levels, and the distinction matters: + +- **`app/`** — the whole `CoilApp` scaffold with a screen inside it: top bar with the box + indicator, bottom navigation, mini player, offline banner. This is what the app looks like. + Captured on **three device profiles** (`_phone`, `_small`, `_tablet`), so the goldens say + something about layout and not only about colour. +- **`player/`, `library/`** — one screen on its own, for states that would be tedious to reach + through the whole app: web radio with no duration, a running sleep timer, search with and + without results. +- **`chrome/`** — the scaffold's own components in isolation, mainly the box indicator's four + connection states, where the difference is one small coloured dot. + +- **The reason it is Robolectric and not an emulator is the clock.** This UI interpolates + elapsed time, counts a sleep timer down and debounces the search field. Robolectric's looper + is paused, so all of that holds still and a golden is the same picture every run; on a device + it would not be. Anything that genuinely has to advance does so deliberately, via + `ScreenshotTest.advanceTime` — the search results are the only case so far. +- **Nothing is written during `./gradlew test`.** `captureRoboImage` is inert unless Roborazzi's + own tasks set its system property, so an ordinary test run only checks that every screen still + composes — which is worth having on its own, since none of this used to be covered at all. +- **Recording locally is fine, and this has been checked.** The worry going in was the usual one + — that text rendering differs between hosts and a golden recorded on Windows would fail the + Linux runner on antialiasing alone. It does not, at least here: a full run on the runner + against goldens recorded on Windows (JDK 23) reported every file byte-identical. Robolectric + brings its own fonts and its own Skia, so the host contributes less than one would expect. The + SDK level and the JDK major version *do* matter — see the `sdk=35` note in + `robolectric.properties` — so keep those in step rather than the operating system. + `screenshots.yml` still exists as the canonical way to accept a new look, and it is the only + option for a contributor whose setup differs, but a local `recordRoborazziDebug` is a + legitimate way to update a golden. +- **A failing verify uploads what it saw.** `_actual` and `_compare` images land in + `app/build/outputs/roborazzi/` and the `Screenshots` CI job uploads them. That directory is + flat, so **golden file names must stay unique across subfolders** — two `folders_light.png` + in different directories would overwrite each other's diff. +- **The screens take a ViewModel, not a state object**, so the tests build the real ViewModel on + fake repositories (`FakeRepositories.kt`) rather than a stateless copy of the screen. That + keeps the ViewModel's own flow wiring inside the picture: if `combine` stalls because + something never emitted, the golden shows an empty screen and the test fails. +- **The whole-app goldens need Hilt**, because `CoilApp`'s screens resolve their view models + through `hiltViewModel()`. `FakeRepositoryModule` replaces `:core-data`'s `RepositoryModule` + via `@TestInstallIn`, so nothing opens a socket, a database or a DataStore file; + `DatabaseModule` is left in place and simply never asked for anything. The content is composed + into `HiltTestActivity` (debug source set — Robolectric will not launch an activity that is + not in the merged manifest), and `robolectric.properties` names `HiltTestApplication`. Every + concrete test class needs `@HiltAndroidTest`, which is what generates the component the rule + installs. Fakes are singletons exposed under their own type too, so a test can inject one and + change what the box appears to be doing before composing. +- **Destinations are reached by clicking the navigation bar**, not by composing a screen + directly, so the navigation is covered as well: a destination that stopped being reachable + fails the test rather than quietly keeping its old picture. +- **Devices are `@Config(qualifiers = …)` on a subclass** of `AppScreenshotTest` — a new size is + a three-line class. It has to be an annotation: the qualifiers must be set before the host + activity exists, and `RuntimeEnvironment.setQualifiers` inside a test body is too late (the + same trap as the locale). Note this simulates the *window*, not the device: there are no + system bars, no rounded corners and no notch in a golden. +- **Cover art never reaches the network.** The image loader is replaced with a fake engine that + answers every URL with a flat colour, instantly; the box's HTTP cover cache is not reachable + from CI, and an image arriving a frame late would make the golden depend on timing. +- **Roborazzi captures the window, not the node**, whichever node it is handed — so a component + golden shortens the window instead, via `@Config(qualifiers = "+h200dp")` on the class, rather + than being a 40 dp chip above a screenful of background. +- **The screenshot tests are debug-only**, and live in the `testDebug` source set for it: + `HiltTestActivity` is a debug class, so the release unit test variant could not even compile + them. Their dependencies are declared `testDebugImplementation` to match, which leaves + `testReleaseUnitTest` empty rather than broken. +- **Roborazzi is pinned at 1.60.0** because 1.61.0 and later carry Kotlin 2.3 metadata that + Kotlin 2.0.21 cannot read. Upgrading it means upgrading Kotlin and the Compose compiler too. + +Covered so far: the whole app on three devices (player light and dark, library, favourites, +settings, offline, onboarding), the player screen (playing, paused, idle, web radio, sleep +timer, light and dark), the library screen (folders, tracks, albums, search, no results, empty, +light and dark) and the chrome components. + +The tablet goldens are the reason the player got a tablet layout at all: `app/player_tablet.png` +used to be a full-screen cover with its own controls pushed off the bottom, which is obvious in a +picture and invisible in a diff. The remaining tablet goldens still show single-column screens +stretched wide — see "Implementation status" item 5 — and `player/playing_landscape.png` covers +the short-and-wide case that no other golden does. + +### Recording is a decision, and it happens on CI + +`screenshots.yml` is the only thing that should write a golden that gets committed. It runs +either from the Actions tab (`workflow_dispatch`, available once this workflow has reached +`main` at a release) or on a push to `develop` whose **commit message contains +`[record-screenshots]`** — put that marker in the commit that changes the UI. The run +re-records the goldens *and* the store assets on the same runner, validates the images, and +commits the result back to the branch. Its own commit carries no marker, so it cannot loop. + +## Store assets + +`StoreAssetTest` generates the Play listing screenshots and the website's, from the same fakes +and the same harness as the goldens, into `fastlane/metadata/android/en-US/images/` — +`phoneScreenshots` (1233×2460), `sevenInchScreenshots` (1200×1920) and `tenInchScreenshots` +(2560×1600), five images each. The phone set is copied to `docs/pages/assets/screenshots/`, +which is what the landing page shows. + +**They are products, not baselines**, and the distinction runs through everything here: + +- They are **excluded from verify** by the `--tests` filter. A UI change should fail one job, + not two, and they are flattened after capture so they would never match byte for byte anyway. +- **Play's rules decide the frames, not the goldens'.** A screenshot must be a 24-bit PNG with + no alpha, every side between 320 and 3840 px, and the long side at most twice the short one. + The goldens' phone frame is 2.23:1 and would be rejected outright, which is the whole reason + these are generated separately rather than copied across. The phone profile is 1.995:1 — + as tall as Play allows and no taller, because every dp of height is a dp of the player that + fits without scrolling. +- **Flattening happens in Gradle**, in `flattenStoreAssets`, because `java.awt` and + `javax.imageio` are not on an Android unit test's classpath. `recordRoborazziDebug` is + finalised by it, so one command still does the whole job. (In a Kotlin DSL build script the + imports must be at the top of the file: bare `java.awt.Color` parses as a property on the + Java plugin extension, which is what `java` means there.) +- **`tools/check_store_metadata.sh` validates the images**, not just the listing text: format, + alpha channel, dimensions, aspect ratio, file size, and at least two images per set. It reads + the PNG header with `od` rather than depending on ImageMagick or Python, so it runs anywhere. + `google-play.yml` already calls this script, so a bad image fails a deploy. +- **Content comes from `StoreFixtures`, not `Fixtures`.** A golden's content is chosen to stress + the layout — long titles that ellipsise, umlauts, names that run off the edge. A store + screenshot has the opposite job, so the names there are short enough to read at thumbnail size + and nothing truncates. +- **English only, deliberately.** Another locale is one more subclass with a locale qualifier, + but three of the five translations are unreviewed drafts missing 43 strings, and a store + screenshot with English fallbacks visible in it is worse than no localised screenshot. +- The **10-inch set shows the untouched tablet layout** — a phone layout stretched across + 1280 dp. It is accurate and not flattering; publishing the phone set alone may serve the + listing better until that layout is addressed. + +Neither `google-play.yml` nor `r0adkll/upload-google-play` uploads images yet, so these files +are the canonical source waiting for a tool that reads them — the same position the listing text +is in. + +The locale axis is one German golden rather than all five launch locales — +`library/folders_de.png`, which is also the only place the translations can be *seen* without a +phone. It shows the drift honestly: 43 of the 163 strings have no German at all yet and fall +back to English on screen. + +## The `coil://play` deep link + +`PlayDeepLink` in `:feature-shortcuts` owns the format; `PlayShortcutActivity` in `:app` answers it. +It is **exported** (`AndroidManifest.xml`), so it is not a private shortcut mechanism: an automation +app, an NFC tag, an `` in a note, another app or `adb shell am start -a +android.intent.action.VIEW -d "coil://play?…"` all start a favourite the same way a home screen +shortcut does. No window opens — one RPC, a toast, `finish()`. + +``` +coil://play?box=&type=folder&path= +coil://play?box=&type=album&albumartist=&album= +coil://play?box=&type=track&url= +…&favorite= # optional; only counts the launch and feeds the launcher's ranking +``` + +- **`box` is required and authoritative.** `parse` returns null without it, `playOn` sends the + command to *that* box whether or not it is active (a one-shot socket via `callOn` when it is not), + and `setActive` follows so the app and the notification agree afterwards (§7.3). A link for the + bedroom box starts the bedroom box while the living room one is playing. This is deliberate: + favourites are per box, so a folder path from one box means nothing on another. +- **`boxId` is a random UUID** minted in `BoxRepositoryImpl.add`, so a link cannot be written by + hand and a fresh install mints new ids. "Copy link" and "Share link" on a favourite's menu exist + precisely because that id is otherwise invisible. If hand-written links are ever wanted, the shape + to add is `host=` resolved against the configured boxes, not a second id scheme. +- Adding anything to this format means widening what an unprivileged external intent can ask Coil to + do. Keep it to starting playback — the same limit the RPC surface itself observes. ## Project conventions @@ -144,9 +451,90 @@ These are settled decisions from `docs/implementation-plan.md`, not open questio machine translation (a partial, human-reviewed translation is fine — an unreviewed complete one is not). - Scope discipline: Coil is deliberately a playback remote only. Card management, box system - settings, timers, and shutdown/reboot stay out of scope — see README "What Coil does not do" and - the implementation plan §1 and §16. + settings and shutdown/reboot stay out of scope — see README "What Coil does not do" and the + implementation plan §1 and §16. **One exception, deliberately made: the sleep timer** — see + below. - Keep `CHANGELOG.md` current: add an entry under `## [Unreleased]` for any user-facing change in the same commit/PR that makes it, not as a follow-up. When cutting a release, rename `[Unreleased]` to the new `[x.y.z]` version heading (matching `versionName`) and start a fresh empty `Unreleased` section above it. + +## Implementation status + +Phases 1–7 of `docs/implementation-plan.md` §14 are implemented, along with most of phase 8. What +still needs doing, in rough order of importance: + +1. **First run against a real box happened and found three bugs** (all fixed): `as_thread` + discarding every result, `core.version` not being an RPC, and the volume slider firing a command + per frame. What is confirmed working on hardware: the DEALER framing, PubSub status, play/pause/ + next/prev, `play_folder`, and favourites. Still unconfirmed on hardware: the library views and + cover art (both were broken by `as_thread` and have not been retested), the connection test, + mDNS discovery, the media session and notification, launcher shortcuts, and multi-box switching. +2. **The four translations are unreviewed drafts.** `values-de|fr|es|nl/strings.xml` each carry a + header saying so. Per the rule above they must be read by a fluent speaker before a release — + missing strings fall back to English, so correcting them incrementally is safe. +3. **No instrumented tests.** Unit tests cover the parsers, the command catalogue and the domain + models, and the screenshot tests cover what the player and library screens look like (see + "Screenshot tests"). Nothing runs on a device, and nothing tests behaviour above the + ViewModel — a screenshot proves a screen composes and looks right, not that tapping works. +4. **Automatic mode is best-effort by design**, and its boot path is the weakest part: Android may + refuse to start a foreground service from `BootReceiver`, which `AutoSessionStarter` treats as a + normal outcome. In practice the mode becomes reliable once the app has been opened after a + reboot. The settings screen says so in plain language. +5. **The player has a tablet layout; the other screens do not.** On a window wider than it is + tall — a tablet in landscape, a phone on its side — the player puts the cover beside the + controls instead of above them (`WidePlayer` in `PlayerScreen.kt`, at Material's medium and + expanded width classes). It was the screen that needed it most: a full-width square cover is + taller than a landscape window, so the transport controls fell below the fold on the one + screen whose entire job is the transport controls. Library, favourites and settings are still + single-column and merely stretch, which reads as roomy rather than broken — see + `app/library_tablet.png`. +6. **Android 16's local network restriction will eventually break Coil outright.** Access to + local-network addresses — which is every socket this app opens, plus `NsdManager` discovery and + the HTTP cover fetches — will require the `NEARBY_WIFI_DEVICES` permission, granted by the user + as "Nearby devices". It is opt-in during Android 16, so nothing is broken today and no + permission is declared yet. Test the enforced behaviour with + `adb shell am compat enable RESTRICT_LOCAL_NETWORK app.coilforphoniebox.debug` (then reboot). + When enforcement lands, the permission needs declaring *and* requesting — the natural place is + just before the mDNS scan in the add-box flow, where the reason is self-evident. + +### Deviations from the plan worth knowing about + +- **`shuffle` and `repeat` take an `option` string**, not MPD-style flags, and `mute` sets an + absolute state rather than toggling. The plan and the web UI's command table both suggested + otherwise; the plugin signatures in the upstream Python source are what the code follows. Same + for `get_folder_content`, which returns both an absolute `path` and a `relpath` — only `relpath` + is usable, because that is what `play_folder` expects. +- **`PlayShortcutActivity` lives in `:app`, not `:feature-shortcuts`**, so that its failure toast + stays in the single `strings.xml`. `:feature-shortcuts` therefore has no string resources at all. + For the same reason the media notification's text reaches `:feature-media` through the + `MediaNotificationTexts` interface, implemented in `:app`. +- **"Add another box" lives on the settings screen, and is always visible there.** §7.5's collapsed + top bar — a plain indicator, not a switcher, while there is one box — is kept, but it made a second + box unreachable: the switcher sheet holds the only other "Add box" entry, and settings offered one + solely when *no* box existed. One box was therefore a dead end, which reads as multi-box being + unimplemented rather than merely hidden. Settings now also carries a box picker once there are two + or more, mirroring the switcher, since that is where boxes are configured. Adding a box + deliberately does **not** make it active: nothing should tear down a live connection the user did + not ask to change. +- **A single track is favouritable**, which the plan's `FOLDER | ALBUM` favourite type (§6.3, §7.2) + does not allow. From a playing song, "save this" is genuinely ambiguous between the track and its + folder, so both are offered by name instead of one being guessed at: a tap on the player's star + saves the folder, a long press opens a menu with folder and track as separate entries. The cost is + a `TRACK` type plus a `trackUrl` column (Room schema **version 2**, migrated in + `core-data/.../db/Migrations.kt` — no destructive fallback, favourites are the one thing here that + cannot be rebuilt from the box), a `track` variant of the `coil://play` deep link, and settings + backup **format version 2**. +- **Long press in the library opens a context menu, it does not toggle a favourite.** The plan (§14, + phase 2) has long press as the way to favourite something, which is undiscoverable and was the + only way to do it. Every library row and album cell now carries a ⋮ button, and both it and a long + press open the same three-entry menu: Play, save as favourite, and Details. The details sheet is + built strictly from cached data — opening it must never put a request on the RPC socket the box + shares with its card reader. +- **`LibraryFolderEntity` has one column the plan's schema does not**: `contentCachedAt`, recording + when a folder's *own* contents were last fetched, as opposed to when the row was written by its + parent's listing. It is what drives the "Updated 3 days ago" hint per level. +- **The connection is reference counted** (`ConnectionManager.acquire/release`) rather than tied to + the process, which is what makes "only while Coil is open" true rather than approximate. +- **`app/lint.xml` suppresses one `ObsoleteSdkInt` warning.** Following lint's advice there (merging + `mipmap-anydpi-v26` into `mipmap-anydpi`) makes AAPT2 stop resolving the launcher icon. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4080081..e0978cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,66 @@ automatically from the `## [x.y.z]` heading matching `versionName` in `app/build ## [Unreleased] +## [0.8.0] - 2026-08-05 + ### Added +- **Player**: cover art, title and album, progress with seek, transport controls, volume slider, + shuffle and repeat, and a star that saves the folder that is playing — with a long press to + choose explicitly between saving the folder and saving just the track +- **Library browsing** by folder and by album, with folders loaded one level at a time, album covers + fetched as they come into view, pull-to-refresh per level and a "last updated" hint. The back + gesture goes up one folder, and leaves the library only from the top level +- **Sleep timer**: stops playback after 15 minutes to 2 hours and leaves the box switched on. The + box's own shutdown and idle-shutdown timers are deliberately not offered — Coil never switches the + box off. Shuffle, repeat and the timer now share one "playback options" menu on the player instead + of competing with the transport controls, and a running timer shows the time left +- **Library search**: a field above the tabs finds folders, albums and tracks in one list, + ignoring case and accents — "bar" finds "Bär". It searches what Coil has loaded from the box, + since the Phoniebox interface has no search of its own; settings can walk the whole library once + to make all of it searchable +- **A menu on every library item** — reachable from its ⋮ button or by a long press — offering + Play, saving it as a favourite, and a details panel with what Coil knows about it: path, artist, + album, track number, length, file name and when it was last read from the box +- **Lock screen and notification controls** that appear on their own when the box starts playing, + including control of the box from the phone's hardware volume buttons; switchable between "only + while Coil is open" and "whenever the box plays" +- **Favourites** for a folder, an album or a single track, with launcher shortcuts — up to four + dynamic ones plus pinned ones using cover art as their icon, each starting its own box with a + single command and without opening the app +- **A link for any favourite**, from its menu: copy or share it and anything that opens links — + an automation app, an NFC tag, a note — starts that favourite on the box it belongs to, whichever + box Coil is currently showing +- **Multiple boxes**: "Add another box" in settings, a switcher in the top bar showing each box's + reachability, an add flow with an mDNS scan and a connection test, a box picker and per-box + settings on the settings screen, and a collapsed top bar while only one box is configured. + Switching box returns the library to its root, since folders belong to the box they came from +- **Settings**: theme, wallpaper colours, language shortcut, per-box address and rescan, background + mode with a plain-language note about its limits, and settings export and import +- **Five locales**: English as the source, plus German, French, Spanish and Dutch. The four + translations are unreviewed drafts and are marked as such in their files +- **A tablet layout for the player**: on a screen wider than it is tall, the cover sits beside the + title and controls instead of above them, so the transport controls stay in view on a family + tablet and on a phone held sideways. The rest of the app is unchanged - Project scaffolding: architecture and protocol documentation (`docs/`), brand assets (`brand/`), Android theme reference bundle (`android/`), static UI mockup (`mockup/`) + +### Changed +- Targets Android 16 (API 36), which the Play Store requires for new apps from 31 August 2026. + Supported devices are unchanged: Android 8.0 and newer +- The volume slider now sends one command when it settles instead of one per frame of the drag, + so dragging it is smooth and does not flood the box + +### Fixed +- Folder and album views stayed empty, and cover art never loaded: the requests were asking the + box to run them in a background thread, which throws the answer away +- The player stopped updating — title, album and progress frozen while playback carried on — + because the whole player display waited on the cover art lookup before showing anything +- Cover art never appeared even once it was fetchable: the box answers the first request for a + song by *starting* the artwork extraction, and Coil mistook that answer for a file name +- "Test connection" failed against a perfectly reachable box, because it asked for a version + over a channel that only publishes it +- The album list is now fetched when the tab is first opened, instead of only when the box + happened to be idle at app start - Transport validation: standalone JeroMQ spike (`spike/`) and Python protocol probe (`tools/probe_phoniebox.py`) against a live Phoniebox v3 box - CI/CD scaffolding: build/test/lint, CodeQL, release, Google Play deploy, and GitHub Pages diff --git a/README.md b/README.md index 4025e30..416afb1 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,10 @@ Screenshots go here once captured. Suggested layout: ## Features - **Player** with cover art, scrubbing, transport controls and volume -- **Library browsing** by folder or album, play anything you find +- **Sleep timer** that stops playback after a set time and leaves the box switched on +- **Library browsing** by folder or album, play anything you find, and search that ignores case and accents - **Lock screen and notification controls** that appear on their own when the box starts playing, and can be switched off entirely -- **Favourites** with home screen shortcuts, so one tap starts an audiobook without opening the app +- **Favourites** for a folder, an album or a single track, with home screen shortcuts, so one tap starts an audiobook without opening the app — or as a link, for an automation app or an NFC tag - **Multiple boxes** — one per room, switched from the top bar, each with its own favourites - **Hardware volume buttons** control the box while the media session is active - **Dark mode** built for bedtime rather than bolted on @@ -42,7 +43,9 @@ Coil keeps everything on your device and your network. No account, no telemetry, ## What Coil does not do -Card management, system settings, timers and shutdown stay in the Phoniebox web UI. Coil covers the thing you do twenty times a day — putting something on — and deliberately leaves administration alone. Beyond keeping the app focused, it means Coil never sends a command that could take the box down. +Card management, system settings and shutdown stay in the Phoniebox web UI. Coil covers the thing you do twenty times a day — putting something on — and deliberately leaves administration alone. Beyond keeping the app focused, it means Coil never sends a command that could take the box down. + +That applies to timers too. The box can shut itself down on a timer, or after being idle for a while; Coil offers neither. Its sleep timer is the one that stops the player and leaves the box on. ## Requirements @@ -76,7 +79,9 @@ cd coil ./gradlew assembleDebug ``` -Requires JDK 17 and Android SDK 35. No API keys, no proprietary dependencies, no extra setup. +Requires JDK 17 and Android SDK 36, plus a `local.properties` with `sdk.dir` pointing at your SDK +(escape a Windows drive letter as `sdk.dir=C\:/Android/Sdk`). No API keys, no proprietary +dependencies, no extra setup. Architecture and protocol notes are in [`docs/`](docs/) — worth a look before touching the transport layer, since the Phoniebox RPC socket has some sharp edges. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..1d246a0 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,219 @@ +import java.awt.Color +import java.awt.image.BufferedImage +import javax.imageio.ImageIO + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) + alias(libs.plugins.roborazzi) +} + +android { + namespace = "app.coilforphoniebox" + compileSdk = 36 + + defaultConfig { + // Immutable after publication — see docs/implementation-plan.md §10.1. + applicationId = "app.coilforphoniebox" + minSdk = 26 + targetSdk = 36 + versionCode = 2 + versionName = "0.8.0" + } + + androidResources { + // The launch locales (§12.1). Pseudolocales are not listed: they are generated for + // the debug build by isPseudoLocalesEnabled below, after filtering. + localeFilters += listOf("en", "de", "fr", "es", "nl") + } + + // Supplied by .github/workflows/release.yml. A local release build has no keystore and + // produces an unsigned APK rather than failing — the signing config is only created + // when there is something to put in it. + val releaseKeystore: String? = System.getenv("KEYSTORE_PATH") + + signingConfigs { + if (releaseKeystore != null) { + create("release") { + storeFile = rootProject.file(releaseKeystore) + storePassword = System.getenv("KEYSTORE_PASSWORD") + keyAlias = System.getenv("KEY_ALIAS") + keyPassword = System.getenv("KEY_PASSWORD") + } + } + } + + buildTypes { + debug { + applicationIdSuffix = ".debug" + // Generates en_XA / ar_XB at build time for the pseudolocale pass (§12.3). + isPseudoLocalesEnabled = true + } + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + signingConfig = releaseKeystore?.let { signingConfigs.getByName("release") } + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + // Only for VERSION_NAME, which the about section shows. + buildConfig = true + } + + lint { + // §12.2: hardcoded user-facing text must fail the build, not warn. + error += listOf("HardcodedText") + // Partial translations are explicitly allowed to merge (§12.5), so drift + // stays visible without blocking a release. + warning += listOf("MissingTranslation", "MissingQuantity") + checkDependencies = true + abortOnError = true + warningsAsErrors = false + sarifReport = true + } + + packaging { + resources.excludes += setOf("/META-INF/{AL2.0,LGPL2.1}") + } + + testOptions { + unitTests { + // Robolectric renders the real UI, so the unit tests need the real resources — + // strings, colours and all five locales included. + isIncludeAndroidResources = true + all { + // Rendering a full screen at 420dpi needs more heap than the 512 MB default. + it.maxHeapSize = "2g" + } + } + } +} + +// ---------------------------------------------------------------- store assets +// +// `StoreAssetTest` writes the Play listing screenshots straight into the fastlane layout, but +// it cannot finish the job: Roborazzi writes RGBA, Play rejects any image with an alpha +// channel, and `javax.imageio` is not on an Android unit test's classpath. So the last two +// steps happen here, where the full JDK is available. +// +// Paths are resolved inside the configuration block and captured as plain Files. They cannot be +// script-level `val`s: reading one from `doLast` captures the build script object itself, which +// the configuration cache refuses to serialise. +val flattenStoreAssets = tasks.register("flattenStoreAssets") { + group = "publishing" + description = "Flattens the Play Store screenshots to 24-bit PNG and copies the phone set " + + "to the Pages site." + + val storeImages = rootProject.layout.projectDirectory + .dir("fastlane/metadata/android/en-US/images").asFile + val pagesScreenshots = rootProject.layout.projectDirectory + .dir("docs/pages/assets/screenshots").asFile + + doLast { + if (!storeImages.isDirectory) { + logger.lifecycle("No store images at $storeImages — nothing to flatten.") + return@doLast + } + + // The app's own light background (md_light_background). It only ever shows through + // antialiased edges, and white would fringe them. + val background = Color(0xF5FAF6) + var flattened = 0 + + storeImages.walkTopDown().filter { it.isFile && it.extension == "png" }.forEach { file -> + val source = ImageIO.read(file) ?: return@forEach + val opaque = BufferedImage(source.width, source.height, BufferedImage.TYPE_INT_RGB) + opaque.createGraphics().apply { + color = background + fillRect(0, 0, source.width, source.height) + drawImage(source, 0, 0, null) + dispose() + } + ImageIO.write(opaque, "png", file) + flattened++ + } + + // The website is served from docs/pages only, so it needs its own copy rather than a + // link into the fastlane tree. + val phones = storeImages.resolve("phoneScreenshots") + if (phones.isDirectory) { + pagesScreenshots.mkdirs() + phones.listFiles { file -> file.extension == "png" }?.forEach { file -> + file.copyTo(pagesScreenshots.resolve(file.name), overwrite = true) + } + } + + logger.lifecycle("Flattened $flattened store screenshot(s); phone set copied to the Pages site.") + } +} + +// Recording is the only thing that writes those files, so it is the only thing that needs to +// finish the job. A record run that captured no store assets simply finds nothing to do. +tasks.matching { it.name == "recordRoborazziDebug" }.configureEach { finalizedBy(flattenStoreAssets) } + +dependencies { + implementation(project(":core-domain")) + implementation(project(":core-data")) + implementation(project(":core-transport")) + implementation(project(":feature-media")) + implementation(project(":feature-shortcuts")) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.navigation.compose) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.material3) + implementation(libs.compose.material.icons.extended) + implementation(libs.compose.ui.tooling.preview) + debugImplementation(libs.compose.ui.tooling) + + implementation(libs.coil.compose) + + implementation(libs.hilt.android) + implementation(libs.hilt.navigation.compose) + ksp(libs.hilt.compiler) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + + // Screenshot tests. They run as ordinary unit tests and stay inert during `./gradlew test` + // — Roborazzi only writes or compares files when its Gradle tasks set the system property, + // so the plain test task neither records nor fails on a golden. + // + // They live in the `testDebug` source set, not `test`: the activity they compose into is + // `HiltTestActivity` from the debug source set, which the release unit test variant cannot + // see. Scoping the dependencies the same way keeps `testReleaseUnitTest` empty rather than + // broken. + testDebugImplementation(platform(libs.compose.bom)) + testDebugImplementation(libs.compose.ui.test.junit4) + testDebugImplementation(libs.robolectric) + testDebugImplementation(libs.roborazzi) + testDebugImplementation(libs.roborazzi.compose) + testDebugImplementation(libs.coil.test) + // Whole-app goldens compose CoilApp, whose screens resolve their view models through + // `hiltViewModel()` — so the graph has to exist, with fakes in place of the repositories. + testDebugImplementation(libs.hilt.android.testing) + kspTestDebug(libs.hilt.compiler) +} diff --git a/app/lint.xml b/app/lint.xml new file mode 100644 index 0000000..6d2f6f5 --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f62802b --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,15 @@ +# JeroMQ reaches for a few optional pieces reflectively and ships classes that +# reference JDK-only APIs; keeping the package intact is simpler than chasing +# individual warnings, and it is small. +-keep class org.zeromq.** { *; } +-keep class zmq.** { *; } +-dontwarn org.zeromq.** +-dontwarn zmq.** + +# kotlinx.serialization keeps generated serializers on companion objects. +-keepclassmembers class app.coilforphoniebox.** { + *** Companion; +} +-keepclasseswithmembers class app.coilforphoniebox.** { + kotlinx.serialization.KSerializer serializer(...); +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..c4b70c0 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/app/src/debug/kotlin/app/coilforphoniebox/HiltTestActivity.kt b/app/src/debug/kotlin/app/coilforphoniebox/HiltTestActivity.kt new file mode 100644 index 0000000..f3c17f3 --- /dev/null +++ b/app/src/debug/kotlin/app/coilforphoniebox/HiltTestActivity.kt @@ -0,0 +1,19 @@ +package app.coilforphoniebox + +import androidx.activity.ComponentActivity +import dagger.hilt.android.AndroidEntryPoint + +/** + * An empty activity for the screenshot tests to compose into. + * + * It exists because `CoilApp` resolves its view models through `hiltViewModel()`, which needs + * a host activity that is itself a Hilt entry point — the stock activity a Compose test rule + * would otherwise use is not one, and the app's own [MainActivity] brings a media session, + * a connection and a permission prompt with it, none of which belong in a screenshot. + * + * It lives in the debug source set because that is the only place a test can see an activity + * from: Robolectric will not launch one that is absent from the merged manifest. It is + * therefore present in debug builds and absent from release ones, and it is not exported. + */ +@AndroidEntryPoint +class HiltTestActivity : ComponentActivity() diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9e56b37 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/app/coilforphoniebox/CoilApplication.kt b/app/src/main/kotlin/app/coilforphoniebox/CoilApplication.kt new file mode 100644 index 0000000..7854dde --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/CoilApplication.kt @@ -0,0 +1,44 @@ +package app.coilforphoniebox + +import android.app.Application +import app.coilforphoniebox.shortcuts.ShortcutSynchronizer +import coil.ImageLoader +import coil.ImageLoaderFactory +import coil.disk.DiskCache +import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject + +@HiltAndroidApp +class CoilApplication : Application(), ImageLoaderFactory { + + @Inject lateinit var shortcutSynchronizer: ShortcutSynchronizer + + override fun onCreate() { + super.onCreate() + // Launcher shortcuts belong to the launcher's state rather than the app's, so they + // are kept current from here rather than from any one screen. + shortcutSynchronizer.start() + } + + /** + * The image loader — the *library* called Coil, not this app. + * + * Cover art is effectively immutable per file name and comes from the box over plain + * HTTP, so a generous disk cache is the whole strategy (§6.2). The URL already contains + * the box's host, which keeps two boxes' identically named covers apart on its own. + */ + override fun newImageLoader(): ImageLoader = + ImageLoader.Builder(this) + .diskCache { + DiskCache.Builder() + .directory(cacheDir.resolve("cover-art")) + .maxSizeBytes(COVER_CACHE_BYTES) + .build() + } + .crossfade(true) + .build() + + private companion object { + const val COVER_CACHE_BYTES = 96L * 1024 * 1024 + } +} diff --git a/app/src/main/kotlin/app/coilforphoniebox/MainActivity.kt b/app/src/main/kotlin/app/coilforphoniebox/MainActivity.kt new file mode 100644 index 0000000..0e5ad7d --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/MainActivity.kt @@ -0,0 +1,97 @@ +package app.coilforphoniebox + +import android.Manifest +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.compose.material3.Surface +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.media3.common.util.UnstableApi +import app.coilforphoniebox.domain.model.ThemeMode +import app.coilforphoniebox.media.AutoSessionStarter +import app.coilforphoniebox.media.MediaSessionBinder +import app.coilforphoniebox.transport.ConnectionManager +import app.coilforphoniebox.ui.AppViewModel +import app.coilforphoniebox.ui.CoilApp +import app.coilforphoniebox.ui.theme.CoilTheme +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import javax.inject.Inject + +@androidx.annotation.OptIn(UnstableApi::class) +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + @Inject lateinit var connectionManager: ConnectionManager + + @Inject lateinit var mediaSessionBinder: MediaSessionBinder + + @Inject lateinit var autoSessionStarter: AutoSessionStarter + + private val appViewModel: AppViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + setContent { + val state by appViewModel.state.collectAsStateWithLifecycle() + + CoilTheme( + darkTheme = when (state.settings.themeMode) { + ThemeMode.SYSTEM -> androidx.compose.foundation.isSystemInDarkTheme() + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true + }, + dynamicColor = state.settings.dynamicColor, + ) { + Surface { + RequestNotificationPermission() + CoilApp(appViewModel) + } + } + } + } + + override fun onStart() { + super.onStart() + // The UI is one of the two things that can hold the connection open; the media + // service is the other (§8.3). + connectionManager.acquire() + mediaSessionBinder.bind() + + // Starting a foreground service is reliably permitted while the app is in the + // foreground, which is why the automatic mode is also (re)started from here and + // not only from the boot receiver. + lifecycleScope.launch { autoSessionStarter.startIfEnabled() } + } + + override fun onStop() { + mediaSessionBinder.unbind() + connectionManager.release() + super.onStop() + } +} + +/** + * Asked for on first launch, because without it the media notification — the whole point + * of the media session — cannot be shown (§8.2). + */ +@androidx.compose.runtime.Composable +private fun RequestNotificationPermission() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { } + + LaunchedEffect(Unit) { launcher.launch(Manifest.permission.POST_NOTIFICATIONS) } +} diff --git a/app/src/main/kotlin/app/coilforphoniebox/di/AppModule.kt b/app/src/main/kotlin/app/coilforphoniebox/di/AppModule.kt new file mode 100644 index 0000000..502e7ac --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/di/AppModule.kt @@ -0,0 +1,54 @@ +package app.coilforphoniebox.di + +import android.content.Context +import app.coilforphoniebox.R +import app.coilforphoniebox.media.MediaNotificationTexts +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Supplies the media session with its text and icon. + * + * Deliberately implemented here rather than in `:feature-media`: it keeps every + * translatable string in the app module's single `strings.xml`, which is what the + * translation workflow in §12.5 assumes. Strings are resolved on each read, so a locale + * change reaches an already-running service. + */ +@Singleton +class AppMediaNotificationTexts @Inject constructor( + @ApplicationContext private val context: Context, +) : MediaNotificationTexts { + + override val playbackChannelName: String + get() = context.getString(R.string.notification_channel_playback) + + override val playbackChannelDescription: String + get() = context.getString(R.string.notification_channel_playback_description) + + override val statusChannelName: String + get() = context.getString(R.string.notification_channel_status) + + override val statusChannelDescription: String + get() = context.getString(R.string.notification_channel_status_description) + + override val readyTitle: String + get() = context.getString(R.string.notification_watching_title) + + override fun connectedTo(boxName: String): String = + context.getString(R.string.notification_watching_text, boxName) + + override val smallIcon: Int get() = R.drawable.ic_stat_coil +} + +@Module +@InstallIn(SingletonComponent::class) +interface AppModule { + + @Binds + fun bindMediaNotificationTexts(impl: AppMediaNotificationTexts): MediaNotificationTexts +} diff --git a/app/src/main/kotlin/app/coilforphoniebox/shortcut/PlayShortcutActivity.kt b/app/src/main/kotlin/app/coilforphoniebox/shortcut/PlayShortcutActivity.kt new file mode 100644 index 0000000..8b0a457 --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/shortcut/PlayShortcutActivity.kt @@ -0,0 +1,93 @@ +package app.coilforphoniebox.shortcut + +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.lifecycle.lifecycleScope +import androidx.media3.common.util.UnstableApi +import app.coilforphoniebox.R +import app.coilforphoniebox.domain.repository.BoxRepository +import app.coilforphoniebox.domain.repository.FavoriteRepository +import app.coilforphoniebox.domain.repository.PlayerRepository +import app.coilforphoniebox.media.PhonieboxMediaService +import app.coilforphoniebox.shortcuts.PlayDeepLink +import app.coilforphoniebox.shortcuts.ShortcutPublisher +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Handles `coil://play?box=…`, which is what a home screen shortcut points at. + * + * No UI at all: it connects to the box named in the link, fires one `play_folder`, starts + * the media service and finishes. Tapping the icon starts the audiobook without the app + * ever becoming visible; on failure there is a toast rather than an empty screen (§9). + * + * The box comes from the link rather than from whatever is currently active, and becomes + * the active box afterwards so that the app and the media notification agree (§7.3). + */ +@androidx.annotation.OptIn(UnstableApi::class) +@AndroidEntryPoint +class PlayShortcutActivity : ComponentActivity() { + + @Inject lateinit var player: PlayerRepository + + @Inject lateinit var boxes: BoxRepository + + @Inject lateinit var favorites: FavoriteRepository + + @Inject lateinit var shortcuts: ShortcutPublisher + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val request = PlayDeepLink.parse(intent?.data) + if (request == null) { + finish() + return + } + + lifecycleScope.launch { + // No cache lookup: the link already carries the folder path that play_folder + // takes, so this is exactly one RPC (§6.5). + val result = player.playOn(request.boxId, request.target) + + result.onSuccess { + request.favoriteId?.let { favoriteId -> + favorites.recordLaunch(favoriteId) + // Coil ranks its own dynamic set; the launcher keeps a separate history + // and needs telling, which also covers pinned shortcuts. + shortcuts.reportUsed(favoriteId) + } + boxes.setActive(request.boxId) + confirm(request.favoriteId, request.boxId) + // Started rather than bound: this activity finishes straight away, so there + // is nothing to hold a binding. The service stops itself again once the box + // stops playing. + startService(PhonieboxMediaService.serviceIntent(this@PlayShortcutActivity)) + }.onFailure { + Toast.makeText( + this@PlayShortcutActivity, + R.string.shortcut_failed, + Toast.LENGTH_SHORT, + ).show() + } + + finish() + } + } + + /** + * The box is usually in another room, so a tap that opens no UI needs to say something. + * Both names are content the user chose, and go into the message as they are (§12.4). + */ + private suspend fun confirm(favoriteId: Long?, boxId: String) { + val label = favoriteId?.let { favorites.favorite(it)?.label } ?: return + val boxName = boxes.box(boxId)?.displayName ?: return + Toast.makeText( + this, + getString(R.string.shortcut_starting, label, boxName), + Toast.LENGTH_SHORT, + ).show() + } +} diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/AppViewModel.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/AppViewModel.kt new file mode 100644 index 0000000..e213d11 --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/AppViewModel.kt @@ -0,0 +1,121 @@ +package app.coilforphoniebox.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.coilforphoniebox.domain.model.AppSettings +import app.coilforphoniebox.domain.model.Box +import app.coilforphoniebox.domain.model.ConnectionState +import app.coilforphoniebox.domain.model.ConnectionTestResult +import app.coilforphoniebox.domain.model.PlayerStatus +import app.coilforphoniebox.domain.repository.BoxRepository +import app.coilforphoniebox.domain.repository.LibraryRepository +import app.coilforphoniebox.domain.repository.PlayerRepository +import app.coilforphoniebox.domain.repository.SettingsRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +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.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * State the whole shell needs: which box is active, whether it answers, and what is + * playing for the mini player. + */ +@HiltViewModel +class AppViewModel @Inject constructor( + private val boxes: BoxRepository, + private val player: PlayerRepository, + private val library: LibraryRepository, + settings: SettingsRepository, +) : ViewModel() { + + data class State( + val settings: AppSettings = AppSettings(), + val boxes: List = emptyList(), + val activeBox: Box? = null, + val connection: ConnectionState = ConnectionState.DISCONNECTED, + val status: PlayerStatus = PlayerStatus.Idle, + val coverUrl: String? = null, + /** False until the first read from storage, so no screen flashes the wrong way. */ + val loaded: Boolean = false, + ) { + val needsOnboarding: Boolean get() = loaded && boxes.isEmpty() + + val showMiniPlayer: Boolean get() = status.hasContent + + val isOffline: Boolean + get() = activeBox != null && connection == ConnectionState.DISCONNECTED + } + + val state: StateFlow = combine( + settings.settings, + boxes.boxes, + boxes.activeBox, + combine(player.connectionState, player.status, player.coverUrl) { connection, status, cover -> + Triple(connection, status, cover) + }, + ) { appSettings, allBoxes, activeBox, live -> + State( + settings = appSettings, + boxes = allBoxes, + activeBox = activeBox, + connection = live.first, + status = live.second, + coverUrl = live.third, + loaded = true, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), State()) + + /** Reachability of each configured box, filled in when the switcher is opened. */ + private val _reachability = MutableStateFlow>(emptyMap()) + val reachability: StateFlow> = _reachability.asStateFlow() + + init { + // Opportunistic library refresh, which the repository only carries out when the + // cache is old and the box is idle (§6.4). + viewModelScope.launch { + boxes.activeBox + .map { it?.id } + .distinctUntilChanged() + .collect { boxId -> if (boxId != null) library.refreshIfStaleAndIdle(boxId) } + } + } + + fun selectBox(boxId: String) { + viewModelScope.launch { boxes.setActive(boxId) } + } + + /** + * Probes every box in parallel. The active one is answered by its live connection, so + * only the others cost a request — and they are one `core.version` call each. + */ + fun probeBoxes() { + viewModelScope.launch { + val all = boxes.boxes.first() + val active = state.value.activeBox?.id + val results = all.map { box -> + async { + if (box.id == active && state.value.connection.isUsable) { + box.id to true + } else { + box.id to (boxes.testConnection(box.host, box.rpcPort) is ConnectionTestResult.Reachable) + } + } + }.awaitAll() + _reachability.value = results.toMap() + } + } + + fun togglePlayback() { + viewModelScope.launch { player.toggle() } + } +} diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/CoilApp.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/CoilApp.kt new file mode 100644 index 0000000..6ac016e --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/CoilApp.kt @@ -0,0 +1,233 @@ +package app.coilforphoniebox.ui + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.LibraryMusic +import androidx.compose.material.icons.rounded.PlayCircle +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import app.coilforphoniebox.R +import app.coilforphoniebox.ui.boxes.AddBoxScreen +import app.coilforphoniebox.ui.boxes.AddBoxViewModel +import app.coilforphoniebox.ui.components.BoxIndicator +import app.coilforphoniebox.ui.components.BoxSwitcherSheet +import app.coilforphoniebox.ui.components.MiniPlayer +import app.coilforphoniebox.ui.components.OfflineBanner +import app.coilforphoniebox.ui.favorites.FavoritesScreen +import app.coilforphoniebox.ui.favorites.FavoritesViewModel +import app.coilforphoniebox.ui.library.LibraryScreen +import app.coilforphoniebox.ui.library.LibraryViewModel +import app.coilforphoniebox.ui.player.PlayerScreen +import app.coilforphoniebox.ui.player.PlayerViewModel +import app.coilforphoniebox.ui.settings.SettingsScreen +import app.coilforphoniebox.ui.settings.SettingsViewModel +import kotlinx.coroutines.flow.Flow + +private enum class Destination( + val route: String, + @StringRes val label: Int, + val icon: ImageVector, +) { + PLAYER("player", R.string.nav_player, Icons.Rounded.PlayCircle), + LIBRARY("library", R.string.nav_library, Icons.Rounded.LibraryMusic), + FAVOURITES("favourites", R.string.nav_favourites, Icons.Rounded.Star), + SETTINGS("settings", R.string.nav_settings, Icons.Rounded.Settings), +} + +private const val ROUTE_ADD_BOX = "add-box" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CoilApp(appViewModel: AppViewModel) { + val state by appViewModel.state.collectAsStateWithLifecycle() + val navController = rememberNavController() + val snackbarHostState = remember { SnackbarHostState() } + var switcherOpen by remember { mutableStateOf(false) } + + // Nothing in the app is usable without a box, so a first launch is the add-box screen + // and nothing else — no feature tour, no carousel (§11.2). + if (state.needsOnboarding) { + Scaffold { padding -> + AddBoxScreen( + viewModel = hiltViewModel(), + onSaved = { }, + modifier = Modifier.padding(padding), + ) + } + return + } + + val backStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = backStackEntry?.destination?.route + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + CenterAlignedTopAppBar( + title = { + BoxIndicator( + activeBox = state.activeBox, + connection = state.connection, + switchable = state.boxes.size > 1, + onClick = { + switcherOpen = true + appViewModel.probeBoxes() + }, + ) + }, + ) + }, + bottomBar = { + Column { + if (state.showMiniPlayer && currentRoute != Destination.PLAYER.route) { + MiniPlayer( + status = state.status, + coverUrl = state.coverUrl, + onClick = { navController.navigateSingleTop(Destination.PLAYER.route) }, + onToggle = appViewModel::togglePlayback, + ) + } + BottomBar(navController = navController, currentRoute = currentRoute) + } + }, + ) { padding -> + Column( + Modifier + .padding(padding) + .fillMaxSize(), + ) { + if (state.isOffline) { + OfflineBanner( + text = stringResource(R.string.offline_banner), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + + NavHost( + navController = navController, + startDestination = Destination.PLAYER.route, + modifier = Modifier.fillMaxSize(), + ) { + composable(Destination.PLAYER.route) { + val viewModel = hiltViewModel() + SnackbarMessages(viewModel.messages, snackbarHostState) + PlayerScreen(viewModel) + } + + composable(Destination.LIBRARY.route) { + val viewModel = hiltViewModel() + SnackbarMessages(viewModel.messages, snackbarHostState) + LibraryScreen(viewModel) + } + + composable(Destination.FAVOURITES.route) { + val viewModel = hiltViewModel() + SnackbarMessages(viewModel.messages, snackbarHostState) + FavoritesScreen(viewModel) + } + + composable(Destination.SETTINGS.route) { + val viewModel = hiltViewModel() + SnackbarMessages(viewModel.messages, snackbarHostState) + SettingsScreen( + viewModel = viewModel, + onAddBox = { navController.navigate(ROUTE_ADD_BOX) }, + ) + } + + composable(ROUTE_ADD_BOX) { + AddBoxScreen( + viewModel = hiltViewModel(), + onSaved = { navController.popBackStack() }, + onCancel = { navController.popBackStack() }, + ) + } + } + } + } + + if (switcherOpen) { + val reachability by appViewModel.reachability.collectAsStateWithLifecycle() + BoxSwitcherSheet( + boxes = state.boxes, + activeBoxId = state.activeBox?.id, + reachability = reachability, + onSelect = { boxId -> + appViewModel.selectBox(boxId) + switcherOpen = false + }, + onAddBox = { + switcherOpen = false + navController.navigate(ROUTE_ADD_BOX) + }, + onDismiss = { switcherOpen = false }, + ) + } +} + +@Composable +private fun BottomBar(navController: NavHostController, currentRoute: String?) { + NavigationBar { + Destination.entries.forEach { destination -> + NavigationBarItem( + selected = currentRoute == destination.route, + onClick = { navController.navigateSingleTop(destination.route) }, + icon = { Icon(imageVector = destination.icon, contentDescription = null) }, + label = { Text(stringResource(destination.label)) }, + ) + } + } +} + +/** Shows one-off view model messages, which arrive as string resources rather than text. */ +@Composable +private fun SnackbarMessages(messages: Flow, host: SnackbarHostState) { + val context = LocalContext.current + LaunchedEffect(messages) { + messages.collect { message -> + val text = message.formatArg + ?.let { context.getString(message.text, it) } + ?: context.getString(message.text) + host.showSnackbar(text) + } + } +} + +private fun NavHostController.navigateSingleTop(route: String) { + navigate(route) { + popUpTo(graph.findStartDestination().id) { saveState = true } + launchSingleTop = true + restoreState = true + } +} diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/UiMessage.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/UiMessage.kt new file mode 100644 index 0000000..179de23 --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/UiMessage.kt @@ -0,0 +1,13 @@ +package app.coilforphoniebox.ui + +import androidx.annotation.StringRes + +/** + * A one-off message for a snackbar or toast, carried as a resource id rather than + * finished text: view models never build user-facing strings, which is what keeps + * every translatable string in `strings.xml` (§12.2). + */ +data class UiMessage( + @StringRes val text: Int, + val formatArg: String? = null, +) diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/boxes/AddBoxScreen.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/boxes/AddBoxScreen.kt new file mode 100644 index 0000000..ad9c653 --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/boxes/AddBoxScreen.kt @@ -0,0 +1,242 @@ +package app.coilforphoniebox.ui.boxes + +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.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.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Router +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +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.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.coilforphoniebox.R + +/** + * Used both for onboarding and for adding a second box later — the same job, so the same + * screen. One sentence of explanation, a scan list, a manual field. No carousel, no + * feature tour, no permission pre-briefing (§11.2). + */ +@Composable +fun AddBoxScreen( + viewModel: AddBoxViewModel, + onSaved: () -> Unit, + modifier: Modifier = Modifier, + onCancel: (() -> Unit)? = null, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { viewModel.startScan() } + LaunchedEffect(state.saved) { if (state.saved) onSaved() } + + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + ) { + Text( + text = stringResource(R.string.onboarding_title), + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.add_box_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(20.dp)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.add_box_discovered), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + if (state.scanning) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + } + } + + Spacer(Modifier.height(4.dp)) + + if (state.candidates.isEmpty()) { + Text( + text = stringResource( + if (state.scanning) R.string.add_box_scanning else R.string.add_box_none_found, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + state.candidates.forEach { candidate -> + ListItem( + headlineContent = { Text(candidate.serviceName) }, + supportingContent = { Text(candidate.host) }, + leadingContent = { Icon(Icons.Rounded.Router, contentDescription = null) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable { viewModel.selectCandidate(candidate) }, + ) + } + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.add_box_manual), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = state.host, + onValueChange = viewModel::onHostChange, + label = { Text(stringResource(R.string.field_host)) }, + placeholder = { Text(stringResource(R.string.field_host_hint)) }, + isError = state.hostError, + supportingText = if (state.hostError) { + { Text(stringResource(R.string.error_host_empty)) } + } else { + null + }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = state.displayName, + onValueChange = viewModel::onDisplayNameChange, + label = { Text(stringResource(R.string.field_display_name)) }, + placeholder = { Text(stringResource(R.string.field_display_name_hint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(12.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = state.rpcPort, + onValueChange = viewModel::onRpcPortChange, + label = { Text(stringResource(R.string.field_rpc_port)) }, + isError = state.portError, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = state.pubPort, + onValueChange = viewModel::onPubPortChange, + label = { Text(stringResource(R.string.field_pub_port)) }, + isError = state.portError, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) + } + if (state.portError) { + Text( + text = stringResource(R.string.error_port_invalid), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + Spacer(Modifier.height(16.dp)) + + ConnectionTestRow(state = state, onTest = viewModel::testConnection) + + Spacer(Modifier.height(20.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (onCancel != null) { + TextButton(onClick = onCancel) { Text(stringResource(R.string.action_cancel)) } + } + Button(onClick = viewModel::save, enabled = state.canSave) { + Text(stringResource(R.string.action_save)) + } + } + + Spacer(Modifier.height(32.dp)) + } +} + +@Composable +private fun ConnectionTestRow( + state: AddBoxViewModel.State, + onTest: () -> Unit, +) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = onTest, enabled = state.testState != AddBoxViewModel.TestState.RUNNING) { + Text(stringResource(R.string.action_test_connection)) + } + if (state.testState == AddBoxViewModel.TestState.RUNNING) { + Spacer(Modifier.size(12.dp)) + Text( + text = stringResource(R.string.test_running), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + when (state.testState) { + AddBoxViewModel.TestState.REACHABLE -> Text( + text = state.reportedVersion?.let { stringResource(R.string.test_success, it) } + ?: stringResource(R.string.test_success_unknown_version), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + + AddBoxViewModel.TestState.UNREACHABLE -> Column { + Text( + text = stringResource(R.string.error_connection_timeout), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(4.dp)) + // Client isolation is the failure that looks like a bug rather than a + // configuration issue, so it is named here and only here (§11.2). + Text( + text = stringResource(R.string.error_connection_timeout_help), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + else -> Unit + } + } +} diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/boxes/AddBoxViewModel.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/boxes/AddBoxViewModel.kt new file mode 100644 index 0000000..0902623 --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/boxes/AddBoxViewModel.kt @@ -0,0 +1,170 @@ +package app.coilforphoniebox.ui.boxes + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.coilforphoniebox.domain.model.Box +import app.coilforphoniebox.domain.model.ConnectionTestResult +import app.coilforphoniebox.domain.repository.BoxRepository +import app.coilforphoniebox.transport.HostDiscovery +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 + +/** + * Adding a box: an mDNS scan, a manual entry field, a connection test, and a name. + * + * The audience already owns a Phoniebox and set it up themselves, so this explains the + * one thing genuinely specific to the app — how to point it at the box — and nothing else + * (§11.2). + */ +@HiltViewModel +class AddBoxViewModel @Inject constructor( + private val boxes: BoxRepository, + private val discovery: HostDiscovery, +) : ViewModel() { + + data class Candidate(val host: String, val serviceName: String) + + enum class TestState { IDLE, RUNNING, REACHABLE, UNREACHABLE } + + data class State( + val host: String = "", + val displayName: String = "", + val rpcPort: String = Box.DEFAULT_RPC_PORT.toString(), + val pubPort: String = Box.DEFAULT_PUB_PORT.toString(), + val scanning: Boolean = false, + val candidates: List = emptyList(), + val testState: TestState = TestState.IDLE, + val reportedVersion: String? = null, + val hostError: Boolean = false, + val portError: Boolean = false, + val saved: Boolean = false, + ) { + val canSave: Boolean get() = host.isNotBlank() && !portError + } + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + private var scanJob: Job? = null + + fun onHostChange(value: String) = _state.update { + it.copy(host = value, hostError = false, testState = TestState.IDLE, reportedVersion = null) + } + + fun onDisplayNameChange(value: String) = _state.update { it.copy(displayName = value) } + + fun onRpcPortChange(value: String) = _state.update { + it.copy(rpcPort = value, portError = !value.isValidPort()) + } + + fun onPubPortChange(value: String) = _state.update { + it.copy(pubPort = value, portError = !value.isValidPort()) + } + + /** + * Candidates are anything Avahi announces, which cannot be told apart from any other + * Raspberry Pi by name alone — so each one is asked for `core.version` and only the + * ones that answer are offered (§4.4). + */ + fun startScan() { + if (scanJob?.isActive == true) return + _state.update { it.copy(scanning = true, candidates = emptyList()) } + + scanJob = viewModelScope.launch { + discovery.discover().collect { candidate -> + val reachable = boxes.testConnection(candidate.host, Box.DEFAULT_RPC_PORT) + if (reachable is ConnectionTestResult.Reachable) { + _state.update { current -> + if (current.candidates.any { it.host == candidate.host }) { + current + } else { + current.copy( + candidates = current.candidates + Candidate( + host = candidate.host, + serviceName = candidate.serviceName, + ), + ) + } + } + } + } + } + } + + fun stopScan() { + scanJob?.cancel() + scanJob = null + _state.update { it.copy(scanning = false) } + } + + fun selectCandidate(candidate: Candidate) = _state.update { + it.copy( + host = candidate.host, + displayName = it.displayName.ifBlank { candidate.serviceName }, + testState = TestState.REACHABLE, + ) + } + + fun testConnection() { + val current = _state.value + val host = current.host.trim() + if (host.isEmpty()) { + _state.update { it.copy(hostError = true) } + return + } + val port = current.rpcPort.toIntOrNull() + if (port == null || !current.rpcPort.isValidPort()) { + _state.update { it.copy(portError = true) } + return + } + + viewModelScope.launch { + _state.update { it.copy(testState = TestState.RUNNING) } + when (val result = boxes.testConnection(host, port)) { + is ConnectionTestResult.Reachable -> _state.update { + it.copy(testState = TestState.REACHABLE, reportedVersion = result.version) + } + + ConnectionTestResult.Unreachable -> _state.update { + it.copy(testState = TestState.UNREACHABLE, reportedVersion = null) + } + } + } + } + + fun save() { + val current = _state.value + val host = current.host.trim() + if (host.isEmpty()) { + _state.update { it.copy(hostError = true) } + return + } + val rpcPort = current.rpcPort.toIntOrNull() ?: Box.DEFAULT_RPC_PORT + val pubPort = current.pubPort.toIntOrNull() ?: Box.DEFAULT_PUB_PORT + + viewModelScope.launch { + boxes.add( + // An unnamed box takes its host as its name, which is better than a blank + // row in the switcher. + displayName = current.displayName.trim().ifBlank { host }, + host = host, + rpcPort = rpcPort, + pubPort = pubPort, + ) + _state.update { it.copy(saved = true) } + } + } + + override fun onCleared() { + stopScan() + super.onCleared() + } + + private fun String.isValidPort(): Boolean = toIntOrNull()?.let { it in 1..65535 } == true +} diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/components/BoxSwitcher.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/components/BoxSwitcher.kt new file mode 100644 index 0000000..0f497f0 --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/components/BoxSwitcher.kt @@ -0,0 +1,194 @@ +package app.coilforphoniebox.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.coilforphoniebox.R +import app.coilforphoniebox.domain.model.Box +import app.coilforphoniebox.domain.model.ConnectionState + +/** + * Top bar element for the active box. + * + * With exactly one box configured this collapses to a plain connection indicator: no + * one should pay UI complexity for a feature they do not use (§7.5). German and Dutch + * box names run a third longer than English ones, so the label truncates rather than + * pushing the bar's actions off the edge (§12.3). + */ +@Composable +fun BoxIndicator( + activeBox: Box?, + connection: ConnectionState, + switchable: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val label = boxLabel(activeBox) + val spokenState = connectionDescription(activeBox, connection) + + Surface( + modifier = modifier + .clip(CircleShape) + .then(if (switchable) Modifier.clickable(onClick = onClick) else Modifier) + .semantics { contentDescription = spokenState }, + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) { + Row( + modifier = Modifier.padding( + start = 10.dp, + end = if (switchable) 4.dp else 12.dp, + top = 6.dp, + bottom = 6.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + ConnectionDot(connection) + Spacer(Modifier.size(8.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + if (switchable) { + Icon( + imageVector = Icons.Rounded.ExpandMore, + contentDescription = stringResource(R.string.boxes_switch), + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +@Composable +private fun boxLabel(activeBox: Box?): String = + activeBox?.displayName ?: stringResource(R.string.connection_disconnected) + +@Composable +private fun connectionDescription(activeBox: Box?, connection: ConnectionState): String { + val name = activeBox?.displayName ?: return stringResource(R.string.connection_disconnected) + return when (connection) { + ConnectionState.CONNECTED -> stringResource(R.string.connection_connected, name) + ConnectionState.CONNECTING -> stringResource(R.string.connection_connecting, name) + ConnectionState.DEGRADED -> stringResource(R.string.connection_degraded, name) + ConnectionState.DISCONNECTED -> stringResource(R.string.connection_disconnected) + } +} + +/** Decorative: the surrounding element carries the spoken state. */ +@Composable +private fun ConnectionDot(connection: ConnectionState) { + val colour = when (connection) { + ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primary + ConnectionState.CONNECTING, ConnectionState.DEGRADED -> MaterialTheme.colorScheme.tertiary + // Not the error colour: a box that is switched off is a normal state (§10.7). + ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.outline + } + Spacer( + Modifier + .size(8.dp) + .clip(CircleShape) + .background(colour), + ) +} + +/** Bottom sheet with every configured box, its reachability, and a way to add one. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BoxSwitcherSheet( + boxes: List, + activeBoxId: String?, + reachability: Map, + onSelect: (String) -> Unit, + onAddBox: () -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(Modifier.padding(bottom = 24.dp)) { + Text( + text = stringResource(R.string.boxes_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) + + boxes.forEach { box -> + ListItem( + headlineContent = { + Text(box.displayName, maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + supportingContent = { + Text(box.host, maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + leadingContent = { ReachabilityDot(reachable = reachability[box.id]) }, + trailingContent = { + if (box.id == activeBoxId) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = stringResource(R.string.box_active), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable { onSelect(box.id) }, + ) + } + + ListItem( + headlineContent = { Text(stringResource(R.string.action_add_box)) }, + leadingContent = { Icon(Icons.Rounded.Add, contentDescription = null) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable(onClick = onAddBox), + ) + } + } +} + +/** Null means "still being probed", which is a third state worth showing honestly. */ +@Composable +private fun ReachabilityDot(reachable: Boolean?) { + val colour = when (reachable) { + true -> MaterialTheme.colorScheme.primary + false -> MaterialTheme.colorScheme.outlineVariant + null -> MaterialTheme.colorScheme.outline + } + Spacer( + Modifier + .size(10.dp) + .clip(CircleShape) + .background(colour), + ) +} diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/components/ContextMenu.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/components/ContextMenu.kt new file mode 100644 index 0000000..340893f --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/components/ContextMenu.kt @@ -0,0 +1,72 @@ +package app.coilforphoniebox.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.StarBorder +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow + +/** + * The building blocks of the context menus on the player and in the library. + * + * Every favourite entry names its target: "save this" alone leaves the user guessing + * whether the folder, the album or the one track is meant, which is the ambiguity these + * menus exist to remove. + */ +@Composable +fun ActionMenuItem( + text: String, + icon: ImageVector, + onClick: () -> Unit, +) { + DropdownMenuItem( + text = { Text(text) }, + leadingIcon = { Icon(imageVector = icon, contentDescription = null) }, + onClick = onClick, + ) +} + +/** + * A favourite entry. [detail] is the target's own name as it comes from the box — shown + * verbatim, never composed into a sentence, because it is content and not UI text (§12.4). + * The star is filled exactly when the target is already saved, so the menu doubles as the + * answer to "which of these is a favourite?". + */ +@Composable +fun FavoriteMenuItem( + text: String, + detail: String?, + saved: Boolean, + onClick: () -> Unit, +) { + DropdownMenuItem( + text = { + Column { + Text(text) + if (detail != null) { + Text( + text = detail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, + leadingIcon = { + Icon( + imageVector = if (saved) Icons.Rounded.Star else Icons.Rounded.StarBorder, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + ) + }, + onClick = onClick, + ) +} diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/components/CoverArt.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/components/CoverArt.kt new file mode 100644 index 0000000..9eedf38 --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/components/CoverArt.kt @@ -0,0 +1,73 @@ +package app.coilforphoniebox.ui.components + +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.material.icons.Icons +import androidx.compose.material.icons.rounded.Album +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.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.SubcomposeAsyncImage + +/** + * Cover art from the box's HTTP cover cache. + * + * The image library here is Coil, which shares its name with this app — it is imported + * as `coil.compose` and called "the image loader" everywhere else, to keep review + * comments unambiguous. + * + * Cover art dominates the player, so the brand colour deliberately recedes here: the + * placeholder is a neutral surface rather than something green competing with artwork + * (§10.7). + */ +@Composable +fun CoverArt( + url: String?, + contentDescription: String?, + modifier: Modifier = Modifier, + cornerRadius: Dp = 18.dp, + placeholderIconSize: Dp = 40.dp, + /** What stands in for missing artwork — a folder has no cover, but it has a shape. */ + placeholderIcon: ImageVector = Icons.Rounded.Album, +) { + val shape = RoundedCornerShape(cornerRadius) + + if (url == null) { + CoverPlaceholder(modifier.clip(shape), placeholderIconSize, placeholderIcon) + return + } + + SubcomposeAsyncImage( + model = url, + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + modifier = modifier.clip(shape), + loading = { CoverPlaceholder(Modifier.fillMaxSize(), placeholderIconSize, placeholderIcon) }, + error = { CoverPlaceholder(Modifier.fillMaxSize(), placeholderIconSize, placeholderIcon) }, + ) +} + +@Composable +private fun CoverPlaceholder(modifier: Modifier, iconSize: Dp, icon: ImageVector) { + Box( + modifier = modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(iconSize), + ) + } +} diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/components/DetailsSheet.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/components/DetailsSheet.kt new file mode 100644 index 0000000..26dd3f0 --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/components/DetailsSheet.kt @@ -0,0 +1,151 @@ +package app.coilforphoniebox.ui.components + +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.StarBorder +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.coilforphoniebox.R + +/** One label/value line in a [DetailsSheet]. Values are content from the box, shown as-is. */ +data class DetailRow(val label: String, val value: String) + +/** + * What Coil knows about one library item. + * + * Everything here comes from the cache: opening details must not put a request on the RPC + * socket the box also uses for its card reader (§6). A field the cache does not have is + * left out rather than guessed at, which is why [rows] is assembled by the caller. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DetailsSheet( + title: String, + subtitle: String?, + coverUrl: String?, + placeholderIcon: ImageVector, + rows: List, + /** Freshness caption, or null when this item has never been fetched. */ + footnote: String?, + favouriteLabel: String, + favourite: Boolean, + onToggleFavourite: () -> Unit, + onPlay: () -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + Modifier + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + CoverArt( + url = coverUrl, + contentDescription = null, + modifier = Modifier.size(72.dp), + cornerRadius = 14.dp, + placeholderIconSize = 32.dp, + placeholderIcon = placeholderIcon, + ) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + Spacer(Modifier.height(20.dp)) + + rows.forEach { row -> + DetailLine(row) + Spacer(Modifier.height(10.dp)) + } + + if (footnote != null) { + Text( + text = footnote, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(10.dp)) + } + + Spacer(Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Button(onClick = onPlay, modifier = Modifier.weight(1f)) { + Icon(Icons.Rounded.PlayArrow, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(text = stringResource(R.string.action_play), maxLines = 1) + } + OutlinedButton(onClick = onToggleFavourite, modifier = Modifier.weight(1f)) { + Icon( + imageVector = if (favourite) Icons.Rounded.Star else Icons.Rounded.StarBorder, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + ) + Spacer(Modifier.width(8.dp)) + Text(text = favouriteLabel, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +/** + * Label above value rather than side by side: a German label and a long folder path both + * need the full width, and stacking them means neither has to be truncated (§12.3). + */ +@Composable +private fun DetailLine(row: DetailRow) { + Column { + Text( + text = row.label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(text = row.value, style = MaterialTheme.typography.bodyMedium) + } +} diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/components/Formatting.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/components/Formatting.kt new file mode 100644 index 0000000..a7298d4 --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/components/Formatting.kt @@ -0,0 +1,56 @@ +package app.coilforphoniebox.ui.components + +import android.text.format.DateUtils +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import app.coilforphoniebox.R +import java.text.NumberFormat +import java.util.concurrent.TimeUnit + +/** + * Durations, counts and relative times all go through the platform rather than being + * assembled by hand (§12.2). `DateUtils` knows that some locales separate minutes and + * seconds differently, and the plural rules differ across the launch set — French + * treats 0 as singular where English, German, Dutch and Spanish do not. + */ +fun formatDuration(seconds: Double?): String { + val total = seconds?.takeIf { it.isFinite() && it >= 0 }?.toLong() ?: 0L + return DateUtils.formatElapsedTime(total) +} + +/** Digits differ by locale, so even a volume level goes through the platform. */ +fun formatNumber(value: Int): String = NumberFormat.getIntegerInstance().format(value) + +/** "Updated 3 days ago" and friends. Returns null when nothing has been cached yet. */ +@Composable +fun rememberFreshnessLabel(cachedAt: Long?): String? { + val context = LocalContext.current + if (cachedAt == null || cachedAt <= 0L) return null + + val elapsed = (System.currentTimeMillis() - cachedAt).coerceAtLeast(0L) + val minutes = TimeUnit.MILLISECONDS.toMinutes(elapsed) + val hours = TimeUnit.MILLISECONDS.toHours(elapsed) + val days = TimeUnit.MILLISECONDS.toDays(elapsed) + + val relative = when { + minutes < 1 -> context.getString(R.string.time_just_now) + hours < 1 -> context.resources.getQuantityString( + R.plurals.time_minutes_ago, + minutes.toInt(), + minutes.toInt(), + ) + + days < 1 -> context.resources.getQuantityString( + R.plurals.time_hours_ago, + hours.toInt(), + hours.toInt(), + ) + + else -> context.resources.getQuantityString( + R.plurals.time_days_ago, + days.toInt(), + days.toInt(), + ) + } + return context.getString(R.string.library_updated, relative) +} diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/components/MiniPlayer.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/components/MiniPlayer.kt new file mode 100644 index 0000000..cff0c3f --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/components/MiniPlayer.kt @@ -0,0 +1,107 @@ +package app.coilforphoniebox.ui.components + +import androidx.compose.foundation.clickable +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.material.icons.Icons +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +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.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.coilforphoniebox.R +import app.coilforphoniebox.domain.model.PlayerStatus + +/** Sits above the navigation bar on every screen except the player itself. */ +@Composable +fun MiniPlayer( + status: PlayerStatus, + coverUrl: String?, + onClick: () -> Unit, + onToggle: () -> Unit, + modifier: Modifier = Modifier, +) { + val progress = progressOf(status) + + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 3.dp, + ) { + Column { + if (progress != null) { + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth(), + ) + } + + Row( + modifier = Modifier + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CoverArt( + url = coverUrl, + contentDescription = null, + modifier = Modifier.size(40.dp), + cornerRadius = 10.dp, + placeholderIconSize = 18.dp, + ) + Spacer(Modifier.size(12.dp)) + Column(Modifier.weight(1f)) { + Text( + text = status.title ?: stringResource(R.string.player_no_title), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val subtitle = status.artist ?: status.album + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + IconButton(onClick = onToggle) { + Icon( + imageVector = if (status.isPlaying) { + Icons.Rounded.Pause + } else { + Icons.Rounded.PlayArrow + }, + contentDescription = stringResource( + if (status.isPlaying) R.string.action_pause else R.string.action_play, + ), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } +} + +/** Null for content without a duration, such as web radio. */ +private fun progressOf(status: PlayerStatus): Float? { + val duration = status.durationSeconds?.takeIf { it > 0 } ?: return null + val elapsed = status.elapsedSeconds ?: 0.0 + return (elapsed / duration).coerceIn(0.0, 1.0).toFloat() +} diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/components/StateViews.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/components/StateViews.kt new file mode 100644 index 0000000..f928112 --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/components/StateViews.kt @@ -0,0 +1,96 @@ +package app.coilforphoniebox.ui.components + +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CloudOff +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +/** Empty state: a heading, a sentence of explanation and at most one action. */ +@Composable +fun EmptyState( + icon: ImageVector, + title: String, + body: String, + modifier: Modifier = Modifier, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + Spacer(Modifier.height(16.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + if (actionLabel != null && onAction != null) { + Spacer(Modifier.height(16.dp)) + TextButton(onClick = onAction) { Text(actionLabel) } + } + } +} + +/** + * Shown while cached data is on screen but the box cannot be reached. + * + * Uses the neutral surface rather than the error colour: a box that is switched off is + * a normal state, and error red is reserved for things that actually went wrong (§10.7). + */ +@Composable +fun OfflineBanner(text: String, modifier: Modifier = Modifier) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.CloudOff, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.size(10.dp)) + Text(text = text, style = MaterialTheme.typography.bodySmall) + } + } +} diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/favorites/FavoritesScreen.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/favorites/FavoritesScreen.kt new file mode 100644 index 0000000..4407949 --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/favorites/FavoritesScreen.kt @@ -0,0 +1,217 @@ +package app.coilforphoniebox.ui.favorites + +import android.content.Context +import android.content.Intent +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.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Album +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.StarBorder +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +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.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.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.coilforphoniebox.R +import app.coilforphoniebox.domain.model.Box as PhonieBox +import app.coilforphoniebox.domain.model.Favorite +import app.coilforphoniebox.domain.model.FavoriteType +import app.coilforphoniebox.ui.components.CoverArt +import app.coilforphoniebox.ui.components.EmptyState + +@Composable +fun FavoritesScreen( + viewModel: FavoritesViewModel, + modifier: Modifier = Modifier, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + if (state.favorites.isEmpty()) { + EmptyState( + icon = Icons.Rounded.StarBorder, + title = stringResource(R.string.favourites_empty_title), + body = stringResource(R.string.favourites_empty_body), + modifier = modifier, + ) + return + } + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 148.dp), + contentPadding = PaddingValues(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier, + ) { + items(state.favorites, key = { it.id }) { favorite -> + FavoriteCell( + favorite = favorite, + box = state.activeBox, + link = viewModel.linkFor(favorite), + onPlay = { viewModel.play(favorite) }, + onRemove = { viewModel.remove(favorite) }, + onPin = { coverUrl -> viewModel.requestPin(favorite, coverUrl) }, + onMove = { up -> viewModel.move(favorite, up) }, + onLinkCopied = viewModel::onLinkCopied, + ) + } + } +} + +@Composable +private fun FavoriteCell( + favorite: Favorite, + box: PhonieBox?, + /** `coil://play` link for this favourite; null for a row that cannot be played. */ + link: String?, + onPlay: () -> Unit, + onRemove: () -> Unit, + onPin: (String?) -> Unit, + onMove: (Boolean) -> Unit, + onLinkCopied: () -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + val coverUrl = remember(favorite.coverFile, box?.host) { + favorite.coverFile?.let { file -> box?.coverUrl(file) } + } + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + + Column { + Box { + CoverArt( + url = coverUrl, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clickable(onClick = onPlay), + cornerRadius = 14.dp, + // Folders and single tracks have no artwork, so the placeholder is what + // tells a saved folder apart from a saved track at a glance. + placeholderIcon = when (favorite.type) { + FavoriteType.FOLDER -> Icons.Rounded.Folder + FavoriteType.ALBUM -> Icons.Rounded.Album + FavoriteType.TRACK -> Icons.Rounded.MusicNote + }, + ) + + IconButton( + onClick = { menuOpen = true }, + modifier = Modifier.align(Alignment.TopEnd), + ) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.action_more), + ) + } + + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_add_to_home_screen)) }, + onClick = { + menuOpen = false + onPin(coverUrl) + }, + ) + // The link is what a home screen shortcut points at. Handing it out lets an + // automation app, an NFC tag or a link in a note start this favourite on its + // own box — and it is the only way to see it, since the box id in it is a + // UUID shown nowhere else. + if (link != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_copy_link)) }, + onClick = { + menuOpen = false + clipboard.setText(AnnotatedString(link)) + onLinkCopied() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_share_link)) }, + onClick = { + menuOpen = false + context.shareLink(link, favorite.label) + }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.action_move_up)) }, + onClick = { + menuOpen = false + onMove(true) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_move_down)) }, + onClick = { + menuOpen = false + onMove(false) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_remove)) }, + onClick = { + menuOpen = false + onRemove() + }, + ) + } + } + + Spacer(Modifier.height(6.dp)) + Text( + text = favorite.label, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.size(4.dp)) + } +} + +/** + * Hands the link to the share sheet. [label] rides along as the sheet's preview title, so + * the target says which favourite it is rather than showing a bare URI — it is the name the + * user gave the favourite and goes out as it is (§12.4). + * + * A launcher with no app able to take plain text is possible, if unlikely; the link is on + * the clipboard route in that case, so this stays silent rather than raising an error. + */ +private fun Context.shareLink(link: String, label: String) { + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, link) + putExtra(Intent.EXTRA_TITLE, label) + } + runCatching { startActivity(Intent.createChooser(send, null)) } +} diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/favorites/FavoritesViewModel.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/favorites/FavoritesViewModel.kt new file mode 100644 index 0000000..07826ff --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/favorites/FavoritesViewModel.kt @@ -0,0 +1,116 @@ +package app.coilforphoniebox.ui.favorites + +import android.os.Build +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.coilforphoniebox.R +import app.coilforphoniebox.domain.model.Box +import app.coilforphoniebox.domain.model.Favorite +import app.coilforphoniebox.domain.repository.BoxRepository +import app.coilforphoniebox.domain.repository.FavoriteRepository +import app.coilforphoniebox.domain.usecase.PlayFavoriteUseCase +import app.coilforphoniebox.shortcuts.PlayDeepLink +import app.coilforphoniebox.shortcuts.ShortcutPublisher +import app.coilforphoniebox.ui.UiMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class FavoritesViewModel @Inject constructor( + private val favorites: FavoriteRepository, + private val boxes: BoxRepository, + private val playFavorite: PlayFavoriteUseCase, + private val shortcuts: ShortcutPublisher, +) : ViewModel() { + + data class State( + val favorites: List = emptyList(), + val activeBox: Box? = null, + ) + + private val messageChannel = MutableSharedFlow( + extraBufferCapacity = 4, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val messages: SharedFlow = messageChannel + + val state: StateFlow = combine( + boxes.activeBox, + boxes.activeBox.map { it?.id }.distinctUntilChanged().flatMapLatest { boxId -> + if (boxId == null) flowOf(emptyList()) else favorites.favorites(boxId) + }, + ) { box, list -> State(favorites = list, activeBox = box) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), State()) + + fun play(favorite: Favorite) { + viewModelScope.launch { + playFavorite(favorite) + .onFailure { messageChannel.emit(UiMessage(R.string.shortcut_failed)) } + } + } + + fun remove(favorite: Favorite) { + viewModelScope.launch { + favorites.remove(favorite.id) + messageChannel.emit(UiMessage(R.string.favourites_removed)) + } + } + + /** Drag-free reordering: one step at a time, which is also reachable with TalkBack. */ + fun move(favorite: Favorite, up: Boolean) { + val current = state.value.favorites + val index = current.indexOfFirst { it.id == favorite.id } + if (index < 0) return + val target = if (up) index - 1 else index + 1 + if (target !in current.indices) return + + val reordered = current.toMutableList().apply { + add(target, removeAt(index)) + } + viewModelScope.launch { favorites.reorder(reordered.map { it.id }) } + } + + /** + * The `coil://play` link for [favorite], or null for a row that cannot be played. + * + * This is the same URI a home screen shortcut carries, box id and all, so anything that + * can fire a VIEW intent — an automation app, an NFC tag, a link in a note — starts this + * favourite on *its* box regardless of which one is active (§7.3). Handing it over is the + * only way to get at it: the box id is a UUID that appears nowhere in the UI. + */ + fun linkFor(favorite: Favorite): String? = PlayDeepLink.uriFor(favorite)?.toString() + + /** + * Android 13 shows its own confirmation whenever an app writes to the clipboard, so + * saying it twice is worse than not saying it at all. + */ + fun onLinkCopied() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return + viewModelScope.launch { messageChannel.emit(UiMessage(R.string.favourites_link_copied)) } + } + + fun requestPin(favorite: Favorite, coverUrl: String?) { + viewModelScope.launch { + val requested = shortcuts.requestPin(favorite, coverUrl) + if (requested) { + favorites.setPinned(favorite.id, true) + } else { + messageChannel.emit(UiMessage(R.string.shortcut_pin_unsupported)) + } + } + } +} diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/library/LibraryScreen.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/library/LibraryScreen.kt new file mode 100644 index 0000000..17ce44c --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/library/LibraryScreen.kt @@ -0,0 +1,1056 @@ +package app.coilforphoniebox.ui.library + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState +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.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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Album +import androidx.compose.material.icons.rounded.ArrowUpward +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.FolderOff +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.SearchOff +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.coilforphoniebox.R +import app.coilforphoniebox.domain.model.Box as PhonieBox +import app.coilforphoniebox.domain.model.LibraryAlbum +import app.coilforphoniebox.domain.model.LibraryFolder +import app.coilforphoniebox.domain.model.LibraryTrack +import app.coilforphoniebox.domain.model.PlayTarget +import app.coilforphoniebox.ui.components.ActionMenuItem +import app.coilforphoniebox.ui.components.CoverArt +import app.coilforphoniebox.ui.components.DetailRow +import app.coilforphoniebox.ui.components.DetailsSheet +import app.coilforphoniebox.ui.components.EmptyState +import app.coilforphoniebox.ui.components.FavoriteMenuItem +import app.coilforphoniebox.ui.components.formatDuration +import app.coilforphoniebox.ui.components.formatNumber +import app.coilforphoniebox.ui.components.rememberFreshnessLabel + +private const val TAB_FOLDERS = 0 +private const val TAB_ALBUMS = 1 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LibraryScreen( + viewModel: LibraryViewModel, + modifier: Modifier = Modifier, +) { + var selectedTab by rememberSaveable { mutableIntStateOf(TAB_FOLDERS) } + val query by viewModel.searchQuery.collectAsStateWithLifecycle() + val searching = query.isNotEmpty() + + // Back leaves search before it does anything else, so a query is never something the user + // has to undo by hand. The two handlers are mutually exclusive, so their order is moot. + BackHandler(enabled = searching) { viewModel.clearSearch() } + // Picking the album tab is navigation the user did inside the library too, so back + // undoes it rather than dropping out of the library entirely. + BackHandler(enabled = !searching && selectedTab != TAB_FOLDERS) { selectedTab = TAB_FOLDERS } + + Column(modifier.fillMaxSize()) { + SearchField( + query = query, + onQueryChange = viewModel::onSearchQueryChange, + onClear = viewModel::clearSearch, + ) + + // The tabs are a way of browsing, and searching is not browsing: with a query up, + // folders, albums and tracks all appear in one list and the tab row would only pose a + // question with no answer. + if (searching) { + SearchResults(viewModel) + return@Column + } + + PrimaryTabRow(selectedTabIndex = selectedTab) { + Tab( + selected = selectedTab == TAB_FOLDERS, + onClick = { selectedTab = TAB_FOLDERS }, + text = { Text(stringResource(R.string.library_tab_folders)) }, + ) + Tab( + selected = selectedTab == TAB_ALBUMS, + onClick = { selectedTab = TAB_ALBUMS }, + text = { Text(stringResource(R.string.library_tab_albums)) }, + ) + } + + when (selectedTab) { + TAB_FOLDERS -> FolderTab(viewModel) + else -> AlbumTab(viewModel) + } + } +} + +@Composable +private fun SearchField( + query: String, + onQueryChange: (String) -> Unit, + onClear: () -> Unit, +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text(stringResource(R.string.library_search_hint)) }, + leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = onClear) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.action_search_clear), + ) + } + } + }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + ) +} + +/** + * One list for all three kinds, each under its own heading. + * + * Everything here comes from the cache, which is why the empty state explains what has been + * loaded rather than claiming the library holds nothing: the box has no search command, so + * Coil can only search what it has already seen (§6). + */ +@Composable +private fun SearchResults(viewModel: LibraryViewModel) { + val results by viewModel.searchResults.collectAsStateWithLifecycle() + val favouriteKeys by viewModel.favoriteKeys.collectAsStateWithLifecycle() + val activeBox by viewModel.activeBox.collectAsStateWithLifecycle() + var details by remember { mutableStateOf(null) } + + details?.let { target -> + LibraryDetailsSheet( + target = target, + box = activeBox, + favouriteKeys = favouriteKeys, + viewModel = viewModel, + onDismiss = { details = null }, + ) + } + + if (results.isEmpty) { + EmptyState( + icon = Icons.Rounded.SearchOff, + title = stringResource(R.string.library_search_empty_title), + body = stringResource(R.string.library_search_empty_body), + ) + return + } + + LazyColumn( + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + if (results.folders.isNotEmpty()) { + item { ResultHeading(stringResource(R.string.library_tab_folders)) } + items(results.folders, key = { "folder:${it.path}" }) { folder -> + FolderRow( + folder = folder, + favourite = "folder:${folder.path}" in favouriteKeys, + // Opening the folder that was found is the point of having found it, so + // this leaves search and lands in the browser at that level. + onOpen = { + viewModel.openFolder(folder.path) + viewModel.clearSearch() + }, + onPlay = { viewModel.play(PlayTarget.Folder(folder.path)) }, + onToggleFavourite = { + viewModel.toggleFavorite(folder.displayName, PlayTarget.Folder(folder.path)) + }, + onDetails = { details = DetailsTarget.Folder(folder) }, + ) + } + } + + if (results.albums.isNotEmpty()) { + item { ResultHeading(stringResource(R.string.library_tab_albums)) } + items(results.albums, key = { "album:${it.albumArtist}|${it.album}" }) { album -> + AlbumRow( + album = album, + box = activeBox, + favourite = "album:${album.albumArtist}/${album.album}" in favouriteKeys, + onPlay = { viewModel.play(PlayTarget.Album(album.albumArtist, album.album)) }, + onToggleFavourite = { + viewModel.toggleFavorite( + label = album.album, + target = PlayTarget.Album(album.albumArtist, album.album), + coverFile = album.coverFile, + ) + }, + onDetails = { details = DetailsTarget.Album(album) }, + ) + } + } + + if (results.tracks.isNotEmpty()) { + item { ResultHeading(stringResource(R.string.library_search_tracks)) } + items(results.tracks, key = { "track:${it.url}" }) { track -> + TrackRow( + track = track, + favourite = "track:${track.url}" in favouriteKeys, + onPlay = { viewModel.play(PlayTarget.Track(track.url)) }, + onToggleFavourite = { + viewModel.toggleFavorite(track.displayTitle, PlayTarget.Track(track.url)) + }, + onDetails = { details = DetailsTarget.Track(track) }, + ) + } + } + } +} + +@Composable +private fun ResultHeading(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 4.dp, top = 12.dp, bottom = 4.dp), + ) +} + +@Composable +private fun FolderTab(viewModel: LibraryViewModel) { + val state by viewModel.folderState.collectAsStateWithLifecycle() + val favouriteKeys by viewModel.favoriteKeys.collectAsStateWithLifecycle() + val activeBox by viewModel.activeBox.collectAsStateWithLifecycle() + val freshness = rememberFreshnessLabel(state.content.cachedAt) + var details by remember { mutableStateOf(null) } + + // How deep the user is in the folder tree is view model state, not a navigation + // destination, so the system back gesture would leave the library altogether from three + // levels down. Inside the tree it means "up one level" instead — the same thing the + // breadcrumb's arrow does. Registered before the details sheet so that the sheet, when + // it is open, still gets back first and closes itself. + BackHandler(enabled = state.canGoUp) { viewModel.goUp() } + + details?.let { target -> + LibraryDetailsSheet( + target = target, + box = activeBox, + favouriteKeys = favouriteKeys, + viewModel = viewModel, + onDismiss = { details = null }, + ) + } + + Column(Modifier.fillMaxSize()) { + Breadcrumb( + segments = state.segments, + canGoUp = state.canGoUp, + onUp = viewModel::goUp, + onSegment = viewModel::openSegment, + onRoot = { viewModel.openFolder("") }, + ) + + if (state.content.isEmpty && !state.refreshing) { + EmptyState( + icon = Icons.Rounded.FolderOff, + title = stringResource(R.string.library_empty_folder), + body = freshness ?: stringResource(R.string.library_never_updated), + actionLabel = stringResource(R.string.action_refresh), + onAction = viewModel::refreshCurrentFolder, + ) + return@Column + } + + LazyColumn( + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + items(state.content.folders, key = { it.path }) { folder -> + FolderRow( + folder = folder, + favourite = "folder:${folder.path}" in favouriteKeys, + onOpen = { viewModel.openFolder(folder.path) }, + onPlay = { viewModel.play(PlayTarget.Folder(folder.path)) }, + onToggleFavourite = { + viewModel.toggleFavorite(folder.displayName, PlayTarget.Folder(folder.path)) + }, + onDetails = { details = DetailsTarget.Folder(folder) }, + ) + } + + items(state.content.tracks, key = { it.url }) { track -> + TrackRow( + track = track, + favourite = "track:${track.url}" in favouriteKeys, + onPlay = { viewModel.play(PlayTarget.Track(track.url)) }, + onToggleFavourite = { + viewModel.toggleFavorite(track.displayTitle, PlayTarget.Track(track.url)) + }, + onDetails = { details = DetailsTarget.Track(track) }, + ) + } + + if (freshness != null) { + item { + Text( + text = freshness, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 16.dp), + ) + } + } + } + } +} + +@Composable +private fun Breadcrumb( + segments: List, + canGoUp: Boolean, + onUp: () -> Unit, + onSegment: (Int) -> Unit, + onRoot: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 4.dp, end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onUp, enabled = canGoUp) { + Icon( + imageVector = Icons.Rounded.ArrowUpward, + contentDescription = stringResource(R.string.action_up), + ) + } + + // Scrolls rather than truncates: a deep path would otherwise hide the current + // folder's name, which is the one part that matters. Long German and Dutch names + // make this the first place a fixed width would have bitten (§12.3). + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.library_root), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.clickable(onClick = onRoot), + ) + segments.forEachIndexed { index, segment -> + Text( + text = SEGMENT_SEPARATOR, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = segment, + style = MaterialTheme.typography.titleSmall, + color = if (index == segments.lastIndex) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 1, + modifier = Modifier.clickable { onSegment(index) }, + ) + } + } + } +} + +/** + * A folder. Tapping opens it, the note button plays it, and both the ⋮ button and a long + * press open the same menu — the long press alone was the only way to favourite a folder, + * which is not something a user finds by accident. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun FolderRow( + folder: LibraryFolder, + favourite: Boolean, + onOpen: () -> Unit, + onPlay: () -> Unit, + onToggleFavourite: () -> Unit, + onDetails: () -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + + Surface( + shape = RoundedCornerShape(13.dp), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = onOpen, + onLongClick = { menuOpen = true }, + onLongClickLabel = stringResource(R.string.action_more), + ), + ) { + Row( + modifier = Modifier.padding(start = 11.dp, end = 4.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RowIcon(Icons.Rounded.Folder) + Spacer(Modifier.size(12.dp)) + Text( + text = folder.displayName, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (favourite) { + Icon( + imageVector = Icons.Rounded.Star, + contentDescription = stringResource(R.string.action_favourite_remove), + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.size(8.dp)) + } + IconButton(onClick = onPlay) { + Icon( + imageVector = Icons.Rounded.MusicNote, + contentDescription = stringResource(R.string.action_play_folder), + ) + } + ItemMenu( + expanded = menuOpen, + onExpandedChange = { menuOpen = it }, + playLabel = stringResource(R.string.action_play_folder), + favouriteLabel = stringResource( + if (favourite) R.string.action_favourite_remove_folder + else R.string.action_favourite_add_folder, + ), + favourite = favourite, + onPlay = onPlay, + onToggleFavourite = onToggleFavourite, + onDetails = onDetails, + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun TrackRow( + track: LibraryTrack, + favourite: Boolean, + onPlay: () -> Unit, + onToggleFavourite: () -> Unit, + onDetails: () -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + + Surface( + shape = RoundedCornerShape(13.dp), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = onPlay, + onLongClick = { menuOpen = true }, + onLongClickLabel = stringResource(R.string.action_more), + ), + ) { + Row( + modifier = Modifier.padding(start = 11.dp, end = 4.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RowIcon(Icons.Rounded.MusicNote) + Spacer(Modifier.size(12.dp)) + Text( + text = track.displayTitle, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (favourite) { + Icon( + imageVector = Icons.Rounded.Star, + contentDescription = stringResource(R.string.action_favourite_remove), + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.size(8.dp)) + } + ItemMenu( + expanded = menuOpen, + onExpandedChange = { menuOpen = it }, + playLabel = stringResource(R.string.action_play), + favouriteLabel = stringResource( + if (favourite) R.string.action_favourite_remove_track + else R.string.action_favourite_add_track, + ), + favourite = favourite, + onPlay = onPlay, + onToggleFavourite = onToggleFavourite, + onDetails = onDetails, + ) + } + } +} + +/** An album as a list row, for search results — the album tab shows a grid of covers instead. */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun AlbumRow( + album: LibraryAlbum, + box: PhonieBox?, + favourite: Boolean, + onPlay: () -> Unit, + onToggleFavourite: () -> Unit, + onDetails: () -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + val coverUrl = remember(album.coverFile, box?.host) { + album.coverFile?.let { file -> box?.coverUrl(file) } + } + + Surface( + shape = RoundedCornerShape(13.dp), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = onPlay, + onLongClick = { menuOpen = true }, + onLongClickLabel = stringResource(R.string.action_more), + ), + ) { + Row( + modifier = Modifier.padding(start = 11.dp, end = 4.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // No cover request here: search results scroll past quickly, and one RPC per row + // is exactly what the album grid is careful not to do (§6). + CoverArt( + url = coverUrl, + contentDescription = null, + modifier = Modifier.size(38.dp), + cornerRadius = 11.dp, + placeholderIconSize = 20.dp, + ) + Spacer(Modifier.size(12.dp)) + Column(Modifier.weight(1f)) { + Text( + text = album.album.ifBlank { stringResource(R.string.library_unknown_album) }, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = album.albumArtist.ifBlank { + stringResource(R.string.library_unknown_artist) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (favourite) { + Icon( + imageVector = Icons.Rounded.Star, + contentDescription = stringResource(R.string.action_favourite_remove), + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.size(8.dp)) + } + ItemMenu( + expanded = menuOpen, + onExpandedChange = { menuOpen = it }, + playLabel = stringResource(R.string.action_play), + favouriteLabel = stringResource( + if (favourite) R.string.action_favourite_remove_album + else R.string.action_favourite_add_album, + ), + favourite = favourite, + onPlay = onPlay, + onToggleFavourite = onToggleFavourite, + onDetails = onDetails, + ) + } + } +} + +/** + * The ⋮ button and the menu behind it, shared by folders, tracks and albums. Its three + * entries are the same everywhere; only the wording of the first two changes with the kind + * of item, so that "Play" and "Save as favourite" never leave open *what* is meant. + */ +@Composable +private fun ItemMenu( + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + playLabel: String, + favouriteLabel: String, + favourite: Boolean, + onPlay: () -> Unit, + onToggleFavourite: () -> Unit, + onDetails: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier) { + IconButton(onClick = { onExpandedChange(true) }) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.action_more), + ) + } + + DropdownMenu(expanded = expanded, onDismissRequest = { onExpandedChange(false) }) { + ActionMenuItem( + text = playLabel, + icon = Icons.Rounded.PlayArrow, + onClick = { + onExpandedChange(false) + onPlay() + }, + ) + FavoriteMenuItem( + text = favouriteLabel, + detail = null, + saved = favourite, + onClick = { + onExpandedChange(false) + onToggleFavourite() + }, + ) + ActionMenuItem( + text = stringResource(R.string.action_details), + icon = Icons.Rounded.Info, + onClick = { + onExpandedChange(false) + onDetails() + }, + ) + } + } +} + +@Composable +private fun RowIcon(icon: androidx.compose.ui.graphics.vector.ImageVector) { + Surface( + shape = RoundedCornerShape(11.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.size(38.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun AlbumTab(viewModel: LibraryViewModel) { + val state by viewModel.albumState.collectAsStateWithLifecycle() + val favouriteKeys by viewModel.favoriteKeys.collectAsStateWithLifecycle() + val activeBox by viewModel.activeBox.collectAsStateWithLifecycle() + val freshness = rememberFreshnessLabel(state.cachedAt) + var details by remember { mutableStateOf(null) } + + // Opening the tab is what asks the box for its albums the first time. + LaunchedEffect(activeBox?.id) { viewModel.onAlbumsShown() } + + details?.let { target -> + LibraryDetailsSheet( + target = target, + box = activeBox, + favouriteKeys = favouriteKeys, + viewModel = viewModel, + onDismiss = { details = null }, + ) + } + + if (state.albums.isEmpty() && !state.refreshing) { + EmptyState( + icon = Icons.Rounded.Album, + title = stringResource(R.string.library_empty_albums), + body = stringResource(R.string.library_never_updated), + actionLabel = stringResource(R.string.action_refresh), + onAction = viewModel::refreshAlbums, + ) + return + } + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 148.dp), + contentPadding = PaddingValues(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(state.albums, key = { "${it.albumArtist}|${it.album}" }) { album -> + AlbumCell( + album = album, + box = activeBox, + favourite = "album:${album.albumArtist}/${album.album}" in favouriteKeys, + onRequestCover = { viewModel.requestAlbumCover(album) }, + onPlay = { viewModel.play(PlayTarget.Album(album.albumArtist, album.album)) }, + onToggleFavourite = { + viewModel.toggleFavorite( + label = album.album, + target = PlayTarget.Album(album.albumArtist, album.album), + coverFile = album.coverFile, + ) + }, + onDetails = { details = DetailsTarget.Album(album) }, + ) + } + + if (freshness != null) { + item { + Text( + text = freshness, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 16.dp), + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun AlbumCell( + album: LibraryAlbum, + box: PhonieBox?, + favourite: Boolean, + onRequestCover: () -> Unit, + onPlay: () -> Unit, + onToggleFavourite: () -> Unit, + onDetails: () -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + + // Covers are fetched as cells appear rather than all at once during a refresh: one + // RPC per album on a socket the box shares with its card reader (§6). + LaunchedEffect(album.albumArtist, album.album, album.coverFile) { + if (album.coverFile == null) onRequestCover() + } + + val coverUrl = remember(album.coverFile, box?.host) { + album.coverFile?.let { file -> box?.coverUrl(file) } + } + + Column( + Modifier.combinedClickable( + onClick = onPlay, + onLongClick = { menuOpen = true }, + onLongClickLabel = stringResource(R.string.action_more), + ), + ) { + Box { + CoverArt( + url = coverUrl, + contentDescription = stringResource(R.string.player_cover_art), + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + cornerRadius = 14.dp, + ) + if (favourite) { + Icon( + imageVector = Icons.Rounded.Star, + contentDescription = stringResource(R.string.action_favourite_remove), + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier + .align(Alignment.TopStart) + .padding(6.dp) + .size(20.dp), + ) + } + // Over the artwork rather than under the title: the grid has no spare row, + // and the star badge moves to the opposite corner to make room. + ItemMenu( + expanded = menuOpen, + onExpandedChange = { menuOpen = it }, + playLabel = stringResource(R.string.action_play), + favouriteLabel = stringResource( + if (favourite) R.string.action_favourite_remove_album + else R.string.action_favourite_add_album, + ), + favourite = favourite, + onPlay = onPlay, + onToggleFavourite = onToggleFavourite, + onDetails = onDetails, + modifier = Modifier.align(Alignment.TopEnd), + ) + } + Spacer(Modifier.height(6.dp)) + Text( + text = album.album.ifBlank { stringResource(R.string.library_unknown_album) }, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = album.albumArtist.ifBlank { stringResource(R.string.library_unknown_artist) }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + } +} + +/** What a tab or the search results are currently showing details for. */ +private sealed interface DetailsTarget { + data class Folder(val folder: LibraryFolder) : DetailsTarget + + data class Track(val track: LibraryTrack) : DetailsTarget + + data class Album(val album: LibraryAlbum) : DetailsTarget +} + +/** + * Wires a [DetailsTarget] to its sheet, so the folder tab, the album tab and the search + * results all get the same behaviour from one place. + * + * Starting playback closes the sheet; favouriting from it does not, because the star filling + * in is the confirmation — a snackbar would sit behind the sheet unseen. + */ +@Composable +private fun LibraryDetailsSheet( + target: DetailsTarget, + box: PhonieBox?, + favouriteKeys: Set, + viewModel: LibraryViewModel, + onDismiss: () -> Unit, +) { + when (target) { + is DetailsTarget.Folder -> FolderDetailsSheet( + folder = target.folder, + favourite = "folder:${target.folder.path}" in favouriteKeys, + onPlay = { + onDismiss() + viewModel.play(PlayTarget.Folder(target.folder.path)) + }, + onToggleFavourite = { + viewModel.toggleFavorite( + target.folder.displayName, + PlayTarget.Folder(target.folder.path), + ) + }, + onDismiss = onDismiss, + ) + + is DetailsTarget.Track -> TrackDetailsSheet( + track = target.track, + favourite = "track:${target.track.url}" in favouriteKeys, + onPlay = { + onDismiss() + viewModel.play(PlayTarget.Track(target.track.url)) + }, + onToggleFavourite = { + viewModel.toggleFavorite( + target.track.displayTitle, + PlayTarget.Track(target.track.url), + ) + }, + onDismiss = onDismiss, + ) + + is DetailsTarget.Album -> AlbumDetailsSheet( + album = target.album, + box = box, + favourite = "album:${target.album.albumArtist}/${target.album.album}" in favouriteKeys, + onPlay = { + onDismiss() + viewModel.play(PlayTarget.Album(target.album.albumArtist, target.album.album)) + }, + onToggleFavourite = { + viewModel.toggleFavorite( + label = target.album.album, + target = PlayTarget.Album(target.album.albumArtist, target.album.album), + coverFile = target.album.coverFile, + ) + }, + onDismiss = onDismiss, + ) + } +} + +/** + * The three details sheets differ only in which fields exist for the kind of item, all of + * them read from the cache. Nothing here asks the box anything (§6). + */ +@Composable +private fun FolderDetailsSheet( + folder: LibraryFolder, + favourite: Boolean, + onPlay: () -> Unit, + onToggleFavourite: () -> Unit, + onDismiss: () -> Unit, +) { + DetailsSheet( + title = folder.displayName, + subtitle = null, + coverUrl = null, + placeholderIcon = Icons.Rounded.Folder, + rows = listOf( + DetailRow( + label = stringResource(R.string.details_path), + // The root's children have a bare name as their path, which is still the + // path the box takes. + value = folder.path, + ), + ), + footnote = rememberFreshnessLabel(folder.cachedAt), + favouriteLabel = stringResource( + if (favourite) R.string.action_favourite_remove_folder + else R.string.action_favourite_add_folder, + ), + favourite = favourite, + onToggleFavourite = onToggleFavourite, + onPlay = onPlay, + onDismiss = onDismiss, + ) +} + +@Composable +private fun TrackDetailsSheet( + track: LibraryTrack, + favourite: Boolean, + onPlay: () -> Unit, + onToggleFavourite: () -> Unit, + onDismiss: () -> Unit, +) { + val rows = buildList { + track.artist?.takeIf { it.isNotBlank() }?.let { + add(DetailRow(stringResource(R.string.details_artist), it)) + } + track.album?.takeIf { it.isNotBlank() }?.let { + add(DetailRow(stringResource(R.string.details_album), it)) + } + track.trackNo?.let { + add(DetailRow(stringResource(R.string.details_track_number), formatNumber(it))) + } + track.durationSeconds?.takeIf { it > 0 }?.let { + add(DetailRow(stringResource(R.string.details_duration), formatDuration(it))) + } + add(DetailRow(stringResource(R.string.details_file), track.url)) + } + + DetailsSheet( + title = track.displayTitle, + subtitle = track.artist?.takeIf { it.isNotBlank() }, + coverUrl = null, + placeholderIcon = Icons.Rounded.MusicNote, + rows = rows, + footnote = null, + favouriteLabel = stringResource( + if (favourite) R.string.action_favourite_remove_track + else R.string.action_favourite_add_track, + ), + favourite = favourite, + onToggleFavourite = onToggleFavourite, + onPlay = onPlay, + onDismiss = onDismiss, + ) +} + +@Composable +private fun AlbumDetailsSheet( + album: LibraryAlbum, + box: PhonieBox?, + favourite: Boolean, + onPlay: () -> Unit, + onToggleFavourite: () -> Unit, + onDismiss: () -> Unit, +) { + val coverUrl = remember(album.coverFile, box?.host) { + album.coverFile?.let { file -> box?.coverUrl(file) } + } + + DetailsSheet( + title = album.album.ifBlank { stringResource(R.string.library_unknown_album) }, + subtitle = album.albumArtist.ifBlank { stringResource(R.string.library_unknown_artist) }, + coverUrl = coverUrl, + placeholderIcon = Icons.Rounded.Album, + rows = listOf( + DetailRow( + label = stringResource(R.string.details_album_artist), + value = album.albumArtist.ifBlank { stringResource(R.string.library_unknown_artist) }, + ), + ), + footnote = rememberFreshnessLabel(album.cachedAt), + favouriteLabel = stringResource( + if (favourite) R.string.action_favourite_remove_album + else R.string.action_favourite_add_album, + ), + favourite = favourite, + onToggleFavourite = onToggleFavourite, + onPlay = onPlay, + onDismiss = onDismiss, + ) +} + +/** Path separator between breadcrumb segments; not translated. */ +private const val SEGMENT_SEPARATOR = " / " diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/library/LibraryViewModel.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/library/LibraryViewModel.kt new file mode 100644 index 0000000..c374a44 --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/library/LibraryViewModel.kt @@ -0,0 +1,293 @@ +package app.coilforphoniebox.ui.library + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.coilforphoniebox.R +import app.coilforphoniebox.domain.model.Box +import app.coilforphoniebox.domain.model.Favorite +import app.coilforphoniebox.domain.model.FolderContent +import app.coilforphoniebox.domain.model.LibraryAlbum +import app.coilforphoniebox.domain.model.LibrarySearchResults +import app.coilforphoniebox.domain.model.PlayTarget +import app.coilforphoniebox.domain.repository.BoxRepository +import app.coilforphoniebox.domain.repository.FavoriteRepository +import app.coilforphoniebox.domain.repository.LibraryRepository +import app.coilforphoniebox.domain.repository.PlayerRepository +import app.coilforphoniebox.ui.UiMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Folder drill-down and album grid. + * + * Both read from Room, never from an RPC result directly (§6.1), so the library stays + * browsable with the box switched off. A level is fetched on first visit and then only + * when the user asks for it again. + */ +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@HiltViewModel +class LibraryViewModel @Inject constructor( + private val library: LibraryRepository, + private val boxes: BoxRepository, + private val player: PlayerRepository, + private val favorites: FavoriteRepository, +) : ViewModel() { + + data class FolderState( + val path: String = FolderContent.ROOT, + val content: FolderContent = FolderContent(FolderContent.ROOT), + val refreshing: Boolean = false, + ) { + val canGoUp: Boolean get() = path.isNotEmpty() + + /** Breadcrumb segments, in order, for the header. */ + val segments: List get() = path.split('/').filter { it.isNotBlank() } + } + + data class AlbumState( + val albums: List = emptyList(), + val cachedAt: Long? = null, + val refreshing: Boolean = false, + ) + + private val messageChannel = MutableSharedFlow( + extraBufferCapacity = 4, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val messages: SharedFlow = messageChannel + + private val currentPath = MutableStateFlow(FolderContent.ROOT) + private val folderRefreshing = MutableStateFlow(false) + private val albumsRefreshing = MutableStateFlow(false) + + /** Levels already fetched in this session, so revisiting one does not re-fetch. */ + private val visitedLevels = mutableSetOf() + + /** Boxes whose album list has been fetched in this session. */ + private val loadedAlbumsFor = mutableSetOf() + + val activeBox: StateFlow = + boxes.activeBox.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + private val activeBoxId = boxes.activeBox.map { it?.id }.distinctUntilChanged() + + val folderState: StateFlow = combine( + activeBoxId, + currentPath, + folderRefreshing, + ) { boxId, path, refreshing -> Triple(boxId, path, refreshing) } + .flatMapLatest { (boxId, path, refreshing) -> + if (boxId == null) { + flowOf(FolderState(path = path, refreshing = refreshing)) + } else { + library.folderContent(boxId, path).map { content -> + FolderState(path = path, content = content, refreshing = refreshing) + } + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), FolderState()) + + val albumState: StateFlow = combine( + activeBoxId, + albumsRefreshing, + ) { boxId, refreshing -> boxId to refreshing } + .flatMapLatest { (boxId, refreshing) -> + if (boxId == null) { + flowOf(AlbumState(refreshing = refreshing)) + } else { + combine(library.albums(boxId), library.albumsCachedAt(boxId)) { albums, cachedAt -> + AlbumState(albums = albums, cachedAt = cachedAt, refreshing = refreshing) + } + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AlbumState()) + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + /** + * Results for [searchQuery], from the cache only — the box has no search command (§6). + * + * Debounced so a five-letter word is three queries rather than five, but with no delay on + * clearing: leaving search should feel instant, and an empty query costs nothing anyway. + */ + val searchResults: StateFlow = combine( + activeBoxId, + _searchQuery.debounce { query -> if (query.isBlank()) 0L else SEARCH_DEBOUNCE_MILLIS }, + ) { boxId, query -> boxId to query } + .flatMapLatest { (boxId, query) -> + if (boxId == null) flowOf(LibrarySearchResults()) else library.search(boxId, query) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibrarySearchResults()) + + fun onSearchQueryChange(value: String) { + _searchQuery.value = value + } + + fun clearSearch() { + _searchQuery.value = "" + } + + /** Favourited targets on the active box, so the list can mark what is already saved. */ + val favoriteKeys: StateFlow> = activeBoxId + .flatMapLatest { boxId -> + if (boxId == null) flowOf(emptyList()) else favorites.favorites(boxId) + } + .map { list -> list.mapNotNull { favoriteKey(it) }.toSet() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptySet()) + + init { + // Fetching a level is a side effect, so it lives here rather than inside a flow + // transform: opening a folder for the first time loads it, revisiting does not. + // + // Switching box starts again at the root. A path is only meaningful on the box it + // came from — the other box may have no such folder — and `collectLatest` drops the + // old box's collector before the reset, so the stale path is never fetched from the + // new box on the way past. + viewModelScope.launch { + activeBoxId.collectLatest { boxId -> + if (boxId == null) return@collectLatest + currentPath.value = FolderContent.ROOT + // Results are per box as well — leaving a query up would show hits from the + // box the user just switched away from. + _searchQuery.value = "" + currentPath.collect { path -> loadLevelOnce(boxId, path) } + } + } + } + + // ------------------------------------------------------------- navigation + + fun openFolder(path: String) { + currentPath.value = path + } + + fun goUp() { + val path = currentPath.value + if (path.isEmpty()) return + currentPath.value = path.substringBeforeLast('/', missingDelimiterValue = FolderContent.ROOT) + } + + fun openSegment(index: Int) { + val segments = folderState.value.segments + currentPath.value = segments.take(index + 1).joinToString("/") + } + + // ---------------------------------------------------------------- actions + + /** Pull to refresh: only the level on screen, never the whole library (§6.4). */ + fun refreshCurrentFolder() { + val boxId = activeBox.value?.id ?: return + val path = currentPath.value + viewModelScope.launch { + folderRefreshing.value = true + library.refreshFolder(boxId, path) + .onFailure { messageChannel.emit(UiMessage(R.string.error_library_load_failed)) } + folderRefreshing.value = false + } + } + + fun refreshAlbums() { + val boxId = activeBox.value?.id ?: return + viewModelScope.launch { + albumsRefreshing.value = true + library.refreshAlbums(boxId) + .onFailure { messageChannel.emit(UiMessage(R.string.error_library_load_failed)) } + albumsRefreshing.value = false + } + } + + /** + * Called when the album tab is shown. Loads the list once per box per session if it has + * never been loaded. + * + * Deliberately not gated on the box being idle, unlike the opportunistic refresh at app + * start: §6.4's caution is about background and periodic fetches, and opening the tab is + * neither. Without this, a first visit while the box was playing showed an empty grid and + * never asked the box for anything. + */ + fun onAlbumsShown() { + val boxId = activeBox.value?.id ?: return + if (!loadedAlbumsFor.add(boxId)) return + viewModelScope.launch { + albumsRefreshing.value = true + library.refreshAlbums(boxId) + .onFailure { messageChannel.emit(UiMessage(R.string.error_library_load_failed)) } + albumsRefreshing.value = false + } + } + + /** Called as grid items appear; the repository limits how many run at once. */ + fun requestAlbumCover(album: LibraryAlbum) { + viewModelScope.launch { + library.ensureAlbumCover(album.boxId, album.albumArtist, album.album) + } + } + + fun play(target: PlayTarget) { + viewModelScope.launch { + player.play(target) + .onFailure { messageChannel.emit(UiMessage(R.string.error_not_connected)) } + } + } + + /** From a row's context menu: save that exact target as a favourite, or take it out again. */ + fun toggleFavorite(label: String, target: PlayTarget, coverFile: String? = null) { + val boxId = activeBox.value?.id ?: return + viewModelScope.launch { + val existing = favorites.matching(boxId, target) + if (existing != null) { + favorites.remove(existing.id) + messageChannel.emit(UiMessage(R.string.favourites_removed)) + return@launch + } + favorites.add(Favorite.of(boxId, label, target, coverFile)) + messageChannel.emit(UiMessage(R.string.favourites_added)) + } + } + + private suspend fun loadLevelOnce(boxId: String, path: String) { + if (!visitedLevels.add("$boxId|$path")) return + folderRefreshing.value = true + library.refreshFolder(boxId, path) + // Say so rather than showing an empty folder and leaving the user to guess + // whether the box has nothing in it or the request failed. + .onFailure { + visitedLevels.remove("$boxId|$path") + messageChannel.emit(UiMessage(R.string.error_library_load_failed)) + } + folderRefreshing.value = false + } + + private fun favoriteKey(favorite: Favorite): String? = + favorite.toPlayTarget()?.let { keyFor(it) } + + private fun keyFor(target: PlayTarget): String = when (target) { + is PlayTarget.Folder -> "folder:${target.path}" + is PlayTarget.Album -> "album:${target.albumArtist}/${target.album}" + is PlayTarget.Track -> "track:${target.url}" + } + + private companion object { + /** Short enough to feel live while typing, long enough to skip intermediate words. */ + const val SEARCH_DEBOUNCE_MILLIS = 200L + } +} diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/player/PlayerScreen.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/player/PlayerScreen.kt new file mode 100644 index 0000000..e3d1f00 --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/player/PlayerScreen.kt @@ -0,0 +1,810 @@ +package app.coilforphoniebox.ui.player + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.VolumeMute +import androidx.compose.material.icons.automirrored.rounded.VolumeUp +import androidx.compose.material.icons.rounded.Bedtime +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Repeat +import androidx.compose.material.icons.rounded.RepeatOne +import androidx.compose.material.icons.rounded.Shuffle +import androidx.compose.material.icons.rounded.SkipNext +import androidx.compose.material.icons.rounded.SkipPrevious +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.StarBorder +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material3.AssistChip +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.coilforphoniebox.R +import app.coilforphoniebox.domain.model.PlaybackState +import app.coilforphoniebox.domain.model.RepeatMode +import app.coilforphoniebox.ui.components.CoverArt +import app.coilforphoniebox.ui.components.FavoriteMenuItem +import app.coilforphoniebox.ui.components.formatDuration +import app.coilforphoniebox.ui.components.formatNumber + +/** + * The start screen: cover, title, progress, transport, volume. + * + * Primary carries the transport controls, tertiary marks the favourite star — the warm + * counterpoint keeps an all-green interface from becoming fatiguing (§10.7). + */ +@Composable +fun PlayerScreen( + viewModel: PlayerViewModel, + modifier: Modifier = Modifier, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val scrub by viewModel.scrubPosition.collectAsStateWithLifecycle() + val volumeTarget by viewModel.volumeTarget.collectAsStateWithLifecycle() + val timerRemaining by viewModel.sleepTimerRemaining.collectAsStateWithLifecycle() + var timerSheetOpen by remember { mutableStateOf(false) } + + if (timerSheetOpen) { + SleepTimerSheet( + remainingSeconds = timerRemaining, + onPick = { minutes -> + timerSheetOpen = false + viewModel.startSleepTimer(minutes) + }, + onCancel = { + timerSheetOpen = false + viewModel.cancelSleepTimer() + }, + onDismiss = { timerSheetOpen = false }, + ) + } + + // One RPC on opening, so the sheet shows the truth even if the timer was set from the + // box's own web UI before Coil connected. + val openTimer = { + viewModel.refreshSleepTimer() + timerSheetOpen = true + } + + BoxWithConstraints(modifier.fillMaxSize()) { + // Two panes on a large screen, and also on any window that is merely wider than it is + // tall: a phone on its side has width to spare and no height at all, which is the same + // problem a tablet has and wants the same answer. A 7-inch tablet held upright stays in + // one column, where two panes would only be two cramped ones. + val wide = maxWidth >= WIDE_WIDTH || (maxWidth >= MEDIUM_WIDTH && maxWidth > maxHeight) + + if (wide) { + WidePlayer( + // Bounded by the height as well as the width: on a landscape tablet the + // limiting dimension is the short one, and a square that ignores it is exactly + // how this screen used to push its own controls off the bottom. + coverSize = minOf(maxWidth * WIDE_COVER_FRACTION, maxHeight - WIDE_COVER_INSET), + state = state, + scrub = scrub, + volumeTarget = volumeTarget, + timerRemaining = timerRemaining, + viewModel = viewModel, + onOpenTimer = openTimer, + ) + } else { + CompactPlayer( + // The width still decides on a phone. The height only takes over when the + // window is short — a phone in landscape, or a small floating window — where a + // full-width cover would leave no room for anything else. + coverSize = minOf( + maxWidth - COMPACT_PADDING * 2, + maxHeight * COMPACT_COVER_FRACTION, + ), + state = state, + scrub = scrub, + volumeTarget = volumeTarget, + timerRemaining = timerRemaining, + viewModel = viewModel, + onOpenTimer = openTimer, + ) + } + } +} + +/** Phone-shaped: cover on top, everything else stacked under it. */ +@Composable +private fun CompactPlayer( + coverSize: Dp, + state: PlayerViewModel.State, + scrub: Float?, + volumeTarget: Int?, + timerRemaining: Int?, + viewModel: PlayerViewModel, + onOpenTimer: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = COMPACT_PADDING), + ) { + CoverArt( + url = state.coverUrl, + contentDescription = stringResource(R.string.player_cover_art), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .size(coverSize), + placeholderIconSize = 64.dp, + ) + + Spacer(Modifier.height(20.dp)) + + TitleBlock(state = state, style = MaterialTheme.typography.titleLarge, viewModel = viewModel) + + Spacer(Modifier.height(16.dp)) + + PlayerControls( + state = state, + scrub = scrub, + volumeTarget = volumeTarget, + timerRemaining = timerRemaining, + viewModel = viewModel, + onOpenTimer = onOpenTimer, + ) + + Spacer(Modifier.height(24.dp)) + } +} + +/** + * Tablet-shaped: cover beside the controls rather than above them. + * + * A family tablet is one of the places this app belongs — propped in a kitchen, tapped by + * whoever walks past — and there the single column was actively bad: the cover took the whole + * width, which on a landscape screen is more than the whole height, and the transport controls + * ended up below the fold on the one screen whose entire job is the transport controls. + */ +@Composable +private fun WidePlayer( + coverSize: Dp, + state: PlayerViewModel.State, + scrub: Float?, + volumeTarget: Int?, + timerRemaining: Int?, + viewModel: PlayerViewModel, + onOpenTimer: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 32.dp, vertical = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CoverArt( + url = state.coverUrl, + contentDescription = stringResource(R.string.player_cover_art), + modifier = Modifier.size(coverSize), + placeholderIconSize = 96.dp, + ) + + Spacer(Modifier.width(32.dp)) + + Column( + modifier = Modifier + .weight(1f) + // Scrollable in its own right, so a short window loses the bottom of the + // controls rather than clipping them out of reach. + .verticalScroll(rememberScrollState()), + ) { + // The title carries the pane, so it steps up a size — at this width `titleLarge` + // reads as a caption rather than as the name of what is playing. + TitleBlock(state = state, style = MaterialTheme.typography.headlineSmall, viewModel = viewModel) + + Spacer(Modifier.height(24.dp)) + + PlayerControls( + state = state, + scrub = scrub, + volumeTarget = volumeTarget, + timerRemaining = timerRemaining, + viewModel = viewModel, + onOpenTimer = onOpenTimer, + ) + } + } +} + +/** Title, artist and album, with the favourite star beside them. */ +@Composable +private fun TitleBlock( + state: PlayerViewModel.State, + style: TextStyle, + viewModel: PlayerViewModel, +) { + Row(verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { + Text( + text = state.status.title + ?: if (state.status.hasContent) { + stringResource(R.string.player_no_title) + } else { + stringResource(R.string.player_nothing_playing) + }, + style = style, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + val subtitle = listOfNotNull(state.status.artist, state.status.album) + .distinct() + .joinToString(" · ") + .ifBlank { null } + if (subtitle != null) { + Spacer(Modifier.height(4.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + + if (state.canFavourite) { + FavouriteControl( + folderSaved = state.currentFavorite != null, + trackSaved = state.currentTrackFavorite != null, + folderLabel = state.folderLabel.takeIf { state.canFavouriteFolder }, + trackLabel = state.trackLabel.takeIf { state.canFavouriteTrack }, + onToggleFolder = viewModel::toggleFolderFavorite, + onToggleTrack = viewModel::toggleTrackFavorite, + ) + } + } +} + +/** + * Progress, transport, the timer line and volume — identical in both layouts. + * + * Kept as one composable rather than repeated in each: these four belong together, and a + * control that appeared on a phone but not on a tablet would be a bug nobody notices for + * months. + */ +@Composable +private fun ColumnScope.PlayerControls( + state: PlayerViewModel.State, + scrub: Float?, + volumeTarget: Int?, + timerRemaining: Int?, + viewModel: PlayerViewModel, + onOpenTimer: () -> Unit, +) { + ProgressRow( + elapsedSeconds = scrub?.toDouble() ?: state.status.elapsedSeconds, + durationSeconds = state.status.durationSeconds, + onScrub = viewModel::onScrub, + onScrubFinished = viewModel::onScrubFinished, + ) + + Spacer(Modifier.height(8.dp)) + + TransportRow( + isPlaying = state.status.state == PlaybackState.PLAY, + shuffle = state.status.shuffle, + repeat = state.status.repeat, + timerRunning = state.sleepTimer.running, + anyOptionActive = state.anyOptionActive, + onToggle = viewModel::toggle, + onNext = viewModel::next, + onPrevious = viewModel::previous, + onShuffle = viewModel::toggleShuffle, + onRepeat = viewModel::cycleRepeat, + onOpenTimer = onOpenTimer, + ) + + // Only while a timer is running: a countdown to something stopping is worth a line of its + // own, and there is nothing to say when nothing is scheduled. + timerRemaining?.let { seconds -> + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource( + R.string.player_sleep_timer_remaining, + formatDuration(seconds.toDouble()), + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + + Spacer(Modifier.height(20.dp)) + + VolumeRow( + // The dragged value wins while a drag is in progress, so the slider follows the finger + // instead of the box's four-per-second reports. + level = volumeTarget ?: state.volume.level, + maxLevel = state.volume.maxLevel, + muted = state.volume.muted, + onChange = viewModel::onVolumeChange, + onChangeFinished = viewModel::onVolumeChangeFinished, + ) +} + +/** + * The favourite star. + * + * A tap saves the folder — the thing a listener usually means by "save this", and what a + * card on the box would hold. A long press opens the menu, where folder and track are two + * separate, named entries: from a playing song the two are genuinely different intentions, + * and a single star cannot say which one it means. + * + * The icon shows the state of what a *tap* does, which is the folder. What the track's own + * state is, the menu says. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun FavouriteControl( + folderSaved: Boolean, + trackSaved: Boolean, + folderLabel: String?, + trackLabel: String?, + onToggleFolder: () -> Unit, + onToggleTrack: () -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + + Box { + Icon( + imageVector = if (folderSaved) Icons.Rounded.Star else Icons.Rounded.StarBorder, + contentDescription = stringResource( + if (folderSaved) R.string.action_favourite_remove_folder + else R.string.action_favourite_add_folder, + ), + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier + .clip(CircleShape) + .combinedClickable( + role = Role.Button, + // Web radio has no folder to save, so there the tap goes straight to + // the menu rather than doing nothing. + onClick = { if (folderLabel != null) onToggleFolder() else menuOpen = true }, + onLongClick = { menuOpen = true }, + onLongClickLabel = stringResource(R.string.action_favourite_options), + ) + // 24 dp icon in 12 dp of padding: the 48 dp touch target an IconButton + // would have given, kept while swapping in the long press. + .padding(12.dp) + .size(24.dp), + ) + + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + if (folderLabel != null) { + FavoriteMenuItem( + text = stringResource( + if (folderSaved) R.string.action_favourite_remove_folder + else R.string.action_favourite_add_folder, + ), + detail = folderLabel, + saved = folderSaved, + onClick = { + menuOpen = false + onToggleFolder() + }, + ) + } + if (trackLabel != null) { + FavoriteMenuItem( + text = stringResource( + if (trackSaved) R.string.action_favourite_remove_track + else R.string.action_favourite_add_track, + ), + detail = trackLabel, + saved = trackSaved, + onClick = { + menuOpen = false + onToggleTrack() + }, + ) + } + } + } +} + +@Composable +private fun ProgressRow( + elapsedSeconds: Double?, + durationSeconds: Double?, + onScrub: (Float) -> Unit, + onScrubFinished: () -> Unit, +) { + // Web radio reports no duration at all, in which case there is nothing to seek in. + val duration = durationSeconds?.takeIf { it > 0 } + val elapsed = (elapsedSeconds ?: 0.0).coerceIn(0.0, duration ?: 0.0) + + Column { + Slider( + value = elapsed.toFloat(), + onValueChange = onScrub, + onValueChangeFinished = onScrubFinished, + valueRange = 0f..(duration?.toFloat() ?: 1f), + enabled = duration != null, + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = formatDuration(elapsed), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatDuration(duration), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * Previous, play and next, with everything else behind one button. + * + * Shuffle, repeat and the sleep timer used to be — and, for a timer, would have been — + * separate icons flanking the transport controls. Three modes competing with the play button + * is more than this screen can carry, so they live in a menu whose button is tinted when any + * of them is on. The empty slot at the end keeps the play button centred. + */ +@Composable +private fun TransportRow( + isPlaying: Boolean, + shuffle: Boolean, + repeat: RepeatMode, + timerRunning: Boolean, + anyOptionActive: Boolean, + onToggle: () -> Unit, + onNext: () -> Unit, + onPrevious: () -> Unit, + onShuffle: () -> Unit, + onRepeat: () -> Unit, + onOpenTimer: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + PlaybackOptionsMenu( + shuffle = shuffle, + repeat = repeat, + timerRunning = timerRunning, + anyOptionActive = anyOptionActive, + onShuffle = onShuffle, + onRepeat = onRepeat, + onOpenTimer = onOpenTimer, + ) + + IconButton(onClick = onPrevious) { + Icon( + imageVector = Icons.Rounded.SkipPrevious, + contentDescription = stringResource(R.string.action_previous), + modifier = Modifier.size(32.dp), + ) + } + + Surface( + color = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + IconButton(onClick = onToggle, modifier = Modifier.size(64.dp)) { + Icon( + imageVector = if (isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow, + contentDescription = stringResource( + if (isPlaying) R.string.action_pause else R.string.action_play, + ), + modifier = Modifier.size(32.dp), + ) + } + } + + IconButton(onClick = onNext) { + Icon( + imageVector = Icons.Rounded.SkipNext, + contentDescription = stringResource(R.string.action_next), + modifier = Modifier.size(32.dp), + ) + } + + // Balances the options button on the other side; an IconButton's own footprint. + Spacer(Modifier.size(48.dp)) + } +} + +/** + * Shuffle, repeat and the sleep timer in one menu. + * + * Shuffle and repeat leave the menu open, because seeing the state change *is* the feedback + * for a toggle. The timer opens a sheet instead, so it closes. + */ +@Composable +private fun PlaybackOptionsMenu( + shuffle: Boolean, + repeat: RepeatMode, + timerRunning: Boolean, + anyOptionActive: Boolean, + onShuffle: () -> Unit, + onRepeat: () -> Unit, + onOpenTimer: () -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + + Box { + IconButton( + onClick = { menuOpen = true }, + colors = IconButtonDefaults.iconButtonColors( + contentColor = if (anyOptionActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ), + ) { + Icon( + imageVector = Icons.Rounded.Tune, + contentDescription = stringResource(R.string.action_playback_options), + ) + } + + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + OptionMenuItem( + text = stringResource(R.string.action_shuffle), + icon = Icons.Rounded.Shuffle, + active = shuffle, + onClick = onShuffle, + ) + OptionMenuItem( + text = stringResource( + when (repeat) { + RepeatMode.OFF -> R.string.action_repeat + RepeatMode.ALL -> R.string.action_repeat_all + RepeatMode.ONE -> R.string.action_repeat_one + }, + ), + icon = if (repeat == RepeatMode.ONE) { + Icons.Rounded.RepeatOne + } else { + Icons.Rounded.Repeat + }, + active = repeat != RepeatMode.OFF, + onClick = onRepeat, + ) + OptionMenuItem( + text = stringResource( + if (timerRunning) R.string.action_sleep_timer_on + else R.string.action_sleep_timer, + ), + icon = Icons.Rounded.Bedtime, + active = timerRunning, + onClick = { + menuOpen = false + onOpenTimer() + }, + ) + } + } +} + +/** A menu row whose icon and colour carry whether the option is currently on. */ +@Composable +private fun OptionMenuItem( + text: String, + icon: ImageVector, + active: Boolean, + onClick: () -> Unit, +) { + val colour = if (active) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + DropdownMenuItem( + text = { Text(text = text, color = if (active) colour else Color.Unspecified) }, + leadingIcon = { Icon(imageVector = icon, contentDescription = null, tint = colour) }, + onClick = onClick, + ) +} + +/** + * Durations for the timer that stops playback. + * + * It says out loud that the box stays on. The Phoniebox also has shutdown timers, and someone + * setting a bedtime timer deserves to know which of the two this is — Coil never switches the + * box off (§16). + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun SleepTimerSheet( + remainingSeconds: Int?, + onPick: (Int) -> Unit, + onCancel: () -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + Modifier + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + ) { + Text( + text = stringResource(R.string.player_sleep_timer_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.player_sleep_timer_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (remainingSeconds != null) { + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource( + R.string.player_sleep_timer_remaining, + formatDuration(remainingSeconds.toDouble()), + ), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(Modifier.height(20.dp)) + + // Wraps rather than scrolls: a row of durations that runs off the edge hides the + // longest one, and German labels are the widest of the five locales (§12.3). + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TIMER_PRESETS.forEach { minutes -> + AssistChip( + onClick = { onPick(minutes) }, + label = { + Text( + pluralStringResource( + R.plurals.duration_minutes, + minutes, + formatNumber(minutes), + ), + ) + }, + ) + } + } + + if (remainingSeconds != null) { + Spacer(Modifier.height(16.dp)) + OutlinedButton(onClick = onCancel) { + Text(stringResource(R.string.action_sleep_timer_cancel)) + } + } + } + } +} + +/** Bedtime-shaped: fifteen minutes to two hours, which is one audiobook either way. */ +private val TIMER_PRESETS = listOf(15, 30, 45, 60, 90, 120) + +/** + * Where the player stops stacking and starts sitting side by side. + * + * Material's "expanded" width class. A 7-inch tablet in portrait (600 dp) stays in one column, + * where two panes would be two cramped ones; a 10-inch in landscape (1280 dp) splits. + */ +private val WIDE_WIDTH = 840.dp + +/** + * The narrowest window that may still split, when it is wider than it is tall. + * + * Material's "medium" width class. Below this there is not enough room for two of anything. + */ +private val MEDIUM_WIDTH = 600.dp + +/** How much of a wide window the cover may claim, leaving the rest for the controls. */ +private const val WIDE_COVER_FRACTION = 0.42f + +/** Breathing room above and below the cover in the wide layout. */ +private val WIDE_COVER_INSET = 48.dp + +private val COMPACT_PADDING = 20.dp + +/** The most of a short window's height the cover may eat before the controls suffer. */ +private const val COMPACT_COVER_FRACTION = 0.55f + +@Composable +private fun VolumeRow( + level: Int, + maxLevel: Int, + muted: Boolean, + onChange: (Int) -> Unit, + onChangeFinished: () -> Unit, +) { + val percent = if (maxLevel <= 0) 0 else (level * 100) / maxLevel + val spokenVolume = stringResource(R.string.volume_percent, percent) + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + // The volume icons are the mirrored variants: no right-to-left locale is + // planned, but not breaking one is nearly free (§12.3). + imageVector = if (muted || level == 0) { + Icons.AutoMirrored.Rounded.VolumeMute + } else { + Icons.AutoMirrored.Rounded.VolumeUp + }, + contentDescription = stringResource(R.string.action_volume), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.size(12.dp)) + Slider( + value = level.toFloat(), + onValueChange = { onChange(it.toInt()) }, + onValueChangeFinished = onChangeFinished, + valueRange = 0f..maxLevel.coerceAtLeast(1).toFloat(), + modifier = Modifier + .weight(1f) + .semantics { contentDescription = spokenVolume }, + ) + Spacer(Modifier.size(12.dp)) + Text( + text = formatNumber(level), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.widthIn(min = 28.dp), + ) + } +} diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/player/PlayerViewModel.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/player/PlayerViewModel.kt new file mode 100644 index 0000000..6f11ab9 --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/player/PlayerViewModel.kt @@ -0,0 +1,344 @@ +package app.coilforphoniebox.ui.player + +import android.os.SystemClock +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.coilforphoniebox.R +import app.coilforphoniebox.domain.model.Box +import app.coilforphoniebox.domain.model.ConnectionState +import app.coilforphoniebox.domain.model.Favorite +import app.coilforphoniebox.domain.model.FavoriteType +import app.coilforphoniebox.domain.model.PlayTarget +import app.coilforphoniebox.domain.model.PlayerStatus +import app.coilforphoniebox.domain.model.RepeatMode +import app.coilforphoniebox.domain.model.SleepTimerStatus +import app.coilforphoniebox.domain.model.VolumeStatus +import app.coilforphoniebox.domain.repository.BoxRepository +import app.coilforphoniebox.domain.repository.FavoriteRepository +import app.coilforphoniebox.domain.repository.PlayerRepository +import app.coilforphoniebox.ui.UiMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@HiltViewModel +class PlayerViewModel @Inject constructor( + private val player: PlayerRepository, + private val boxes: BoxRepository, + private val favorites: FavoriteRepository, +) : ViewModel() { + + data class State( + val status: PlayerStatus = PlayerStatus.Idle, + val volume: VolumeStatus = VolumeStatus.Unknown, + val connection: ConnectionState = ConnectionState.DISCONNECTED, + val coverUrl: String? = null, + val activeBox: Box? = null, + val boxCount: Int = 0, + /** The folder favourite matching what is playing, if there is one. */ + val currentFavorite: Favorite? = null, + /** The favourite for the playing file itself, if there is one. */ + val currentTrackFavorite: Favorite? = null, + val sleepTimer: SleepTimerStatus = SleepTimerStatus.Off, + ) { + /** Whether any playback option is on, so the menu's button can say so at a glance. */ + val anyOptionActive: Boolean + get() = status.shuffle || status.repeat != RepeatMode.OFF || sleepTimer.running + + val canFavourite: Boolean get() = activeBox != null && (status.folder != null || status.file != null) + + /** The folder is the star's single-tap target, so it needs its own condition. */ + val canFavouriteFolder: Boolean get() = activeBox != null && status.folder != null + + val canFavouriteTrack: Boolean get() = activeBox != null && status.file != null + + /** Folder name as shown in the menu, so it is clear what a folder star saves. */ + val folderLabel: String? get() = status.folder?.let { folderLabelFor(it) } + + /** Track name as shown in the menu; the tags first, the file name as a fallback. */ + val trackLabel: String? get() = status.file?.let { file -> + status.title?.takeIf { it.isNotBlank() } ?: file.substringAfterLast('/') + } + } + + private val messageChannel = MutableSharedFlow( + extraBufferCapacity = 4, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val messages: SharedFlow = messageChannel + + /** + * Recomputed only when the fields shown here change. The box publishes four status + * messages a second whether or not anything moved, so narrowing before combining + * keeps recomposition off the idle path (§4.2.3). + */ + private val favoritesForCurrentContent: Flow> = combine( + boxes.activeBox.map { it?.id }.distinctUntilChanged(), + player.status.map { it.folder to it.file }.distinctUntilChanged(), + ) { boxId, content -> boxId to content } + .flatMapLatest { (boxId, content) -> + val (folder, file) = content + if (boxId == null || (folder == null && file == null)) { + flowOf(null to null) + } else { + favorites.favorites(boxId).map { list -> + val folderFavorite = folder?.let { path -> + list.firstOrNull { it.type == FavoriteType.FOLDER && it.folder == path } + } + val trackFavorite = file?.let { url -> + list.firstOrNull { it.type == FavoriteType.TRACK && it.trackUrl == url } + } + folderFavorite to trackFavorite + } + } + } + + /** The box-side inputs, bundled so the outer [combine] stays within five sources. */ + private data class BoxInfo( + val box: Box? = null, + val boxCount: Int = 0, + val folderFavorite: Favorite? = null, + val trackFavorite: Favorite? = null, + val sleepTimer: SleepTimerStatus = SleepTimerStatus.Off, + ) + + val state: StateFlow = combine( + player.status, + player.volume, + player.connectionState, + player.coverUrl, + combine( + boxes.activeBox, + boxes.boxes, + favoritesForCurrentContent, + player.sleepTimer, + ) { box, all, favorites, timer -> + BoxInfo(box, all.size, favorites.first, favorites.second, timer) + }, + ) { status, volume, connection, coverUrl, boxInfo -> + State( + status = status, + volume = volume, + connection = connection, + coverUrl = coverUrl, + activeBox = boxInfo.box, + boxCount = boxInfo.boxCount, + currentFavorite = boxInfo.folderFavorite, + currentTrackFavorite = boxInfo.trackFavorite, + sleepTimer = boxInfo.sleepTimer, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), State()) + + /** + * Seconds left on the timer, recomputed once a second while one is running. + * + * The box publishes its timer state only when it changes, so the countdown has to be + * local — and it only ticks while there is something to count, which keeps an idle + * player from recomposing every second for nothing. + */ + val sleepTimerRemaining: StateFlow = state + .map { it.sleepTimer } + .distinctUntilChanged() + .flatMapLatest { timer -> + if (!timer.running) { + flowOf(null) + } else { + flow { + while (true) { + val left = timer.remainingSecondsAt(SystemClock.elapsedRealtime()) + emit(left) + if (left <= 0) break + delay(TIMER_TICK_MILLIS) + } + } + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + /** Set while the user drags the progress bar, so the 4 Hz updates do not fight it. */ + private val _scrubPosition = MutableStateFlow(null) + val scrubPosition: StateFlow = _scrubPosition + + fun onScrub(seconds: Float) { + _scrubPosition.value = seconds + } + + fun onScrubFinished() { + val target = _scrubPosition.value ?: return + run(clearScrub = true) { player.seekTo(target.toDouble()) } + } + + fun toggle() = run { player.toggle() } + + fun next() = run { player.next() } + + fun previous() = run { player.previous() } + + /** + * Volume the user is currently dragging towards, or null when the slider is simply + * showing what the box reports. Same idea as [scrubPosition]: the 4 Hz status stream + * must not fight the finger. + */ + private val _volumeTarget = MutableStateFlow(null) + val volumeTarget: StateFlow = _volumeTarget + + init { + // A slider drag produces a value per frame; sending each one would put dozens of + // commands on the socket the box also uses for card detection (§6). Waiting for + // the drag to settle sends one — and still gives audible feedback mid-drag, + // because a pause of a few frames is enough to trigger it. + viewModelScope.launch { + _volumeTarget + .filterNotNull() + .debounce(VOLUME_SETTLE_MILLIS) + .distinctUntilChanged() + .collect { level -> send(level) } + } + } + + fun onVolumeChange(level: Int) { + _volumeTarget.value = level + } + + /** Lifting the finger sends immediately rather than waiting out the debounce. */ + fun onVolumeChangeFinished() { + val target = _volumeTarget.value ?: return + viewModelScope.launch { + send(target) + // Hold the local value briefly so the slider does not jump back to a status + // message that was published before the box applied the change. + delay(VOLUME_RELEASE_MILLIS) + if (_volumeTarget.value == target) _volumeTarget.value = null + } + } + + private suspend fun send(level: Int) { + player.setVolume(level).onFailure { messageChannel.emit(UiMessage(commandError())) } + } + + fun toggleShuffle() = run { player.setShuffle(!state.value.status.shuffle) } + + /** off → all → one → off, matching what the box itself cycles through. */ + fun cycleRepeat() = run { + val next = when (state.value.status.repeat) { + RepeatMode.OFF -> RepeatMode.ALL + RepeatMode.ALL -> RepeatMode.ONE + RepeatMode.ONE -> RepeatMode.OFF + } + player.setRepeat(next) + } + + /** + * Asks the box what its timer is doing. Called when the timer sheet opens: the state is + * published on change, but a box that has not been asked for a timer since booting has + * nothing in the last-value cache to hand over. + */ + fun refreshSleepTimer() { + viewModelScope.launch { player.refreshSleepTimer() } + } + + /** Stops playback after [minutes]. The box is left running — Coil never switches it off. */ + fun startSleepTimer(minutes: Int) = run { player.startSleepTimer(minutes) } + + fun cancelSleepTimer() = run { player.cancelSleepTimer() } + + /** + * Saves the folder the playing song sits in, or removes it again. This is what the + * star's single tap does; the long press menu offers the track as well, because from + * a playing song alone "save this" is ambiguous between the two. + * + * The label is the folder's own name — content from the box, so it is used exactly as + * it comes (§12.4). + */ + fun toggleFolderFavorite() { + val current = state.value + val boxId = current.activeBox?.id ?: return + val folder = current.status.folder ?: return + + viewModelScope.launch { + val existing = current.currentFavorite + if (existing != null) { + favorites.remove(existing.id) + messageChannel.emit(UiMessage(R.string.favourites_removed)) + return@launch + } + + favorites.add(Favorite.of(boxId, folderLabelFor(folder), PlayTarget.Folder(folder))) + messageChannel.emit(UiMessage(R.string.favourites_added)) + } + } + + /** Saves the playing file itself, or removes it again. */ + fun toggleTrackFavorite() { + val current = state.value + val boxId = current.activeBox?.id ?: return + val file = current.status.file ?: return + val label = current.trackLabel ?: return + + viewModelScope.launch { + val existing = current.currentTrackFavorite + if (existing != null) { + favorites.remove(existing.id) + messageChannel.emit(UiMessage(R.string.favourites_removed)) + return@launch + } + + favorites.add(Favorite.of(boxId, label, PlayTarget.Track(file))) + messageChannel.emit(UiMessage(R.string.favourites_added)) + } + } + + private fun run(clearScrub: Boolean = false, command: suspend () -> Result) { + viewModelScope.launch { + val result = command() + if (clearScrub) _scrubPosition.value = null + result.onFailure { messageChannel.emit(UiMessage(commandError())) } + } + } + + /** + * A command that failed while the connection looked fine is a real failure; one that + * failed with no connection is just the box being off, which is a normal state. + */ + private fun commandError(): Int = + if (state.value.connection.isUsable) R.string.error_command_failed + else R.string.error_not_connected + + private companion object { + /** Long enough to swallow a drag, short enough to feel like a live control. */ + const val VOLUME_SETTLE_MILLIS = 150L + + /** Two status publishes at 4 Hz, so the box has certainly reported back. */ + const val VOLUME_RELEASE_MILLIS = 600L + + /** The countdown is shown to the minute, but a second's resolution reads as alive. */ + const val TIMER_TICK_MILLIS = 1_000L + } +} + +/** + * Last segment of a folder path, which is the name the user recognises. A path with no + * separator in it is already the name. + */ +private fun folderLabelFor(folder: String): String = + folder.substringAfterLast('/').ifBlank { folder } diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..c3fd003 --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/settings/SettingsScreen.kt @@ -0,0 +1,523 @@ +package app.coilforphoniebox.ui.settings + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.coilforphoniebox.BuildConfig +import app.coilforphoniebox.R +import app.coilforphoniebox.domain.model.SessionMode +import app.coilforphoniebox.domain.model.ThemeMode +import app.coilforphoniebox.ui.components.formatNumber +import androidx.compose.foundation.text.KeyboardOptions +import kotlinx.coroutines.launch + +/** + * Split into global settings and settings for the active box, which is the same split the + * data model makes (§7.2). + */ +@Composable +fun SettingsScreen( + viewModel: SettingsViewModel, + onAddBox: () -> Unit, + modifier: Modifier = Modifier, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var showRemoveDialog by remember { mutableStateOf(false) } + + val exportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json"), + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch { + val content = viewModel.exportSettings() + runCatching { + context.contentResolver.openOutputStream(uri)?.use { it.write(content.toByteArray()) } + }.onSuccess { viewModel.onExported() } + } + } + + val importLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument(), + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + val content = runCatching { + context.contentResolver.openInputStream(uri)?.use { it.readBytes().decodeToString() } + }.getOrNull() + if (content != null) viewModel.importSettings(content) + } + + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + ) { + SectionHeader(stringResource(R.string.settings_section_appearance)) + + GroupLabel(stringResource(R.string.settings_theme)) + ThemeMode.entries.forEach { mode -> + RadioRow( + label = stringResource( + when (mode) { + ThemeMode.SYSTEM -> R.string.settings_theme_system + ThemeMode.LIGHT -> R.string.settings_theme_light + ThemeMode.DARK -> R.string.settings_theme_dark + }, + ), + selected = state.settings.themeMode == mode, + onSelect = { viewModel.setThemeMode(mode) }, + ) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + SwitchRow( + title = stringResource(R.string.settings_dynamic_colour), + subtitle = stringResource(R.string.settings_dynamic_colour_summary), + checked = state.settings.dynamicColor, + onCheckedChange = viewModel::setDynamicColor, + ) + } + + ActionRow( + title = stringResource(R.string.settings_language), + subtitle = stringResource(R.string.settings_language_summary), + onClick = { context.openAppLocaleSettings() }, + ) + + SectionDivider() + SectionHeader(stringResource(R.string.settings_section_notification)) + + SessionModeRows( + current = state.settings.sessionMode, + onSelect = viewModel::setSessionMode, + ) + + if (state.settings.sessionMode == SessionMode.AUTOMATIC) { + state.activeBox?.let { box -> + SwitchRow( + title = stringResource(R.string.settings_auto_session_box), + subtitle = box.displayName, + checked = box.autoSessionEnabled, + onCheckedChange = viewModel::setAutoSessionForActiveBox, + ) + } + + Text( + text = stringResource(R.string.settings_session_honest_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + + ActionRow( + title = stringResource(R.string.settings_battery_exemption), + subtitle = stringResource(R.string.settings_battery_exemption_summary), + onClick = { context.openBatterySettings() }, + ) + } + + SectionDivider() + // The heading follows what the section actually contains: with a second box the + // rows below are no longer about one box only. + SectionHeader( + stringResource( + if (state.boxes.size > 1) R.string.boxes_title else R.string.settings_section_box, + ), + ) + + val box = state.activeBox + if (box == null) { + Text( + text = stringResource(R.string.empty_no_box_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Button(onClick = onAddBox) { Text(stringResource(R.string.action_add_box)) } + } else { + // The same "exactly one box is active" choice the top bar's switcher offers, + // repeated here because this is where the boxes themselves are configured. + if (state.boxes.size > 1) { + GroupLabel(stringResource(R.string.boxes_switch)) + state.boxes.forEach { configured -> + RadioRow( + label = configured.displayName, + subtitle = configured.host, + selected = configured.id == box.id, + onSelect = { viewModel.selectBox(configured.id) }, + ) + } + Spacer(Modifier.height(8.dp)) + } + + // Always offered, and this is the only place it can live: the switcher in the + // top bar collapses to a plain indicator while there is a single box, so a + // second one was previously unreachable from anywhere in the app. + ActionRow( + title = stringResource(R.string.settings_add_box), + subtitle = stringResource(R.string.settings_add_box_summary), + onClick = onAddBox, + ) + + // Which box the fields below belong to needs saying once there is more than one. + if (state.boxes.size > 1) { + GroupLabel(stringResource(R.string.settings_section_box)) + } + + BoxFields( + initialName = box.displayName, + initialHost = box.host, + initialRpcPort = box.rpcPort, + initialPubPort = box.pubPort, + onSave = viewModel::updateActiveBox, + ) + + ActionRow( + title = stringResource(R.string.settings_rescan), + subtitle = stringResource(R.string.settings_rescan_summary), + onClick = viewModel::rescanLibrary, + ) + + // Says plainly what it costs. A crawl shares the socket the box uses for its card + // reader, so this is the user's decision to make, not something to slip in (§6). + val indexState by viewModel.indexState.collectAsStateWithLifecycle() + ActionRow( + title = stringResource( + if (indexState.running) R.string.settings_index_stop + else R.string.settings_index, + ), + subtitle = if (indexState.running) { + stringResource( + R.string.settings_index_progress, + formatNumber(indexState.foldersScanned), + ) + } else { + stringResource(R.string.settings_index_summary) + }, + onClick = { + if (indexState.running) viewModel.stopIndexing() else viewModel.indexLibrary() + }, + ) + + ActionRow( + title = stringResource(R.string.settings_remove_box), + onClick = { showRemoveDialog = true }, + destructive = true, + ) + } + + SectionDivider() + SectionHeader(stringResource(R.string.settings_section_data)) + + ActionRow( + title = stringResource(R.string.settings_export), + subtitle = stringResource(R.string.settings_export_summary), + onClick = { exportLauncher.launch(EXPORT_FILE_NAME) }, + ) + ActionRow( + title = stringResource(R.string.settings_import), + onClick = { importLauncher.launch(arrayOf("application/json", "text/plain")) }, + ) + + SectionDivider() + SectionHeader(stringResource(R.string.settings_section_about)) + + Text( + text = stringResource(R.string.settings_version, BuildConfig.VERSION_NAME), + style = MaterialTheme.typography.bodyMedium, + ) + state.boxVersion?.let { version -> + Text( + text = stringResource(R.string.settings_box_version, version), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.settings_security_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(32.dp)) + } + + if (showRemoveDialog && state.activeBox != null) { + val name = state.activeBox?.displayName.orEmpty() + AlertDialog( + onDismissRequest = { showRemoveDialog = false }, + title = { Text(stringResource(R.string.settings_remove_box)) }, + text = { Text(stringResource(R.string.settings_remove_box_confirm, name)) }, + confirmButton = { + TextButton( + onClick = { + showRemoveDialog = false + viewModel.removeActiveBox() + }, + ) { Text(stringResource(R.string.action_delete)) } + }, + dismissButton = { + TextButton(onClick = { showRemoveDialog = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} + +@Composable +private fun SessionModeRows(current: SessionMode, onSelect: (SessionMode) -> Unit) { + GroupLabel(stringResource(R.string.settings_session_mode)) + RadioRow( + label = stringResource(R.string.settings_session_mode_app_open), + subtitle = stringResource(R.string.settings_session_mode_app_open_summary), + selected = current == SessionMode.APP_ONLY, + onSelect = { onSelect(SessionMode.APP_ONLY) }, + ) + RadioRow( + label = stringResource(R.string.settings_session_mode_automatic), + subtitle = stringResource(R.string.settings_session_mode_automatic_summary), + selected = current == SessionMode.AUTOMATIC, + onSelect = { onSelect(SessionMode.AUTOMATIC) }, + ) +} + +@Composable +private fun BoxFields( + initialName: String, + initialHost: String, + initialRpcPort: Int, + initialPubPort: Int, + onSave: (host: String, rpcPort: Int, pubPort: Int, displayName: String) -> Unit, +) { + var name by remember(initialName) { mutableStateOf(initialName) } + var host by remember(initialHost) { mutableStateOf(initialHost) } + var rpcPort by remember(initialRpcPort) { mutableStateOf(initialRpcPort.toString()) } + var pubPort by remember(initialPubPort) { mutableStateOf(initialPubPort.toString()) } + + val changed = name != initialName || + host != initialHost || + rpcPort != initialRpcPort.toString() || + pubPort != initialPubPort.toString() + + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(R.string.field_display_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = host, + onValueChange = { host = it }, + label = { Text(stringResource(R.string.settings_box_address)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = rpcPort, + onValueChange = { rpcPort = it }, + label = { Text(stringResource(R.string.field_rpc_port)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = pubPort, + onValueChange = { pubPort = it }, + label = { Text(stringResource(R.string.field_pub_port)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) + } + Spacer(Modifier.height(8.dp)) + Button( + onClick = { + onSave( + host, + rpcPort.toIntOrNull() ?: initialRpcPort, + pubPort.toIntOrNull() ?: initialPubPort, + name, + ) + }, + enabled = changed, + ) { Text(stringResource(R.string.action_save)) } + Spacer(Modifier.height(8.dp)) +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp), + ) +} + +/** Label above a group of radio rows, so each group says what it is choosing. */ +@Composable +private fun GroupLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) +} + +@Composable +private fun SectionDivider() { + Spacer(Modifier.height(16.dp)) + HorizontalDivider() +} + +@Composable +private fun RadioRow( + label: String, + selected: Boolean, + onSelect: () -> Unit, + subtitle: String? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onSelect) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = onSelect) + Column(Modifier.padding(start = 4.dp)) { + Text(label, style = MaterialTheme.typography.bodyLarge) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun SwitchRow( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + subtitle: String? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +@Composable +private fun ActionRow( + title: String, + onClick: () -> Unit, + subtitle: String? = null, + destructive: Boolean = false, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + // Error is reserved for things that actually go wrong; removing a box is one + // of the few genuinely destructive actions in the app (§10.7). + color = if (destructive) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** Android 13 and later have a per-app language screen; earlier versions do not. */ +private fun Context.openAppLocaleSettings() { + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Intent(Settings.ACTION_APP_LOCALE_SETTINGS, Uri.fromParts("package", packageName, null)) + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", packageName, null)) + } + runCatching { startActivity(intent) } +} + +private fun Context.openBatterySettings() { + runCatching { startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) } +} + +private const val EXPORT_FILE_NAME = "coil-settings.json" diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..23e898a --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/settings/SettingsViewModel.kt @@ -0,0 +1,191 @@ +package app.coilforphoniebox.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.common.util.UnstableApi +import app.coilforphoniebox.R +import app.coilforphoniebox.domain.model.AppSettings +import app.coilforphoniebox.domain.model.Box +import app.coilforphoniebox.domain.model.LibraryIndexResult +import app.coilforphoniebox.domain.model.LibraryIndexState +import app.coilforphoniebox.domain.model.SessionMode +import app.coilforphoniebox.domain.model.ThemeMode +import app.coilforphoniebox.domain.repository.BackupRepository +import app.coilforphoniebox.domain.repository.BoxRepository +import app.coilforphoniebox.domain.repository.LibraryRepository +import app.coilforphoniebox.domain.repository.PlayerRepository +import app.coilforphoniebox.domain.repository.SettingsRepository +import app.coilforphoniebox.media.AutoSessionStarter +import app.coilforphoniebox.ui.UiMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +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 + +@androidx.annotation.OptIn(UnstableApi::class) +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val settings: SettingsRepository, + private val boxes: BoxRepository, + private val library: LibraryRepository, + private val player: PlayerRepository, + private val backup: BackupRepository, + private val autoSession: AutoSessionStarter, +) : ViewModel() { + + data class State( + val settings: AppSettings = AppSettings(), + val activeBox: Box? = null, + /** Every configured box, so this screen can switch between them and add one. */ + val boxes: List = emptyList(), + val boxVersion: String? = null, + ) + + private val messageChannel = MutableSharedFlow( + extraBufferCapacity = 4, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val messages: SharedFlow = messageChannel + + val state: StateFlow = combine( + settings.settings, + boxes.activeBox, + boxes.boxes, + player.boxVersion, + ) { appSettings, activeBox, allBoxes, version -> + State( + settings = appSettings, + activeBox = activeBox, + boxes = allBoxes, + boxVersion = version, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), State()) + + /** + * Makes another box the active one. The connection follows on its own — the transport + * watches the active box and rebuilds its sockets (§7.3) — and every per-box row on this + * screen then describes the box that was just picked. + */ + fun selectBox(boxId: String) { + if (boxId == state.value.activeBox?.id) return + viewModelScope.launch { boxes.setActive(boxId) } + } + + fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { settings.setThemeMode(mode) } + + fun setDynamicColor(enabled: Boolean) = + viewModelScope.launch { settings.setDynamicColor(enabled) } + + fun setSessionMode(mode: SessionMode) { + viewModelScope.launch { + settings.setSessionMode(mode) + // Switching to automatic should take effect now, not after the next reboot. + if (mode == SessionMode.AUTOMATIC) autoSession.startIfEnabled() + } + } + + fun setAutoSessionForActiveBox(enabled: Boolean) { + val box = state.value.activeBox ?: return + viewModelScope.launch { + boxes.update(box.copy(autoSessionEnabled = enabled)) + if (enabled) autoSession.startIfEnabled() + } + } + + /** Progress of the library crawl, so the row can show a count and a way to stop. */ + val indexState: StateFlow = library.indexState + + private var indexJob: Job? = null + + /** + * Walks the whole folder tree so that search covers folders nobody has opened. + * + * Started by hand and never on its own: it occupies the box's sequential RPC loop, which + * its card reader shares, so the user decides when that is acceptable (§6). Every outcome + * is reported — including a library too large for the crawl's cap, which leaves search + * genuinely incomplete and must not look like success. + */ + fun indexLibrary() { + val boxId = state.value.activeBox?.id ?: return + if (indexJob?.isActive == true) return + + indexJob = viewModelScope.launch { + when (val result = library.indexLibrary(boxId)) { + is LibraryIndexResult.Finished -> messageChannel.emit( + if (result.stoppedAtCap) { + UiMessage(R.string.settings_index_capped, result.foldersScanned.toString()) + } else { + UiMessage(R.string.settings_index_done, result.foldersScanned.toString()) + }, + ) + + LibraryIndexResult.BoxBusy -> + messageChannel.emit(UiMessage(R.string.settings_index_box_busy)) + + is LibraryIndexResult.Interrupted -> messageChannel.emit( + UiMessage(R.string.settings_index_interrupted, result.foldersScanned.toString()), + ) + } + } + } + + /** Stops the crawl. Whatever it reached is already cached and stays searchable. */ + fun stopIndexing() { + indexJob?.cancel() + indexJob = null + } + + /** + * Fires `player.ctrl.update` so the box rescans its own database. `update_wait` would + * block the box's RPC loop until it finished, which is why it stays unused (§6.4). + */ + fun rescanLibrary() { + val boxId = state.value.activeBox?.id ?: return + viewModelScope.launch { + library.rescanBoxLibrary(boxId) + .onSuccess { messageChannel.emit(UiMessage(R.string.settings_rescan_started)) } + .onFailure { messageChannel.emit(UiMessage(R.string.error_not_connected)) } + } + } + + fun removeActiveBox() { + val box = state.value.activeBox ?: return + viewModelScope.launch { boxes.delete(box.id) } + } + + fun updateActiveBox(host: String, rpcPort: Int, pubPort: Int, displayName: String) { + val box = state.value.activeBox ?: return + viewModelScope.launch { + boxes.update( + box.copy( + host = host.trim().ifBlank { box.host }, + rpcPort = rpcPort, + pubPort = pubPort, + displayName = displayName.trim().ifBlank { box.displayName }, + ), + ) + } + } + + /** Returns the JSON to write; the screen owns the file picker. */ + suspend fun exportSettings(): String = backup.export() + + fun importSettings(content: String) { + viewModelScope.launch { + backup.importFrom(content) + .onSuccess { messageChannel.emit(UiMessage(R.string.settings_import_done)) } + .onFailure { messageChannel.emit(UiMessage(R.string.settings_import_failed)) } + } + } + + fun onExported() { + viewModelScope.launch { messageChannel.emit(UiMessage(R.string.settings_export_done)) } + } +} diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/theme/Color.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/theme/Color.kt new file mode 100644 index 0000000..3f23b1b --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/theme/Color.kt @@ -0,0 +1,88 @@ +package app.coilforphoniebox.ui.theme + +import androidx.compose.ui.graphics.Color + +/** Brand colours: fixed, independent of theme. For logo, store assets and website. */ +object CoilBrand { + val Deep = Color(0xFF0F6B5C) // Primary colour, icon background + val Mint = Color(0xFF6DDBC3) // Primary in dark mode + val Ink = Color(0xFF14191B) // Text, lockup on light + val Bone = Color(0xFFE4E7E4) // Neutral background +} + +// ---------------------------------------------------------------- Light + +val md_light_primary = Color(0xFF0F6B5C) +val md_light_onPrimary = Color(0xFFFFFFFF) +val md_light_primaryContainer = Color(0xFFA8F2DF) +val md_light_onPrimaryContainer = Color(0xFF00201A) + +val md_light_secondary = Color(0xFF4A635C) +val md_light_onSecondary = Color(0xFFFFFFFF) +val md_light_secondaryContainer = Color(0xFFCCE8DF) +val md_light_onSecondaryContainer = Color(0xFF06201A) + +// Warm counterpoint: reserved for favourites and "currently playing". +val md_light_tertiary = Color(0xFF7A5900) +val md_light_onTertiary = Color(0xFFFFFFFF) +val md_light_tertiaryContainer = Color(0xFFFFDF9B) +val md_light_onTertiaryContainer = Color(0xFF261A00) + +val md_light_error = Color(0xFFBA1A1A) +val md_light_onError = Color(0xFFFFFFFF) +val md_light_errorContainer = Color(0xFFFFDAD6) +val md_light_onErrorContainer = Color(0xFF410002) + +val md_light_background = Color(0xFFF5FBF7) +val md_light_onBackground = Color(0xFF171D1B) +val md_light_surface = Color(0xFFF5FBF7) +val md_light_onSurface = Color(0xFF171D1B) +val md_light_surfaceVariant = Color(0xFFDBE5E0) +val md_light_onSurfaceVariant = Color(0xFF3F4945) +val md_light_outline = Color(0xFF6F7975) +val md_light_outlineVariant = Color(0xFFBFC9C4) + +val md_light_surfaceContainerLowest = Color(0xFFFFFFFF) +val md_light_surfaceContainerLow = Color(0xFFEFF5F1) +val md_light_surfaceContainer = Color(0xFFE9EFEB) +val md_light_surfaceContainerHigh = Color(0xFFE3E9E5) +val md_light_surfaceContainerHighest = Color(0xFFDDE4E0) + +// ---------------------------------------------------------------- Dark +// Deliberately very dark and slightly green tinted: the app is used at +// bedtime, often in a darkened child's bedroom. + +val md_dark_primary = Color(0xFF6DDBC3) +val md_dark_onPrimary = Color(0xFF003830) +val md_dark_primaryContainer = Color(0xFF005046) +val md_dark_onPrimaryContainer = Color(0xFF8AF8DF) + +val md_dark_secondary = Color(0xFFB1CCC3) +val md_dark_onSecondary = Color(0xFF1C352F) +val md_dark_secondaryContainer = Color(0xFF334B45) +val md_dark_onSecondaryContainer = Color(0xFFCCE8DF) + +val md_dark_tertiary = Color(0xFFF0C048) +val md_dark_onTertiary = Color(0xFF412D00) +val md_dark_tertiaryContainer = Color(0xFF5D4200) +val md_dark_onTertiaryContainer = Color(0xFFFFDF9B) + +val md_dark_error = Color(0xFFFFB4AB) +val md_dark_onError = Color(0xFF690005) +val md_dark_errorContainer = Color(0xFF93000A) +val md_dark_onErrorContainer = Color(0xFFFFDAD6) + +val md_dark_background = Color(0xFF0E1513) +val md_dark_onBackground = Color(0xFFDDE4E0) +val md_dark_surface = Color(0xFF0E1513) +val md_dark_onSurface = Color(0xFFDDE4E0) +val md_dark_surfaceVariant = Color(0xFF3F4945) +val md_dark_onSurfaceVariant = Color(0xFFBFC9C4) +val md_dark_outline = Color(0xFF899390) +val md_dark_outlineVariant = Color(0xFF3F4945) + +val md_dark_surfaceContainerLowest = Color(0xFF090F0E) +val md_dark_surfaceContainerLow = Color(0xFF141B19) +val md_dark_surfaceContainer = Color(0xFF1A2220) +val md_dark_surfaceContainerHigh = Color(0xFF242C2A) +val md_dark_surfaceContainerHighest = Color(0xFF2F3735) diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/theme/Theme.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/theme/Theme.kt new file mode 100644 index 0000000..6de8cfd --- /dev/null +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/theme/Theme.kt @@ -0,0 +1,96 @@ +package app.coilforphoniebox.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val LightColors = lightColorScheme( + primary = md_light_primary, + onPrimary = md_light_onPrimary, + primaryContainer = md_light_primaryContainer, + onPrimaryContainer = md_light_onPrimaryContainer, + secondary = md_light_secondary, + onSecondary = md_light_onSecondary, + secondaryContainer = md_light_secondaryContainer, + onSecondaryContainer = md_light_onSecondaryContainer, + tertiary = md_light_tertiary, + onTertiary = md_light_onTertiary, + tertiaryContainer = md_light_tertiaryContainer, + onTertiaryContainer = md_light_onTertiaryContainer, + error = md_light_error, + onError = md_light_onError, + errorContainer = md_light_errorContainer, + onErrorContainer = md_light_onErrorContainer, + background = md_light_background, + onBackground = md_light_onBackground, + surface = md_light_surface, + onSurface = md_light_onSurface, + surfaceVariant = md_light_surfaceVariant, + onSurfaceVariant = md_light_onSurfaceVariant, + outline = md_light_outline, + outlineVariant = md_light_outlineVariant, + surfaceContainerLowest = md_light_surfaceContainerLowest, + surfaceContainerLow = md_light_surfaceContainerLow, + surfaceContainer = md_light_surfaceContainer, + surfaceContainerHigh = md_light_surfaceContainerHigh, + surfaceContainerHighest = md_light_surfaceContainerHighest, +) + +private val DarkColors = darkColorScheme( + primary = md_dark_primary, + onPrimary = md_dark_onPrimary, + primaryContainer = md_dark_primaryContainer, + onPrimaryContainer = md_dark_onPrimaryContainer, + secondary = md_dark_secondary, + onSecondary = md_dark_onSecondary, + secondaryContainer = md_dark_secondaryContainer, + onSecondaryContainer = md_dark_onSecondaryContainer, + tertiary = md_dark_tertiary, + onTertiary = md_dark_onTertiary, + tertiaryContainer = md_dark_tertiaryContainer, + onTertiaryContainer = md_dark_onTertiaryContainer, + error = md_dark_error, + onError = md_dark_onError, + errorContainer = md_dark_errorContainer, + onErrorContainer = md_dark_onErrorContainer, + background = md_dark_background, + onBackground = md_dark_onBackground, + surface = md_dark_surface, + onSurface = md_dark_onSurface, + surfaceVariant = md_dark_surfaceVariant, + onSurfaceVariant = md_dark_onSurfaceVariant, + outline = md_dark_outline, + outlineVariant = md_dark_outlineVariant, + surfaceContainerLowest = md_dark_surfaceContainerLowest, + surfaceContainerLow = md_dark_surfaceContainerLow, + surfaceContainer = md_dark_surfaceContainer, + surfaceContainerHigh = md_dark_surfaceContainerHigh, + surfaceContainerHighest = md_dark_surfaceContainerHighest, +) + +/** + * Dynamic colour is deliberately OFF by default. + * The brand is new and needs recognition; the toggle lives in settings. + */ +@Composable +fun CoilTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = false, + content: @Composable () -> Unit, +) { + val context = LocalContext.current + val scheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + + darkTheme -> DarkColors + else -> LightColors + } + MaterialTheme(colorScheme = scheme, content = content) +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..874181e --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..4583935 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..7253eb8 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_stat_coil.xml b/app/src/main/res/drawable/ic_stat_coil.xml new file mode 100644 index 0000000..19ca068 --- /dev/null +++ b/app/src/main/res/drawable/ic_stat_coil.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..13b7496 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..13b7496 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..103ef4c --- /dev/null +++ b/app/src/main/res/values-de/strings.xml @@ -0,0 +1,164 @@ + + + + + Player + Bibliothek + Favoriten + Einstellungen + + Es läuft nichts + Unbekannter Titel + Cover + Abspielen + Pause + Vorheriger Titel + Nächster Titel + Zufallswiedergabe + Wiederholen + Einen Titel wiederholen + Lautstärke + Aus den Favoriten entfernen + Lautstärke %1$d%% + + Mit %1$s verbunden + Verbinde mit %1$s… + Verbinde erneut mit %1$s… + Nicht verbunden + + Ordner + Alben + Bibliothek + Dieser Ordner ist leer + Noch keine Alben. Wenn du welche erwartest, lass die Box ihre Bibliothek neu einlesen. + Unbekanntes Album + Unbekannte Interpretin oder Interpret + Aktualisiert %1$s + Noch nicht geladen + gerade eben + + vor %1$d Minute + vor %1$d Minuten + + + vor %1$d Stunde + vor %1$d Stunden + + + vor %1$d Tag + vor %1$d Tagen + + Ordner abspielen + Eine Ebene nach oben + Aktualisieren + + Noch keine Favoriten + Halte in der Bibliothek einen Ordner oder ein Album gedrückt, um es hier zu speichern. + Als Favorit gespeichert + Favorit entfernt + Zum Startbildschirm hinzufügen + Entfernen + Nach oben + Nach unten + Dieser Launcher unterstützt keine Verknüpfungen auf dem Startbildschirm. + + Boxen + Box wechseln + Box hinzufügen + Aktiv + + Die Box und dieses Telefon müssen im gleichen Netzwerk sein. Coil spricht direkt mit der Box; nichts läuft über das Internet. + Im Netzwerk gefunden + Suche… + Automatisch nichts gefunden. Gib die Adresse von Hand ein. + Adresse eingeben + Hostname oder IP-Adresse + Name + Wohnzimmer + Befehlsport + Statusport + Verbindung testen + Speichern + Abbrechen + Löschen + Test läuft… + Box erreicht. Phoniebox %1$s. + Box erreicht. + + Keine Antwort von der Box. + Prüfe, ob die Box eingeschaltet und im gleichen Netzwerk ist. Manche Router und Gastnetzwerke verhindern, dass Geräte sich gegenseitig sehen — diese Einstellung heißt meist Client-Isolation oder AP-Isolation. + Gib einen Hostnamen oder eine IP-Adresse ein. + Gib einen Port zwischen 1 und 65535 ein. + Die Box hat das nicht angenommen. + Keine Verbindung zur Box. + Die Bibliothek konnte nicht geladen werden. + + Gespeicherte Daten. Die Box ist nicht erreichbar. + Füge deine Phoniebox hinzu, um zu beginnen. + + Darstellung + Erscheinungsbild + Wie im System + Hell + Dunkel + Farben des Hintergrundbilds + Ersetzt das Grün von Coil durch Farben aus deinem Hintergrundbild. + Sprache + Öffnet die Spracheinstellungen des Systems für Coil. + + Diese Box + Adresse + Bibliothek auf der Box neu einlesen + Die Box sucht nach neuen Dateien. Bei einer großen Bibliothek kann das dauern. + Die Box liest ihre Bibliothek neu ein. + Diese Box entfernen + %1$s entfernen? Favoriten und zwischengespeicherte Bibliothek werden mit entfernt. Die Box selbst bleibt unangetastet. + + Steuerung auf dem Sperrbildschirm + Steuerung anzeigen + Nur solange Coil offen ist + Keine Verbindung im Hintergrund, kein Akkuverbrauch. + Immer wenn die Box spielt + Hält im WLAN eine Verbindung zur Box, damit die Steuerung von selbst erscheint. + Android kann eine Verbindung im Hintergrund trotzdem trennen, um Strom zu sparen, und manche Telefone sind strenger als andere. Dann erscheint die Steuerung erst verzögert oder bleibt aus, bis du Coil öffnest. + Coil im Hintergrund erlauben + Öffnet die Systemeinstellung, mit der Android Coil nicht schlafen legt. + Automatische Steuerung für diese Box + + Daten + Einstellungen exportieren + Speichert Boxen und Favoriten in einer Datei. Sinnvoll, bevor du zwischen Play-Store- und GitHub-Version wechselst. + Einstellungen importieren + Einstellungen exportiert. + Einstellungen importiert. + Diese Datei konnte nicht gelesen werden. + + Über + Coil %1$s + Phoniebox %1$s + Coil ist für dein Heimnetzwerk gedacht. Die Befehlsschnittstelle der Box hat kein Passwort — leite diese Ports nicht durch deinen Router. Nutze ein VPN, wenn du von außerhalb zugreifen willst. + + Coil auf deine Box richten + + Wiedergabe + Steuerung für die Phoniebox, die gerade spielt. + Verbindung + Leiser Hinweis, solange Coil eine Verbindung zur Box hält. + Bereit + Mit %1$s verbunden + + Die Box war nicht erreichbar. + %1$s startet auf %2$s + + Weitere Optionen + + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..812525d --- /dev/null +++ b/app/src/main/res/values-es/strings.xml @@ -0,0 +1,161 @@ + + + + + Reproductor + Biblioteca + Favoritos + Ajustes + + No se está reproduciendo nada + Título desconocido + Portada + Reproducir + Pausa + Pista anterior + Pista siguiente + Aleatorio + Repetir + Repetir una pista + Volumen + Quitar de favoritos + Volumen %1$d%% + + Conectado a %1$s + Conectando con %1$s… + Reconectando con %1$s… + Sin conexión + + Carpetas + Álbumes + Biblioteca + Esta carpeta está vacía + Todavía no hay álbumes. Si esperabas alguno, pide a la caja que vuelva a leer su biblioteca. + Álbum desconocido + Artista desconocido + Actualizado %1$s + Aún no cargado + ahora mismo + + hace %1$d minuto + hace %1$d minutos + + + hace %1$d hora + hace %1$d horas + + + hace %1$d día + hace %1$d días + + Reproducir carpeta + Subir un nivel + Actualizar + + Aún no hay favoritos + Mantén pulsada una carpeta o un álbum en la biblioteca para guardarlo aquí. + Guardado como favorito + Favorito eliminado + Añadir a la pantalla de inicio + Quitar + Mover arriba + Mover abajo + Este lanzador no admite accesos directos en la pantalla de inicio. + + Cajas + Cambiar de caja + Añadir caja + Activa + + La caja y este teléfono tienen que estar en la misma red. Coil habla directamente con la caja; nada pasa por internet. + Encontrado en esta red + Buscando… + No se ha encontrado nada automáticamente. Escribe la dirección a mano. + Escribir la dirección + Nombre de host o dirección IP + Nombre + Salón + Puerto de comandos + Puerto de estado + Probar la conexión + Guardar + Cancelar + Eliminar + Probando… + Caja localizada. Phoniebox %1$s. + Caja localizada. + + La caja no responde. + Comprueba que la caja está encendida y en la misma red. Algunos routers y redes de invitados impiden que los dispositivos se vean entre sí — ese ajuste suele llamarse aislamiento de clientes o aislamiento AP. + Escribe un nombre de host o una dirección IP. + Escribe un puerto entre 1 y 65535. + La caja no ha aceptado esa orden. + Sin conexión con la caja. + No se ha podido cargar la biblioteca. + + Se muestran datos guardados. La caja no está accesible. + Añade tu Phoniebox para empezar. + + Apariencia + Tema + Seguir al sistema + Claro + Oscuro + Colores del fondo de pantalla + Sustituye el verde de Coil por colores tomados de tu fondo de pantalla. + Idioma + Abre los ajustes de idioma del sistema para Coil. + + Esta caja + Dirección + Volver a leer la biblioteca en la caja + Pide a la caja que busque archivos nuevos. Con una biblioteca grande puede tardar. + La caja está releyendo su biblioteca. + Quitar esta caja + ¿Quitar %1$s? Sus favoritos y su biblioteca en caché se van con ella. La caja en sí no se toca. + + Controles en la pantalla de bloqueo + Mostrar controles + Solo mientras Coil está abierto + Sin conexión en segundo plano y sin gasto de batería. + Siempre que la caja suene + Mantiene una conexión con la caja mientras estás en Wi-Fi, para que los controles aparezcan por sí solos. + Android puede cortar de todos modos una conexión en segundo plano para ahorrar energía, y algunos teléfonos son más estrictos que otros. Entonces los controles pueden tardar en aparecer o no aparecer hasta que abras Coil. + Permitir que Coil funcione en segundo plano + Abre el ajuste del sistema que evita que Android duerma a Coil. + Controles automáticos para esta caja + + Datos + Exportar ajustes + Guarda las cajas y los favoritos en un archivo. Útil antes de cambiar entre la versión de Play Store y la de GitHub. + Importar ajustes + Ajustes exportados. + Ajustes importados. + No se ha podido leer ese archivo. + + Acerca de + Coil %1$s + Phoniebox %1$s + Coil está pensado para tu red doméstica. La interfaz de comandos de la caja no tiene contraseña, así que no redirijas esos puertos en tu router: usa una VPN si necesitas acceso desde fuera. + + Indica tu caja a Coil + + Reproducción + Controles de la Phoniebox que está reproduciendo. + Conexión + Aviso discreto mientras Coil mantiene la conexión con la caja. + Listo + Conectado a %1$s + + No se ha podido acceder a la caja. + Reproduciendo %1$s en %2$s + + Más opciones + + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..cfac702 --- /dev/null +++ b/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,165 @@ + + + + + Lecteur + Bibliothèque + Favoris + Réglages + + Aucune lecture en cours + Titre inconnu + Pochette + Lire + Pause + Piste précédente + Piste suivante + Lecture aléatoire + Répéter + Répéter une piste + Volume + Retirer des favoris + Volume %1$d%% + + Connecté à %1$s + Connexion à %1$s… + Reconnexion à %1$s… + Non connecté + + Dossiers + Albums + Bibliothèque + Ce dossier est vide + Aucun album pour le moment. Si vous en attendez, demandez à la box de réanalyser sa bibliothèque. + Album inconnu + Artiste inconnu + Mis à jour %1$s + Pas encore chargé + à l\'instant + + il y a %1$d minute + il y a %1$d minutes + + + il y a %1$d heure + il y a %1$d heures + + + il y a %1$d jour + il y a %1$d jours + + Lire le dossier + Remonter d\'un niveau + Actualiser + + Aucun favori + Appuyez longuement sur un dossier ou un album dans la bibliothèque pour l\'enregistrer ici. + Ajouté aux favoris + Favori retiré + Ajouter à l\'écran d\'accueil + Retirer + Déplacer vers le haut + Déplacer vers le bas + Ce lanceur ne prend pas en charge les raccourcis sur l\'écran d\'accueil. + + Box + Changer de box + Ajouter une box + Active + + La box et ce téléphone doivent être sur le même réseau. Coil parle directement à la box ; rien ne passe par internet. + Trouvé sur ce réseau + Recherche… + Rien trouvé automatiquement. Saisissez l\'adresse à la main. + Saisir l\'adresse + Nom d\'hôte ou adresse IP + Nom + Salon + Port de commande + Port d\'état + Tester la connexion + Enregistrer + Annuler + Supprimer + Test en cours… + Box joignable. Phoniebox %1$s. + Box joignable. + + Aucune réponse de la box. + Vérifiez que la box est allumée et sur le même réseau. Certains routeurs et réseaux invités empêchent les appareils de se voir — ce réglage s\'appelle généralement isolation des clients ou isolation AP. + Saisissez un nom d\'hôte ou une adresse IP. + Saisissez un port entre 1 et 65535. + La box n\'a pas accepté cette commande. + Pas de connexion à la box. + Impossible de charger la bibliothèque. + + Données enregistrées affichées. La box n\'est pas joignable. + Ajoutez votre Phoniebox pour commencer. + + Apparence + Thème + Comme le système + Clair + Sombre + Couleurs du fond d\'écran + Remplace le vert de Coil par des couleurs tirées de votre fond d\'écran. + Langue + Ouvre les réglages de langue du système pour Coil. + + Cette box + Adresse + Réanalyser la bibliothèque sur la box + Demande à la box de chercher de nouveaux fichiers. Cela peut prendre du temps sur une grande bibliothèque. + La box réanalyse sa bibliothèque. + Supprimer cette box + Supprimer %1$s ? Ses favoris et sa bibliothèque en cache disparaissent avec elle. La box elle-même n\'est pas touchée. + + Commandes sur l\'écran de verrouillage + Afficher les commandes + Seulement quand Coil est ouvert + Aucune connexion en arrière-plan, aucune consommation de batterie. + Dès que la box joue + Garde une connexion à la box en Wi-Fi, pour que les commandes apparaissent d\'elles-mêmes. + Android peut malgré tout couper une connexion en arrière-plan pour économiser de l\'énergie, et certains téléphones sont plus stricts que d\'autres. Les commandes peuvent alors tarder à apparaître, ou n\'apparaître qu\'à l\'ouverture de Coil. + Autoriser Coil en arrière-plan + Ouvre le réglage système qui empêche Android de mettre Coil en veille. + Commandes automatiques pour cette box + + Données + Exporter les réglages + Enregistre les box et les favoris dans un fichier. Utile avant de passer de la version Play Store à celle de GitHub, ou l\'inverse. + Importer les réglages + Réglages exportés. + Réglages importés. + Ce fichier n\'a pas pu être lu. + + À propos + Coil %1$s + Phoniebox %1$s + Coil est conçu pour votre réseau domestique. L\'interface de commande de la box n\'a pas de mot de passe : ne redirigez pas ces ports sur votre routeur. Utilisez un VPN si vous avez besoin d\'un accès depuis l\'extérieur. + + Indiquez votre box à Coil + + Lecture + Commandes de la Phoniebox en cours de lecture. + Connexion + Note discrète affichée tant que Coil garde une connexion à la box. + Prêt + Connecté à %1$s + + La box n\'était pas joignable. + Lecture de %1$s sur %2$s + + Plus d\'options + + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..e603830 --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,5 @@ + + + + #0E1513 + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..1dcf8d7 --- /dev/null +++ b/app/src/main/res/values-nl/strings.xml @@ -0,0 +1,164 @@ + + + + + Speler + Bibliotheek + Favorieten + Instellingen + + Er speelt niets + Onbekende titel + Hoes + Afspelen + Pauze + Vorig nummer + Volgend nummer + Willekeurig + Herhalen + Eén nummer herhalen + Volume + Uit favorieten verwijderen + Volume %1$d%% + + Verbonden met %1$s + Verbinden met %1$s… + Opnieuw verbinden met %1$s… + Niet verbonden + + Mappen + Albums + Bibliotheek + Deze map is leeg + Nog geen albums. Laat de box haar bibliotheek opnieuw inlezen als je er wel verwacht. + Onbekend album + Onbekende artiest + Bijgewerkt %1$s + Nog niet geladen + net nu + + %1$d minuut geleden + %1$d minuten geleden + + + %1$d uur geleden + %1$d uur geleden + + + %1$d dag geleden + %1$d dagen geleden + + Map afspelen + Eén niveau omhoog + Vernieuwen + + Nog geen favorieten + Houd een map of album in de bibliotheek ingedrukt om het hier op te slaan. + Als favoriet opgeslagen + Favoriet verwijderd + Aan startscherm toevoegen + Verwijderen + Omhoog + Omlaag + Deze launcher ondersteunt geen snelkoppelingen op het startscherm. + + Boxen + Andere box kiezen + Box toevoegen + Actief + + De box en deze telefoon moeten in hetzelfde netwerk zitten. Coil praat direct met de box; niets gaat via internet. + Gevonden in dit netwerk + Zoeken… + Automatisch niets gevonden. Voer het adres met de hand in. + Adres invoeren + Hostnaam of IP-adres + Naam + Woonkamer + Opdrachtpoort + Statuspoort + Verbinding testen + Opslaan + Annuleren + Verwijderen + Testen… + Box bereikt. Phoniebox %1$s. + Box bereikt. + + Geen antwoord van de box. + Controleer of de box aan staat en in hetzelfde netwerk zit. Sommige routers en gastnetwerken verhinderen dat apparaten elkaar zien — die instelling heet meestal client-isolatie of AP-isolatie. + Voer een hostnaam of IP-adres in. + Voer een poort tussen 1 en 65535 in. + De box heeft dat niet geaccepteerd. + Geen verbinding met de box. + De bibliotheek kon niet worden geladen. + + Opgeslagen gegevens. De box is niet bereikbaar. + Voeg je Phoniebox toe om te beginnen. + + Weergave + Thema + Systeem volgen + Licht + Donker + Kleuren van de achtergrond + Vervangt het groen van Coil door kleuren uit je achtergrond. + Taal + Opent de taalinstellingen van het systeem voor Coil. + + Deze box + Adres + Bibliotheek op de box opnieuw inlezen + Laat de box naar nieuwe bestanden zoeken. Bij een grote bibliotheek kan dat even duren. + De box leest haar bibliotheek opnieuw in. + Deze box verwijderen + %1$s verwijderen? De favorieten en de opgeslagen bibliotheek gaan mee. De box zelf blijft ongemoeid. + + Bediening op het vergrendelscherm + Bediening tonen + Alleen zolang Coil open is + Geen verbinding op de achtergrond, geen batterijverbruik. + Zodra de box speelt + Houdt op wifi een verbinding met de box open, zodat de bediening van zichzelf verschijnt. + Android kan een verbinding op de achtergrond alsnog verbreken om stroom te sparen, en sommige telefoons zijn strenger dan andere. Dan kan de bediening later verschijnen of wegblijven tot je Coil opent. + Coil op de achtergrond toestaan + Opent de systeeminstelling die voorkomt dat Android Coil in slaap legt. + Automatische bediening voor deze box + + Gegevens + Instellingen exporteren + Slaat boxen en favorieten op in een bestand. Handig voordat je wisselt tussen de Play Store- en de GitHub-versie. + Instellingen importeren + Instellingen geëxporteerd. + Instellingen geïmporteerd. + Dat bestand kon niet worden gelezen. + + Over + Coil %1$s + Phoniebox %1$s + Coil is bedoeld voor je thuisnetwerk. De opdrachtinterface van de box heeft geen wachtwoord, dus stuur die poorten niet door op je router — gebruik een VPN als je van buitenaf toegang nodig hebt. + + Wijs Coil naar je box + + Weergave + Bediening voor de Phoniebox die speelt. + Verbinding + Stille melding zolang Coil een verbinding met de box openhoudt. + Gereed + Verbonden met %1$s + + De box was niet bereikbaar. + %1$s starten op %2$s + + Meer opties + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..a7f50a0 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + + #F5FBF7 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d92ab45 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,261 @@ + + + + + + Coil + + + Player + Library + Favourites + Settings + + + Nothing playing + Unknown title + Cover art + Play + Pause + Previous track + Next track + Shuffle + Repeat + Repeat all + Repeat one track + Volume + + Playback options + + + Sleep timer + Sleep timer is on + Cancel timer + Sleep timer + Stops playback after the time you pick. The box stays switched on — Coil never turns it off. + + Stops in %1$s + + + %1$s min + %1$s min + + Remove from favourites + + Save folder as favourite + Remove folder from favourites + Save track as favourite + Remove track from favourites + Save album as favourite + Remove album from favourites + + Favourite options + + Volume %1$d%% + + + Connected to %1$s + Connecting to %1$s… + Reconnecting to %1$s… + Not connected + + + Folders + Albums + Library + This folder is empty + No albums yet. Rescan the library on the box if you expect some. + Unknown album + Unknown artist + + Updated %1$s + Not loaded yet + just now + + %1$d minute ago + %1$d minutes ago + + + %1$d hour ago + %1$d hours ago + + + %1$d day ago + %1$d days ago + + Play folder + Up one level + Refresh + + + Search the library + Clear search + Tracks + Nothing found + Coil searches what it has loaded from the box. Albums are all there once the Albums tab has been opened; folders and tracks arrive as you open them. Settings can go through the whole library once and make everything searchable. + + + Details + + Path + Artist + Album + Album artist + Track number + Length + + File + + + No favourites yet + Open the menu next to a folder, album or track in the library to save it here. + Saved as favourite + Favourite removed + Add to home screen + + Copy link + Share link + + Link copied + Remove + Move up + Move down + This launcher does not support home screen shortcuts. + + + Boxes + Switch box + Add box + Active + + + The box and this phone have to be on the same network. Coil talks to the box directly; nothing goes through the internet. + Found on this network + Scanning… + Nothing found automatically. Enter the address by hand. + Enter address + Hostname or IP address + phoniebox.local + Name + Living room + Command port + Status port + Test connection + Save + Cancel + Delete + Testing… + + Box reached. Phoniebox %1$s. + Box reached. + + + No answer from the box. + Check that the box is switched on and on the same network. Some routers and guest networks block devices from seeing each other — that setting is usually called client isolation or AP isolation. + Enter a hostname or IP address. + Enter a port between 1 and 65535. + The box did not accept that. + Not connected to the box. + Could not load the library. + + + Showing saved data. The box is not reachable. + Add your Phoniebox to get started. + + + Appearance + Theme + Follow system + Light + Dark + Use wallpaper colours + Replaces Coil\'s green with colours taken from your wallpaper. + Language + Opens the system language settings for Coil. + + + This box + Address + Rescan library on the box + Tells the box to look for new files. Can take a while on a large library. + Rescan started on the box. + Add another box + Coil controls one box at a time. Once a second box is set up, the name at the top of the screen switches between them. + + Make the whole library searchable + Goes through every folder once so search finds all of it. Takes a while on a large library, and cards on the box can be slow to register meanwhile. Keep this screen open while it runs. + Stop + + %1$s folders read so far. Tap to stop; what has been read stays searchable. + Library searchable: %1$s folders read. + Stopped after %1$s folders — this library is larger than Coil indexes in one go, so search may still miss some of it. + The box stopped answering after %1$s folders. What was read stays searchable. + The box is playing. Try again when it is idle, so cards keep registering promptly. + Remove this box + Remove %1$s? Its favourites and cached library are removed with it. The box itself is not touched. + + + Lock screen controls + Show controls + Only while Coil is open + No background connection, no battery cost. + Whenever the box plays + Keeps a connection to the box while you are on Wi-Fi, so controls appear on their own. + Android may still drop a background connection to save power, and some phones are stricter than others. When that happens, controls can take a moment to appear or stay away until you open Coil. + Allow Coil to run in the background + Opens the system setting that stops Android from putting Coil to sleep. + Automatic controls for this box + + + Data + Export settings + Saves boxes and favourites to a file. Useful before switching between the Play Store and GitHub builds. + Import settings + Settings exported. + Settings imported. + That file could not be read. + + + About + + Coil %1$s + + Phoniebox %1$s + Coil is built for your home network. The box\'s command interface has no password, so do not forward those ports through your router — use a VPN if you need access from outside. + + + Point Coil at your box + + + Playback + Controls for the Phoniebox that is playing. + Connection + Quiet note shown while Coil keeps a connection to the box. + Ready + + Connected to %1$s + + + + Could not reach the box. + + Starting %1$s on %2$s + + More options + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..de8ded0 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..4676874 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..e612e70 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/AppScreenshotTest.kt b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/AppScreenshotTest.kt new file mode 100644 index 0000000..296883b --- /dev/null +++ b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/AppScreenshotTest.kt @@ -0,0 +1,146 @@ +package app.coilforphoniebox.screenshot + +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.hilt.navigation.compose.hiltViewModel +import app.coilforphoniebox.R +import app.coilforphoniebox.domain.model.ConnectionState +import app.coilforphoniebox.domain.model.PlaybackState +import app.coilforphoniebox.ui.AppViewModel +import app.coilforphoniebox.ui.CoilApp +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Test +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import javax.inject.Inject + +/** + * The app as it is actually seen: `CoilApp`'s scaffold with a screen inside it. + * + * The screen-level goldens deliberately capture screens on their own, which leaves out + * everything the scaffold draws — the top bar with the box indicator, the bottom navigation, + * the mini player and the offline banner. These capture the assembled thing instead, so a + * golden of the player starts at the title bar the way the app does. + * + * Destinations are reached by clicking the navigation bar rather than by composing a screen + * directly, which means the navigation itself is under test too: a destination that stopped + * being reachable would fail here rather than quietly keep its old picture. + * + * Subclasses supply the device. Everything else is shared, so adding a phone size is a class + * with two lines in it. + */ +abstract class AppScreenshotTest : ScreenshotTest() { + + /** Suffix for every golden this class writes. Diff output is flat, so it cannot repeat. */ + protected abstract val device: String + + @Inject lateinit var boxes: FakeBoxRepository + + @Inject lateinit var player: FakePlayerRepository + + @Before + fun injectFakes() { + hilt.inject() + } + + private fun string(id: Int): String = RuntimeEnvironment.getApplication().getString(id) + + private fun showApp(dark: Boolean = false) { + show(dark) { CoilApp(appViewModel = hiltViewModel()) } + } + + /** Bottom navigation is how a user changes screen, so it is how these tests do it too. */ + private fun navigateTo(label: Int) { + compose.onNodeWithText(string(label)).performClick() + compose.waitForIdle() + } + + @Test + fun player() { + showApp() + captureRoot("app/player_$device") + } + + @Test + fun player_dark() { + showApp(dark = true) + captureRoot("app/player_dark_$device") + } + + /** + * Away from the player, the mini player appears above the navigation bar — the one piece + * of chrome that is conditional on both what is playing and where you are. + */ + @Test + fun library() { + showApp() + navigateTo(R.string.nav_library) + captureRoot("app/library_$device") + } + + @Test + fun favourites() { + showApp() + navigateTo(R.string.nav_favourites) + captureRoot("app/favourites_$device") + } + + @Test + fun settings() { + showApp() + navigateTo(R.string.nav_settings) + captureRoot("app/settings_$device") + } + + /** + * Box configured, box not answering: the offline banner under the top bar, and the + * indicator's dot gone grey. Both live in the scaffold and appear in no screen golden. + */ + @Test + fun offline() { + player.connectionState.value = ConnectionState.DISCONNECTED + player.status.value = Fixtures.playing.copy(state = PlaybackState.PAUSE) + showApp() + navigateTo(R.string.nav_library) + captureRoot("app/offline_$device") + } + + /** + * A first launch has no box, and the app is the add-box screen and nothing else — no + * navigation bar, no top bar, no way past it (§11.2). + */ + @Test + fun onboarding() { + boxes.boxes.value = emptyList() + boxes.activeBox.value = null + showApp() + captureRoot("app/onboarding_$device") + } +} + +/** The reference device: the size `robolectric.properties` sets for everything else. */ +@HiltAndroidTest +class MediumPhoneAppScreenshotTest : AppScreenshotTest() { + override val device = "phone" +} + +/** + * A small, dense phone. Half the point of a device axis: 360×640 is where a bottom navigation + * bar, a mini player and a screen stop fitting together. + */ +@HiltAndroidTest +@Config(qualifiers = "w360dp-h640dp-normal-long-notround-any-xhdpi-keyshidden-nonav") +class SmallPhoneAppScreenshotTest : AppScreenshotTest() { + override val device = "small" +} + +/** + * A tablet in landscape. The UI is single-column everywhere and this is what that looks like + * on 1280 dp — these goldens document the gap rather than claiming it is handled. + */ +@HiltAndroidTest +@Config(qualifiers = "w1280dp-h800dp-xlarge-notlong-notround-any-xhdpi-keyshidden-nonav") +class TabletAppScreenshotTest : AppScreenshotTest() { + override val device = "tablet" +} diff --git a/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/ChromeScreenshotTest.kt b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/ChromeScreenshotTest.kt new file mode 100644 index 0000000..4cac155 --- /dev/null +++ b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/ChromeScreenshotTest.kt @@ -0,0 +1,110 @@ +package app.coilforphoniebox.screenshot + +import androidx.compose.ui.res.stringResource +import app.coilforphoniebox.R +import app.coilforphoniebox.domain.model.ConnectionState +import app.coilforphoniebox.ui.components.BoxIndicator +import app.coilforphoniebox.ui.components.MiniPlayer +import app.coilforphoniebox.ui.components.OfflineBanner +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Test +import org.robolectric.annotation.Config + +/** + * The app's chrome: what `CoilApp` draws around whichever screen is showing. + * + * None of this appears in the screen goldens, because the screens do not draw it — the top + * bar's box indicator, the mini player and the offline banner all live in the scaffold. They + * are captured here as components rather than in place, since assembling the real scaffold + * means navigation and Hilt-injected view models. + * + * The box indicator earns the most attention of the three: it is the only place the app says + * anything about the connection, and "degraded" and "connected" differing by one small dot is + * exactly the kind of thing a golden should be watching. + */ +@HiltAndroidTest +@Config(qualifiers = "+h200dp") +class ChromeScreenshotTest : ScreenshotTest() { + + @Test + fun box_indicator_connected() { + captureComponent("chrome/box_indicator_connected") { + BoxIndicator( + activeBox = Fixtures.livingRoom, + connection = ConnectionState.CONNECTED, + switchable = false, + onClick = {}, + ) + } + } + + /** Two boxes: the indicator becomes a switcher and grows an affordance for it. */ + @Test + fun box_indicator_switchable() { + captureComponent("chrome/box_indicator_switchable") { + BoxIndicator( + activeBox = Fixtures.livingRoom, + connection = ConnectionState.CONNECTED, + switchable = true, + onClick = {}, + ) + } + } + + /** Sockets up, box gone quiet past the watchdog — the state that must not read as fine. */ + @Test + fun box_indicator_degraded() { + captureComponent("chrome/box_indicator_degraded") { + BoxIndicator( + activeBox = Fixtures.livingRoom, + connection = ConnectionState.DEGRADED, + switchable = true, + onClick = {}, + ) + } + } + + @Test + fun box_indicator_disconnected() { + captureComponent("chrome/box_indicator_disconnected") { + BoxIndicator( + activeBox = Fixtures.livingRoom, + connection = ConnectionState.DISCONNECTED, + switchable = false, + onClick = {}, + ) + } + } + + @Test + fun mini_player_playing() { + captureComponent("chrome/mini_player_playing") { + MiniPlayer( + status = Fixtures.playing, + coverUrl = "http://phoniebox.local/cover-cache/missing-key.jpg", + onClick = {}, + onToggle = {}, + ) + } + } + + @Test + fun mini_player_paused_dark() { + captureComponent("chrome/mini_player_paused_dark", dark = true) { + MiniPlayer( + status = Fixtures.paused, + coverUrl = null, + onClick = {}, + onToggle = {}, + ) + } + } + + /** The banner takes its text from the caller, so the golden uses the app's own string. */ + @Test + fun offline_banner() { + captureComponent("chrome/offline_banner") { + OfflineBanner(text = stringResource(R.string.offline_banner)) + } + } +} diff --git a/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/FakeRepositories.kt b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/FakeRepositories.kt new file mode 100644 index 0000000..f25b4ab --- /dev/null +++ b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/FakeRepositories.kt @@ -0,0 +1,222 @@ +package app.coilforphoniebox.screenshot + +import app.coilforphoniebox.domain.model.AppSettings +import app.coilforphoniebox.domain.model.Box +import app.coilforphoniebox.domain.model.ConnectionState +import app.coilforphoniebox.domain.model.ConnectionTestResult +import app.coilforphoniebox.domain.model.Favorite +import app.coilforphoniebox.domain.model.FolderContent +import app.coilforphoniebox.domain.model.LibraryAlbum +import app.coilforphoniebox.domain.model.LibraryIndexResult +import app.coilforphoniebox.domain.model.LibraryIndexState +import app.coilforphoniebox.domain.model.LibrarySearchResults +import app.coilforphoniebox.domain.model.PlayTarget +import app.coilforphoniebox.domain.model.PlayerStatus +import app.coilforphoniebox.domain.model.RepeatMode +import app.coilforphoniebox.domain.model.SessionMode +import app.coilforphoniebox.domain.model.SleepTimerStatus +import app.coilforphoniebox.domain.model.ThemeMode +import app.coilforphoniebox.domain.model.VolumeStatus +import app.coilforphoniebox.domain.repository.BackupRepository +import app.coilforphoniebox.domain.repository.BoxRepository +import app.coilforphoniebox.domain.repository.FavoriteRepository +import app.coilforphoniebox.domain.repository.LibraryRepository +import app.coilforphoniebox.domain.repository.PlayerRepository +import app.coilforphoniebox.domain.repository.SettingsRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map + +/** + * Repositories that answer from constructor arguments instead of from a box. + * + * A screenshot has to be the same picture every time it is taken, which rules out the real + * implementations twice over: they need a Phoniebox on the network, and what they return + * depends on what that box happens to be doing. These take the screen's state as data, so a + * test names the state it wants and the golden shows exactly that. + * + * Commands succeed silently. A screenshot captures one moment and never presses a button, so + * there is nothing here to record — a fake that verified calls would be a mock, and the + * behaviour those calls have belongs in the ViewModel's own unit tests. + */ +class FakePlayerRepository( + status: PlayerStatus = PlayerStatus.Idle, + volume: VolumeStatus = VolumeStatus(level = 42, maxLevel = 100), + connection: ConnectionState = ConnectionState.CONNECTED, + coverUrl: String? = null, + sleepTimer: SleepTimerStatus = SleepTimerStatus.Off, + boxVersion: String? = "future3/main", +) : PlayerRepository { + override val status = MutableStateFlow(status) + override val volume = MutableStateFlow(volume) + override val connectionState = MutableStateFlow(connection) + override val boxVersion = MutableStateFlow(boxVersion) + override val coverUrl = MutableStateFlow(coverUrl) + override val sleepTimer = MutableStateFlow(sleepTimer) + + override suspend fun play(): Result = Result.success(Unit) + override suspend fun pause(): Result = Result.success(Unit) + override suspend fun toggle(): Result = Result.success(Unit) + override suspend fun next(): Result = Result.success(Unit) + override suspend fun previous(): Result = Result.success(Unit) + override suspend fun seekTo(positionSeconds: Double): Result = Result.success(Unit) + override suspend fun setShuffle(enabled: Boolean): Result = Result.success(Unit) + override suspend fun setRepeat(mode: RepeatMode): Result = Result.success(Unit) + override suspend fun setVolume(level: Int): Result = Result.success(Unit) + override suspend fun changeVolume(step: Int): Result = Result.success(Unit) + override suspend fun toggleMute(): Result = Result.success(Unit) + override suspend fun startSleepTimer(minutes: Int): Result = Result.success(Unit) + override suspend fun cancelSleepTimer(): Result = Result.success(Unit) + override suspend fun refreshSleepTimer(): Result = Result.success(Unit) + override suspend fun play(target: PlayTarget): Result = Result.success(Unit) + override suspend fun playOn(boxId: String, target: PlayTarget): Result = Result.success(Unit) +} + +class FakeBoxRepository( + boxes: List = listOf(Fixtures.livingRoom), + active: Box? = boxes.firstOrNull(), +) : BoxRepository { + override val boxes = MutableStateFlow(boxes) + override val activeBox = MutableStateFlow(active) + + override suspend fun box(boxId: String): Box? = boxes.value.firstOrNull { it.id == boxId } + + override suspend fun add(displayName: String, host: String, rpcPort: Int, pubPort: Int): Box { + val box = Box( + id = "box-${boxes.value.size + 1}", + displayName = displayName, + host = host, + rpcPort = rpcPort, + pubPort = pubPort, + addedAt = Fixtures.NOW, + ) + boxes.value = boxes.value + box + return box + } + + override suspend fun update(box: Box) { + boxes.value = boxes.value.map { if (it.id == box.id) box else it } + } + + override suspend fun delete(boxId: String) { + boxes.value = boxes.value.filterNot { it.id == boxId } + } + + override suspend fun setActive(boxId: String) { + activeBox.value = box(boxId) + } + + override suspend fun markSeen(boxId: String) = Unit + + override suspend fun testConnection(host: String, rpcPort: Int): ConnectionTestResult = + ConnectionTestResult.Reachable(version = "future3/main") +} + +class FakeFavoriteRepository(favorites: List = emptyList()) : FavoriteRepository { + /** Public so an injected instance can be given different favourites before composing. */ + val all = MutableStateFlow(favorites) + + override fun favorites(boxId: String): Flow> = + all.map { list -> list.filter { it.boxId == boxId }.sortedBy { it.sortIndex } } + + override fun mostLaunched(boxId: String, limit: Int): Flow> = + all.map { list -> list.filter { it.boxId == boxId }.sortedByDescending { it.launchCount }.take(limit) } + + override suspend fun favorite(id: Long): Favorite? = all.value.firstOrNull { it.id == id } + + override suspend fun matching(boxId: String, target: PlayTarget): Favorite? = + all.value.firstOrNull { it.boxId == boxId && it.toPlayTarget() == target } + + override suspend fun add(favorite: Favorite): Long { + val id = (all.value.maxOfOrNull { it.id } ?: 0L) + 1 + all.value = all.value + favorite.copy(id = id) + return id + } + + override suspend fun remove(id: Long) { + all.value = all.value.filterNot { it.id == id } + } + + override suspend fun rename(id: Long, label: String) { + all.value = all.value.map { if (it.id == id) it.copy(label = label) else it } + } + + override suspend fun reorder(ids: List) = Unit + override suspend fun recordLaunch(id: Long) = Unit + override suspend fun setPinned(id: Long, pinned: Boolean) = Unit +} + +/** + * [folders] is keyed by path, so a test can hand in as much of a tree as the screen it is + * capturing actually walks. A path that is not in the map is an empty, never-fetched level — + * which is what the app shows before a box has been reached, and a state worth a golden. + */ +class FakeLibraryRepository( + folders: Map = emptyMap(), + albums: List = emptyList(), + cachedAt: Long? = Fixtures.NOW, + results: LibrarySearchResults = LibrarySearchResults(), +) : LibraryRepository { + // Held as flows rather than constructor values so a test that gets this injected can swap + // the library out before composing — which is how the store assets get their own content + // without a second Hilt module. + val allFolders = MutableStateFlow(folders) + val allAlbums = MutableStateFlow(albums) + val cachedAt = MutableStateFlow(cachedAt) + val results = MutableStateFlow(results) + + override val indexState = MutableStateFlow(LibraryIndexState()) + + override fun folderContent(boxId: String, path: String): Flow = + allFolders.map { it[path] ?: FolderContent(path) } + + override suspend fun refreshFolder(boxId: String, path: String): Result = Result.success(Unit) + + override fun albums(boxId: String): Flow> = allAlbums + + override fun albumsCachedAt(boxId: String): Flow = cachedAt + + override suspend fun refreshAlbums(boxId: String): Result = Result.success(Unit) + + override suspend fun ensureAlbumCover(boxId: String, albumArtist: String, album: String) = Unit + + override suspend fun rescanBoxLibrary(boxId: String): Result = Result.success(Unit) + + override suspend fun refreshIfStaleAndIdle(boxId: String) = Unit + + override fun search(boxId: String, query: String): Flow = + results.map { if (query.isBlank()) LibrarySearchResults() else it.copy(query = query) } + + override suspend fun indexLibrary(boxId: String): LibraryIndexResult = + LibraryIndexResult.Finished(foldersScanned = 0, stoppedAtCap = false) +} + +/** Export and import never run in a screenshot; settings only needs the repository to exist. */ +class FakeBackupRepository : BackupRepository { + override suspend fun export(): String = "{}" + override suspend fun importFrom(content: String): Result = Result.success(Unit) +} + +class FakeSettingsRepository(settings: AppSettings = AppSettings()) : SettingsRepository { + private val state = MutableStateFlow(settings) + override val settings: StateFlow = state + + override suspend fun current(): AppSettings = state.value + + override suspend fun setThemeMode(mode: ThemeMode) { + state.value = state.value.copy(themeMode = mode) + } + + override suspend fun setDynamicColor(enabled: Boolean) { + state.value = state.value.copy(dynamicColor = enabled) + } + + override suspend fun setSessionMode(mode: SessionMode) { + state.value = state.value.copy(sessionMode = mode) + } + + override suspend fun setOnboardingComplete(complete: Boolean) { + state.value = state.value.copy(onboardingComplete = complete) + } +} diff --git a/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/FakeRepositoryModule.kt b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/FakeRepositoryModule.kt new file mode 100644 index 0000000..1f6a062 --- /dev/null +++ b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/FakeRepositoryModule.kt @@ -0,0 +1,86 @@ +package app.coilforphoniebox.screenshot + +import app.coilforphoniebox.data.di.RepositoryModule +import app.coilforphoniebox.domain.repository.BackupRepository +import app.coilforphoniebox.domain.repository.BoxRepository +import app.coilforphoniebox.domain.repository.FavoriteRepository +import app.coilforphoniebox.domain.repository.LibraryRepository +import app.coilforphoniebox.domain.repository.PlayerRepository +import app.coilforphoniebox.domain.repository.SettingsRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import javax.inject.Singleton + +/** + * Puts the fakes in place of the real repositories for the whole-app goldens. + * + * This replaces `:core-data`'s `RepositoryModule` outright, so nothing in a screenshot run + * ever opens a socket, a database or a DataStore file. `DatabaseModule` is left alone and + * simply never asked for anything — Room is provided lazily, and with every repository faked + * there is no longer a caller. + * + * Each fake is a singleton *and* is exposed under its own type as well as the interface, so a + * test can inject the concrete class and change what the box appears to be doing before + * composing. That is what makes one screen capturable in several states without a module per + * state. + */ +@Module +@TestInstallIn(components = [SingletonComponent::class], replaces = [RepositoryModule::class]) +object FakeRepositoryModule { + + @Provides + @Singleton + fun provideFakeBoxes(): FakeBoxRepository = FakeBoxRepository() + + @Provides + @Singleton + fun provideFakePlayer(): FakePlayerRepository = FakePlayerRepository( + status = Fixtures.playing, + // A URL rather than null, so the whole-app goldens show artwork where a real box + // would have it. The image loader answers it with a flat colour — see ScreenshotTest. + coverUrl = "http://phoniebox.local/cover-cache/missing-key.jpg", + ) + + @Provides + @Singleton + fun provideFakeLibrary(): FakeLibraryRepository = FakeLibraryRepository( + folders = mapOf( + "" to Fixtures.libraryRoot, + Fixtures.detectiveStories.path to Fixtures.detectiveStories, + ), + albums = Fixtures.albums, + results = Fixtures.searchResults, + ) + + @Provides + @Singleton + fun provideFakeFavorites(): FakeFavoriteRepository = FakeFavoriteRepository(Fixtures.favorites) + + @Provides + @Singleton + fun provideFakeSettings(): FakeSettingsRepository = FakeSettingsRepository() + + @Provides + @Singleton + fun provideFakeBackup(): FakeBackupRepository = FakeBackupRepository() + + @Provides + fun bindBoxes(fake: FakeBoxRepository): BoxRepository = fake + + @Provides + fun bindPlayer(fake: FakePlayerRepository): PlayerRepository = fake + + @Provides + fun bindLibrary(fake: FakeLibraryRepository): LibraryRepository = fake + + @Provides + fun bindFavorites(fake: FakeFavoriteRepository): FavoriteRepository = fake + + @Provides + fun bindSettings(fake: FakeSettingsRepository): SettingsRepository = fake + + @Provides + fun bindBackup(fake: FakeBackupRepository): BackupRepository = fake +} diff --git a/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/Fixtures.kt b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/Fixtures.kt new file mode 100644 index 0000000..29e2ed9 --- /dev/null +++ b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/Fixtures.kt @@ -0,0 +1,198 @@ +package app.coilforphoniebox.screenshot + +import app.coilforphoniebox.domain.model.Box +import app.coilforphoniebox.domain.model.Favorite +import app.coilforphoniebox.domain.model.FavoriteType +import app.coilforphoniebox.domain.model.FolderContent +import app.coilforphoniebox.domain.model.LibraryAlbum +import app.coilforphoniebox.domain.model.LibraryFolder +import app.coilforphoniebox.domain.model.LibrarySearchResults +import app.coilforphoniebox.domain.model.LibraryTrack +import app.coilforphoniebox.domain.model.PlaybackState +import app.coilforphoniebox.domain.model.PlayerStatus +import app.coilforphoniebox.domain.model.RepeatMode +import app.coilforphoniebox.domain.model.SleepTimerStatus +import java.util.concurrent.TimeUnit + +/** + * The library a screenshot shows. + * + * Names are deliberately a mixed bag: an umlaut and a long title are in here because those + * are what break a layout, and a golden that only ever renders short ASCII would not notice. + * German in particular runs longer than English at the same meaning, which is the reason the + * locale axis exists at all. + */ +object Fixtures { + + /** A fixed instant for anything whose exact value never reaches the screen. */ + const val NOW = 1_770_000_000_000L + + /** + * Freshness labels are computed against the wall clock, so a cache timestamp has to be an + * offset from it rather than a constant — a fixed epoch would read "Updated 2,847 days + * ago" and change every night. The extra hours keep the value off the bucket boundary, so + * "3 days ago" cannot round to "4" depending on when the suite runs. + */ + val cachedThreeDaysAgo: Long + get() = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(3) - TimeUnit.HOURS.toMillis(5) + + val livingRoom = Box( + id = "box-living-room", + displayName = "Living room", + host = "phoniebox.local", + addedAt = NOW, + lastSeenAt = NOW, + ) + + val bedroom = Box( + id = "box-bedroom", + displayName = "Bedroom", + host = "phoniebox-bedroom.local", + addedAt = NOW, + sortIndex = 1, + ) + + // ------------------------------------------------------------------ player + + val playing = PlayerStatus( + state = PlaybackState.PLAY, + title = "Chapter 3 — The Cave Behind the Waterfall", + artist = "Detective Stories", + album = "The Missing Key", + file = "Detective Stories/The Missing Key/03 The Cave.mp3", + elapsedSeconds = 95.0, + durationSeconds = 372.0, + songId = "17", + playlistPosition = 3, + playlistLength = 12, + ) + + val paused = playing.copy(state = PlaybackState.PAUSE, elapsedSeconds = 214.0) + + val shuffledAndRepeating = playing.copy(shuffle = true, repeat = RepeatMode.ALL) + + /** Web radio: no album, no duration — the case that used to break the progress row. */ + val webRadio = PlayerStatus( + state = PlaybackState.PLAY, + title = "Kinderradio — Am Nachmittag", + artist = "Kinderradio", + file = "http://stream.example.org/kinderradio.mp3", + elapsedSeconds = 3_612.0, + playlistLength = 1, + ) + + val timerRunning = SleepTimerStatus( + running = true, + remainingSeconds = 1_800, + requestedSeconds = 1_800, + receivedAtElapsedMillis = 0L, + ) + + // ----------------------------------------------------------------- library + + private fun folder(path: String, name: String, parent: String? = null, hasChildren: Boolean = true) = + LibraryFolder( + boxId = livingRoom.id, + path = path, + parentPath = parent, + displayName = name, + hasChildren = hasChildren, + cachedAt = NOW, + ) + + val libraryRoot = FolderContent( + path = FolderContent.ROOT, + folders = listOf( + folder("Bedtime Stories", "Bedtime Stories"), + folder("Bärenstark", "Bärenstark"), + folder("Detective Stories", "Detective Stories"), + folder("Nursery Rhymes", "Nursery Rhymes"), + folder("Sing-Along Songs for the Long Drive Home", "Sing-Along Songs for the Long Drive Home"), + ), + cachedAt = cachedThreeDaysAgo, + ) + + val detectiveStories = FolderContent( + path = "Detective Stories/The Missing Key", + folders = emptyList(), + tracks = listOf( + track(1, "01 The Letter.mp3", "Chapter 1 — The Letter", 301.0), + track(2, "02 The Old House.mp3", "Chapter 2 — The Old House", 288.0), + track(3, "03 The Cave.mp3", "Chapter 3 — The Cave Behind the Waterfall", 372.0), + track(4, "04 Footprints.mp3", "Chapter 4 — Footprints in the Sand", 265.0), + track(5, "05 The Key.mp3", "Chapter 5 — The Key", 341.0), + ), + cachedAt = cachedThreeDaysAgo, + ) + + private fun track(number: Int, file: String, title: String, seconds: Double) = LibraryTrack( + boxId = livingRoom.id, + url = "Detective Stories/The Missing Key/$file", + parentPath = "Detective Stories/The Missing Key", + title = title, + artist = "Detective Stories", + album = "The Missing Key", + trackNo = number, + durationSeconds = seconds, + ) + + private fun album(artist: String, name: String) = LibraryAlbum( + boxId = livingRoom.id, + albumArtist = artist, + album = name, + cachedAt = NOW, + ) + + val albums = listOf( + album("Bärenstark", "Ein Bär räumt auf"), + album("Detective Stories", "The Missing Key"), + album("Detective Stories", "The Silent Lighthouse"), + album("Nursery Rhymes", "Ten in the Bed"), + album("Nursery Rhymes", "Wheels on the Bus"), + album("Sing-Along", "Songs for the Long Drive Home"), + ) + + /** What "the" finds: one hit of each kind, which is the layout worth a golden. */ + val searchResults = LibrarySearchResults( + query = "the", + folders = listOf(folder("Bedtime Stories", "Bedtime Stories")), + albums = listOf(album("Detective Stories", "The Missing Key")), + tracks = listOf( + track(1, "01 The Letter.mp3", "Chapter 1 — The Letter", 301.0), + track(5, "05 The Key.mp3", "Chapter 5 — The Key", 341.0), + ), + ) + + // --------------------------------------------------------------- favourites + + val favorites = listOf( + Favorite( + id = 1, + boxId = livingRoom.id, + label = "Bedtime Stories", + type = FavoriteType.FOLDER, + folder = "Bedtime Stories", + sortIndex = 0, + launchCount = 31, + ), + Favorite( + id = 2, + boxId = livingRoom.id, + label = "The Missing Key", + type = FavoriteType.ALBUM, + albumArtist = "Detective Stories", + album = "The Missing Key", + sortIndex = 1, + launchCount = 12, + ), + Favorite( + id = 3, + boxId = livingRoom.id, + label = "Chapter 3 — The Cave Behind the Waterfall", + type = FavoriteType.TRACK, + trackUrl = "Detective Stories/The Missing Key/03 The Cave.mp3", + sortIndex = 2, + launchCount = 4, + ), + ) +} diff --git a/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/LibraryScreenshotTest.kt b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/LibraryScreenshotTest.kt new file mode 100644 index 0000000..ba1a931 --- /dev/null +++ b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/LibraryScreenshotTest.kt @@ -0,0 +1,127 @@ +package app.coilforphoniebox.screenshot + +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import app.coilforphoniebox.R +import app.coilforphoniebox.domain.model.FolderContent +import app.coilforphoniebox.domain.model.LibraryAlbum +import app.coilforphoniebox.domain.model.LibrarySearchResults +import app.coilforphoniebox.ui.library.LibraryScreen +import app.coilforphoniebox.ui.library.LibraryViewModel +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Test +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * The library screen: folder list, album grid, search results, and the two empty states. + * + * Search and the album grid are reached by driving the screen rather than by handing the + * ViewModel a different starting state — that is how a user gets there, and it means the + * golden also proves the tab and the query field still work. + */ +@HiltAndroidTest +class LibraryScreenshotTest : ScreenshotTest() { + + private fun viewModel( + folders: Map = mapOf(FolderContent.ROOT to Fixtures.libraryRoot), + albums: List = Fixtures.albums, + albumsCachedAt: Long? = Fixtures.cachedThreeDaysAgo, + searchResults: LibrarySearchResults = Fixtures.searchResults, + ) = LibraryViewModel( + library = FakeLibraryRepository( + folders = folders, + albums = albums, + cachedAt = albumsCachedAt, + results = searchResults, + ), + boxes = FakeBoxRepository(), + player = FakePlayerRepository(), + favorites = FakeFavoriteRepository(Fixtures.favorites), + ) + + private fun string(id: Int): String = RuntimeEnvironment.getApplication().getString(id) + + @Test + fun folders_light() { + val vm = viewModel() + capture("library/folders_light") { LibraryScreen(vm) } + } + + @Test + fun folders_dark() { + val vm = viewModel() + capture("library/folders_dark", dark = true) { LibraryScreen(vm) } + } + + /** + * German: the tabs, the breadcrumb and the freshness line all in one picture, which is + * where a longer language runs out of room first. + * + * It is also the only look anyone gets at the translations without a phone — and it shows + * the drift honestly, because 43 of the 163 strings have no German yet and fall back to + * English on screen. + */ + @Test + @Config(qualifiers = "+de") + fun folders_german() { + val vm = viewModel() + capture("library/folders_de") { LibraryScreen(vm) } + } + + /** A level down: tracks with durations, and a breadcrumb wide enough to scroll. */ + @Test + fun tracks_in_folder() { + val path = Fixtures.detectiveStories.path + val vm = viewModel( + folders = mapOf( + FolderContent.ROOT to Fixtures.libraryRoot, + path to Fixtures.detectiveStories, + ), + ) + vm.openFolder(path) + capture("library/tracks_light") { LibraryScreen(vm) } + } + + @Test + fun albums_grid() { + val vm = viewModel() + show { LibraryScreen(vm) } + compose.onNodeWithText(string(R.string.library_tab_albums)).performClick() + captureRoot("library/albums_light") + } + + /** + * Results are debounced, so the clock has to move before there is anything to see — the + * one place these tests have to acknowledge time at all. + */ + @Test + fun search_results() { + val vm = viewModel() + vm.onSearchQueryChange("the") + show { LibraryScreen(vm) } + advanceTime(500) + captureRoot("library/search_light") + } + + /** + * Search found nothing. Worth a golden of its own because the message has a job to do: + * only what has been fetched is searchable, and an empty result must not read as an + * empty library. + */ + @Test + fun search_without_results() { + val vm = viewModel(searchResults = LibrarySearchResults()) + vm.onSearchQueryChange("zzz") + show { LibraryScreen(vm) } + advanceTime(500) + captureRoot("library/search_empty_light") + } + + /** Nothing cached yet — the state a first run shows before the box has answered. */ + @Test + fun empty_library() { + val vm = viewModel(folders = emptyMap(), albums = emptyList(), albumsCachedAt = null) + capture("library/empty_light") { LibraryScreen(vm) } + } +} diff --git a/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/PlayerScreenshotTest.kt b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/PlayerScreenshotTest.kt new file mode 100644 index 0000000..a0e0306 --- /dev/null +++ b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/PlayerScreenshotTest.kt @@ -0,0 +1,126 @@ +package app.coilforphoniebox.screenshot + +import app.coilforphoniebox.domain.model.ConnectionState +import app.coilforphoniebox.domain.model.Favorite +import app.coilforphoniebox.domain.model.PlayerStatus +import app.coilforphoniebox.domain.model.SleepTimerStatus +import app.coilforphoniebox.domain.model.VolumeStatus +import app.coilforphoniebox.ui.player.PlayerScreen +import app.coilforphoniebox.ui.player.PlayerViewModel +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Test +import org.robolectric.annotation.Config + +/** + * The player screen, in the states it is actually seen in. + * + * "Playing" is not one picture: web radio has no duration to draw a progress bar from, an + * idle box has no title, and a running sleep timer adds a line under the transport row. Each + * of those has broken separately at some point, which is why each gets its own golden. + */ +@HiltAndroidTest +class PlayerScreenshotTest : ScreenshotTest() { + + private fun viewModel( + status: PlayerStatus = Fixtures.playing, + volume: VolumeStatus = VolumeStatus(level = 42, maxLevel = 100), + connection: ConnectionState = ConnectionState.CONNECTED, + coverUrl: String? = "http://phoniebox.local/cover-cache/missing-key.jpg", + sleepTimer: SleepTimerStatus = SleepTimerStatus.Off, + favorites: List = Fixtures.favorites, + ) = PlayerViewModel( + player = FakePlayerRepository( + status = status, + volume = volume, + connection = connection, + coverUrl = coverUrl, + sleepTimer = sleepTimer, + ), + boxes = FakeBoxRepository(), + favorites = FakeFavoriteRepository(favorites), + ) + + @Test + fun playing_light() { + val vm = viewModel() + capture("player/playing_light") { PlayerScreen(vm) } + } + + @Test + fun playing_dark() { + val vm = viewModel() + capture("player/playing_dark", dark = true) { PlayerScreen(vm) } + } + + @Test + fun paused() { + val vm = viewModel(status = Fixtures.paused) + capture("player/paused_light") { PlayerScreen(vm) } + } + + /** No box has ever answered: no title, no cover, nothing to seek in. */ + @Test + fun nothing_playing() { + val vm = viewModel( + status = PlayerStatus.Idle, + connection = ConnectionState.DISCONNECTED, + coverUrl = null, + volume = VolumeStatus.Unknown, + favorites = emptyList(), + ) + capture("player/idle_light") { PlayerScreen(vm) } + } + + /** A stream reports no duration and no album — the layout has to survive both. */ + @Test + fun web_radio() { + val vm = viewModel(status = Fixtures.webRadio, coverUrl = null) + capture("player/web_radio_light") { PlayerScreen(vm) } + } + + /** + * Shuffle, repeat and a running timer at once: the options button is tinted and the + * countdown gets its own line, which is the one thing on this screen that appears out of + * nowhere rather than changing in place. + */ + @Test + fun sleep_timer_and_options() { + val vm = viewModel(status = Fixtures.shuffledAndRepeating, sleepTimer = Fixtures.timerRunning) + capture("player/sleep_timer_light") { PlayerScreen(vm) } + } + + /** + * A phone on its side: 731×411 dp, under the two-pane breakpoint but far too short for a + * full-width cover. + * + * This is the case the compact layout's height cap exists for — without it the cover alone + * is taller than the window and the transport controls sit below the fold. Worth a golden + * because nothing else in the suite is short and wide. + */ + @Test + @Config(qualifiers = "w731dp-h411dp-normal-long-notround-any-420dpi-keyshidden-nonav") + fun playing_landscape() { + val vm = viewModel() + capture("player/playing_landscape") { PlayerScreen(vm) } + } + + /** + * German. Almost everything on a playing screen is the box's own metadata, so what this + * golden actually shows is the idle state, where the app is doing the talking. + * + * The locale axis lives mostly on the library screen for that reason — see + * `LibraryScreenshotTest.folders_german`. + */ + @Test + @Config(qualifiers = "+de") + fun nothing_playing_german() { + val vm = viewModel( + status = PlayerStatus.Idle, + connection = ConnectionState.DISCONNECTED, + coverUrl = null, + volume = VolumeStatus.Unknown, + favorites = emptyList(), + ) + capture("player/idle_de") { PlayerScreen(vm) } + } +} diff --git a/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/ScreenshotTest.kt b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/ScreenshotTest.kt new file mode 100644 index 0000000..b52c0d1 --- /dev/null +++ b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/ScreenshotTest.kt @@ -0,0 +1,201 @@ +package app.coilforphoniebox.screenshot + +import android.graphics.drawable.ColorDrawable +import android.os.Looper +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.coilforphoniebox.HiltTestActivity +import app.coilforphoniebox.ui.theme.CoilTheme +import coil.Coil +import coil.ImageLoader +import coil.test.FakeImageLoaderEngine +import com.github.takahirom.roborazzi.captureRoboImage +import dagger.hilt.android.testing.HiltAndroidRule +import org.junit.Before +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import java.time.Duration + +/** + * Base for the screenshot tests: renders the UI on the JVM and writes it to a golden file. + * + * Robolectric with native graphics, rather than an emulator or Layoutlib previews, for one + * reason above the others — the clock. This UI interpolates elapsed time, counts a sleep + * timer down and debounces a search field, and Robolectric's paused looper holds all of that + * still. On a device the same screen is a slightly different picture every time it is taken. + * + * The content is composed into [HiltTestActivity], so `hiltViewModel()` resolves and a whole + * `CoilApp` can be captured rather than only a screen; `robolectric.properties` names + * `HiltTestApplication` as the application class for the same reason. Every concrete test + * class must carry `@HiltAndroidTest` — that annotation is what generates the component this + * rule installs. + * + * Nothing here writes a file during `./gradlew test`. [captureRoboImage] is inert unless + * Roborazzi's own tasks turn it on, so the ordinary test run only checks that everything still + * composes — which is worth having on its own, given none of this used to be covered. + */ +@RunWith(AndroidJUnit4::class) +abstract class ScreenshotTest { + + @get:Rule(order = 0) + val hilt = HiltAndroidRule(this) + + @get:Rule(order = 1) + val compose = createAndroidComposeRule() + + /** + * Cover art must never be a real request. The box's HTTP cover cache is not reachable from + * CI, and an image that arrives a frame late would make the golden depend on timing rather + * than on the layout — so every URL resolves to the same flat colour, instantly. + */ + @Before + fun installFakeImageLoader() { + val engine = FakeImageLoaderEngine.Builder() + .default(ColorDrawable(FAKE_COVER_COLOR)) + .build() + Coil.setImageLoader( + ImageLoader.Builder(RuntimeEnvironment.getApplication()) + .components { add(engine) } + .build(), + ) + } + + /** + * Renders [content] full screen and writes `src/testDebug/screenshots/[name].png`. + * + * A locale is not a parameter here: it has to be in place before the activity hosting the + * content exists, so it comes from `@Config(qualifiers = "+de")` on the test method or + * class. Setting it from inside the test body silently does nothing — the activity has + * already resolved its resources by then, and the golden comes out in English. + */ + protected fun capture( + name: String, + dark: Boolean = false, + content: @Composable () -> Unit, + ) { + show(dark, content) + captureRoot(name) + } + + /** + * Composes without capturing, for UI that has to be *put* into the state worth a picture — + * a tab selected, a destination navigated to, a debounce elapsed. Pair it with + * [captureRoot]. + */ + protected fun show( + dark: Boolean = false, + content: @Composable () -> Unit, + ) { + compose.setContent { + // The theme is passed its mode explicitly rather than reading the system setting, + // so a golden's appearance is decided by the test and not by a qualifier. This is + // the same wrapping MainActivity applies around CoilApp. + CoilTheme(darkTheme = dark, dynamicColor = false) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + content() + } + } + } + } + + protected fun captureRoot(name: String) { + captureTo("$GOLDEN_DIR/$name.png") + } + + /** + * Writes the window to an arbitrary path, relative to the `:app` module directory. + * + * Used by the store assets, which are products rather than baselines and therefore land + * outside the golden directory — under `fastlane/` and `docs/pages/`. + */ + protected fun captureTo(path: String) { + compose.onRoot().captureRoboImage(path) + } + + /** + * Whether Roborazzi is actually writing files this run. + * + * `captureRoboImage` is a no-op during a plain `./gradlew test`, so anything that + * post-processes a captured file has to know not to touch the copy already on disk. + */ + protected fun isRecording(): Boolean = + System.getProperty("roborazzi.test.record")?.toBoolean() == true + + /** + * Captures a single component with a little padding around it. + * + * The picture is still the whole window: Roborazzi captures the window rather than + * cropping to the node, whichever node it is handed. A component test therefore shortens + * the window instead, with `@Config(qualifiers = "+h200dp")` on the class, so the golden is + * the component and not a stripe of it above a screenful of background. + */ + protected fun captureComponent( + name: String, + dark: Boolean = false, + content: @Composable () -> Unit, + ) { + show(dark) { + Box(Modifier.padding(16.dp)) { content() } + } + captureRoot(name) + } + + /** + * Runs the main looper forward by [millis] of virtual time. + * + * Needed wherever the UI waits on a coroutine `delay` — the search field's debounce is + * the case here. Robolectric's clock does not move on its own, which is the whole reason + * these goldens are stable, so anything time-based has to be advanced deliberately. + */ + protected fun advanceTime(millis: Long) { + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(millis)) + compose.waitForIdle() + } + + /** + * Advances the clock until [text] is on screen, and fails the test if it never is. + * + * Compose's own `waitUntil` is no use here: it advances the *compose* clock, and what a + * debounced flow is waiting on is the looper's. So this steps [advanceTime] instead, which + * runs the delayed work the coroutine actually posted. + */ + protected fun awaitText(text: String, timeoutMillis: Long = 5_000) { + var waited = 0L + while (waited <= timeoutMillis) { + if (compose.onAllNodes(hasText(text, substring = true)) + .fetchSemanticsNodes().isNotEmpty() + ) { + return + } + advanceTime(POLL_MILLIS) + waited += POLL_MILLIS + } + error("Timed out after ${timeoutMillis}ms waiting for \"$text\" to appear") + } + + private companion object { + /** Committed next to the tests so a review sees the picture beside the change. */ + const val GOLDEN_DIR = "src/testDebug/screenshots" + + /** How far the clock steps between checks in [awaitText]. */ + const val POLL_MILLIS = 250L + + /** A muted green, close enough to real artwork to show how the layout carries it. */ + const val FAKE_COVER_COLOR = 0xFF3B5A46.toInt() + } +} diff --git a/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/StoreAssetTest.kt b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/StoreAssetTest.kt new file mode 100644 index 0000000..7ba483a --- /dev/null +++ b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/StoreAssetTest.kt @@ -0,0 +1,201 @@ +package app.coilforphoniebox.screenshot + +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.hilt.navigation.compose.hiltViewModel +import app.coilforphoniebox.R +import app.coilforphoniebox.ui.AppViewModel +import app.coilforphoniebox.ui.CoilApp +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Test +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import javax.inject.Inject + +/** + * The Play Store listing images and the website's screenshots, from the same harness as the + * goldens but with a different purpose — and therefore different rules. + * + * These are **products, not baselines**: they are regenerated deliberately and are not + * compared against anything, so they are excluded from the verify job by a `--tests` filter. + * A UI change should fail one job, not two. See AGENTS.md, "Store assets". + * + * ./gradlew :app:recordRoborazziDebug --tests '*StoreAssetTest' + * + * Play's rules are what dictate the shape of what follows: + * + * - **24-bit PNG, no alpha.** Roborazzi writes RGBA, so every file is flattened onto an opaque + * background on the way out. A screenshot with an alpha channel is rejected at upload. + * - **The longest side may be at most twice the shortest**, between 320 and 3840 px. The + * goldens' 1078×2399 phone frame is 2.23:1 and would be refused, which is the main reason + * these are generated separately rather than copied across. + * - Play recommends 1080×1920 for phones, which `w360dp-h640dp` at xxhdpi hits exactly. + * + * `tools/check_store_metadata.sh` enforces all of that on the files themselves, so a + * hand-added image is checked as well as a generated one. + * + * Only English is generated for now. Every other launch locale would be one more subclass with + * a locale qualifier — but three of the five translations are unreviewed drafts missing 43 + * strings, and a store screenshot with English fallbacks showing in it is worse than no + * localised screenshot at all. + */ +abstract class StoreAssetTest : ScreenshotTest() { + + /** `phoneScreenshots`, `sevenInchScreenshots` or `tenInchScreenshots`, per Play's layout. */ + protected abstract val imagesDir: String + + @Inject lateinit var boxes: FakeBoxRepository + + @Inject lateinit var player: FakePlayerRepository + + @Inject lateinit var library: FakeLibraryRepository + + @Inject lateinit var favorites: FakeFavoriteRepository + + @Before + fun useStoreContent() { + hilt.inject() + player.status.value = StoreFixtures.nowPlaying + library.allFolders.value = mapOf("" to StoreFixtures.libraryRoot) + library.results.value = StoreFixtures.searchResults + favorites.all.value = StoreFixtures.favorites + } + + private fun string(id: Int): String = RuntimeEnvironment.getApplication().getString(id) + + private fun showApp() { + show { CoilApp(appViewModel = hiltViewModel()) } + } + + private fun navigateTo(label: Int) { + compose.onNodeWithText(string(label)).performClick() + compose.waitForIdle() + } + + /** + * Brings the transport and volume controls into view. + * + * The player is taller than a store-legal frame: Play caps the long side at twice the + * short one, and the cover alone is a full screen width square. Without this the listing's + * first screenshot would show a player with no play button in it. Scrolling to a node is + * exact — no fling, no gesture distance to get wrong — so the result is the same picture + * every run. + */ + private fun scrollToControls() { + compose.onNodeWithContentDescription(string(R.string.action_volume)).performScrollTo() + compose.waitForIdle() + } + + /** + * Captures straight into the fastlane layout. + * + * The numeric prefix is the order Play and fastlane list them in, which is filename order — + * so the numbers are the running order of the listing, not decoration. + * + * Roborazzi writes RGBA, which Play rejects. Flattening happens afterwards in the + * `flattenStoreAssets` Gradle task rather than here, because `javax.imageio` and `java.awt` + * are not on an Android unit test's classpath at all. That task also puts the phone set on + * the website, and `recordRoborazziDebug` is finalised by it, so one command still does the + * whole job. + */ + private fun store(name: String) { + captureTo("$STORE_DIR/$imagesDir/$name.png") + } + + @Test + fun player() { + showApp() + scrollToControls() + store("01_player") + } + + @Test + fun library() { + showApp() + navigateTo(R.string.nav_library) + store("02_library") + } + + @Test + fun favourites() { + showApp() + navigateTo(R.string.nav_favourites) + store("03_favourites") + } + + /** Typed rather than injected, so the picture shows a real query in a real field. */ + @Test + fun search() { + showApp() + navigateTo(R.string.nav_library) + compose.onNode(hasSetTextAction()).performTextInput(SEARCH_QUERY) + + // Results are debounced, and a store screenshot of an empty result list would be worse + // than no screenshot at all — so this waits for a hit to actually be on screen and + // fails the run if one never arrives, rather than photographing whatever is there. + awaitText(SEARCH_HIT) + store("04_search") + } + + /** The one feature Coil has that the box's own web UI does not put a countdown on. */ + @Test + fun sleep_timer() { + player.sleepTimer.value = StoreFixtures.sleepTimer + showApp() + scrollToControls() + store("05_sleep_timer") + } + + private companion object { + /** Relative to the `:app` module directory, which is where a test's paths resolve. */ + const val STORE_DIR = "../fastlane/metadata/android/en-US/images" + + const val SEARCH_QUERY = "story" + + /** A result the query must produce, so the wait has something concrete to wait for. */ + const val SEARCH_HIT = "Bedtime Stories" + } +} + +/** + * A phone: 1233×2460, from 411×820 dp at xxhdpi. + * + * Sized to be as tall as Play allows and no taller — 1.995:1, just inside the rule that the + * long side may not exceed twice the short one. Today's phones are taller than that (the + * goldens' frame is 2.23:1), and the extra dp matter here: every 20 dp given back is 20 dp of + * the player that fits without scrolling. It comfortably clears Play's 1080 px recommendation + * for the screenshots that get surfaced in recommendation slots. + * + * This is also the set the website shows — `flattenStoreAssets` copies `phoneScreenshots` into + * the Pages tree, since nobody browses a landing page for tablet pictures. + */ +@HiltAndroidTest +@Config(qualifiers = "w411dp-h820dp-normal-long-notround-any-xxhdpi-keyshidden-nonav") +class PhoneStoreAssetTest : StoreAssetTest() { + override val imagesDir = "phoneScreenshots" +} + +/** A 7-inch tablet in portrait: 1200×1920. */ +@HiltAndroidTest +@Config(qualifiers = "w600dp-h960dp-large-notlong-notround-any-xhdpi-keyshidden-nonav") +class SevenInchStoreAssetTest : StoreAssetTest() { + override val imagesDir = "sevenInchScreenshots" +} + +/** + * A 10-inch tablet in landscape: 2560×1600. + * + * Worth looking at before uploading. The UI is single-column everywhere, so these show a phone + * layout stretched across a tablet — accurate, and not flattering. Until the tablet layout is + * addressed, publishing the phone set alone may serve the listing better. + */ +@HiltAndroidTest +@Config(qualifiers = "w1280dp-h800dp-xlarge-notlong-notround-any-xhdpi-keyshidden-nonav") +class TenInchStoreAssetTest : StoreAssetTest() { + override val imagesDir = "tenInchScreenshots" +} diff --git a/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/StoreFixtures.kt b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/StoreFixtures.kt new file mode 100644 index 0000000..f54518f --- /dev/null +++ b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/StoreFixtures.kt @@ -0,0 +1,127 @@ +package app.coilforphoniebox.screenshot + +import app.coilforphoniebox.domain.model.Favorite +import app.coilforphoniebox.domain.model.FavoriteType +import app.coilforphoniebox.domain.model.FolderContent +import app.coilforphoniebox.domain.model.LibraryFolder +import app.coilforphoniebox.domain.model.LibrarySearchResults +import app.coilforphoniebox.domain.model.LibraryTrack +import app.coilforphoniebox.domain.model.PlaybackState +import app.coilforphoniebox.domain.model.PlayerStatus +import app.coilforphoniebox.domain.model.SleepTimerStatus + +/** + * Content for the store listing and the website — a different job from [Fixtures]. + * + * A golden's content is chosen to *stress* the layout: long titles that ellipsise, umlauts, + * a folder name that runs off the edge. A store screenshot has the opposite job, so names here + * are short enough to be read at thumbnail size and nothing truncates. + * + * It is still invented content rather than anyone's real library, which is both what Play asks + * for (a screenshot must show the app, and this is exactly what the app shows) and the only + * way to keep the pictures reproducible. + */ +object StoreFixtures { + + val nowPlaying = PlayerStatus( + state = PlaybackState.PLAY, + title = "The Cave Behind the Waterfall", + artist = "Detective Stories", + album = "The Missing Key", + file = "Detective Stories/The Missing Key/03 The Cave.mp3", + elapsedSeconds = 143.0, + durationSeconds = 372.0, + playlistPosition = 3, + playlistLength = 12, + ) + + val sleepTimer = SleepTimerStatus( + running = true, + remainingSeconds = 1_500, + requestedSeconds = 1_800, + receivedAtElapsedMillis = 0L, + ) + + private fun folder(name: String) = LibraryFolder( + boxId = Fixtures.livingRoom.id, + path = name, + parentPath = null, + displayName = name, + hasChildren = true, + cachedAt = Fixtures.NOW, + ) + + val libraryRoot = FolderContent( + path = FolderContent.ROOT, + folders = listOf( + folder("Bedtime Stories"), + folder("Detective Stories"), + folder("Fairy Tales"), + folder("Nursery Rhymes"), + folder("Sing-Along Songs"), + folder("Bärenstark"), + ), + cachedAt = Fixtures.cachedThreeDaysAgo, + ) + + private fun track(number: Int, title: String, seconds: Double) = LibraryTrack( + boxId = Fixtures.livingRoom.id, + url = "Detective Stories/The Missing Key/0$number.mp3", + parentPath = "Detective Stories/The Missing Key", + title = title, + artist = "Detective Stories", + album = "The Missing Key", + trackNo = number, + durationSeconds = seconds, + ) + + /** What "story" finds — one hit of each kind, so the screenshot shows what search is for. */ + val searchResults = LibrarySearchResults( + folders = listOf(folder("Bedtime Stories")), + albums = listOf(Fixtures.albums.first { it.album == "The Missing Key" }), + tracks = listOf( + track(1, "The Letter", 301.0), + track(3, "The Cave Behind the Waterfall", 372.0), + ), + ) + + val favorites = listOf( + Favorite( + id = 1, + boxId = Fixtures.livingRoom.id, + label = "Bedtime Stories", + type = FavoriteType.FOLDER, + folder = "Bedtime Stories", + sortIndex = 0, + launchCount = 31, + ), + Favorite( + id = 2, + boxId = Fixtures.livingRoom.id, + label = "The Missing Key", + type = FavoriteType.ALBUM, + albumArtist = "Detective Stories", + album = "The Missing Key", + sortIndex = 1, + launchCount = 12, + ), + Favorite( + id = 3, + boxId = Fixtures.livingRoom.id, + label = "Fairy Tales", + type = FavoriteType.FOLDER, + folder = "Fairy Tales", + sortIndex = 2, + launchCount = 9, + ), + Favorite( + id = 4, + boxId = Fixtures.livingRoom.id, + label = "Nursery Rhymes", + type = FavoriteType.FOLDER, + folder = "Nursery Rhymes", + sortIndex = 3, + launchCount = 5, + ), + ) +} diff --git a/app/src/testDebug/resources/robolectric.properties b/app/src/testDebug/resources/robolectric.properties new file mode 100644 index 0000000..3ccaee4 --- /dev/null +++ b/app/src/testDebug/resources/robolectric.properties @@ -0,0 +1,21 @@ +# Shared configuration for every Robolectric test in this module. +# +# The Hilt test application, so the graph exists and `hiltViewModel()` resolves inside a +# captured `CoilApp`. Set here rather than per class because every test needs it. +application=dagger.hilt.android.testing.HiltTestApplication +# +# One below targetSdk, and deliberately: Robolectric's SDK 36 image needs a newer JDK than the +# Temurin 17 every workflow here pins, so it runs on a developer's JDK 21+ machine and throws +# `UnsupportedOperationException` on the CI runner — where the goldens are recorded. SDK 35 runs +# on 17, which makes local and CI the same platform. Nothing on these screens renders +# differently between the two; raising this means raising CI's JDK first. +sdk=35 +# +# NATIVE graphics is what makes a screenshot a screenshot: without it Robolectric draws +# nothing and every golden would be a blank bitmap. +graphicsMode=NATIVE +# +# A mid-size phone in portrait — the same dimensions as Roborazzi's own "MediumPhone" device, +# spelled out rather than referenced so a golden's size cannot change under us when a library +# updates its idea of a current phone. Portrait is implied by the width being under the height. +qualifiers=w411dp-h914dp-normal-long-notround-any-420dpi-keyshidden-nonav diff --git a/app/src/testDebug/screenshots/app/favourites_phone.png b/app/src/testDebug/screenshots/app/favourites_phone.png new file mode 100644 index 0000000..c7265a6 Binary files /dev/null and b/app/src/testDebug/screenshots/app/favourites_phone.png differ diff --git a/app/src/testDebug/screenshots/app/favourites_small.png b/app/src/testDebug/screenshots/app/favourites_small.png new file mode 100644 index 0000000..a312883 Binary files /dev/null and b/app/src/testDebug/screenshots/app/favourites_small.png differ diff --git a/app/src/testDebug/screenshots/app/favourites_tablet.png b/app/src/testDebug/screenshots/app/favourites_tablet.png new file mode 100644 index 0000000..32ce5af Binary files /dev/null and b/app/src/testDebug/screenshots/app/favourites_tablet.png differ diff --git a/app/src/testDebug/screenshots/app/library_phone.png b/app/src/testDebug/screenshots/app/library_phone.png new file mode 100644 index 0000000..44c0716 Binary files /dev/null and b/app/src/testDebug/screenshots/app/library_phone.png differ diff --git a/app/src/testDebug/screenshots/app/library_small.png b/app/src/testDebug/screenshots/app/library_small.png new file mode 100644 index 0000000..87bc323 Binary files /dev/null and b/app/src/testDebug/screenshots/app/library_small.png differ diff --git a/app/src/testDebug/screenshots/app/library_tablet.png b/app/src/testDebug/screenshots/app/library_tablet.png new file mode 100644 index 0000000..090f64c Binary files /dev/null and b/app/src/testDebug/screenshots/app/library_tablet.png differ diff --git a/app/src/testDebug/screenshots/app/offline_phone.png b/app/src/testDebug/screenshots/app/offline_phone.png new file mode 100644 index 0000000..2e55a04 Binary files /dev/null and b/app/src/testDebug/screenshots/app/offline_phone.png differ diff --git a/app/src/testDebug/screenshots/app/offline_small.png b/app/src/testDebug/screenshots/app/offline_small.png new file mode 100644 index 0000000..890055a Binary files /dev/null and b/app/src/testDebug/screenshots/app/offline_small.png differ diff --git a/app/src/testDebug/screenshots/app/offline_tablet.png b/app/src/testDebug/screenshots/app/offline_tablet.png new file mode 100644 index 0000000..fc521a0 Binary files /dev/null and b/app/src/testDebug/screenshots/app/offline_tablet.png differ diff --git a/app/src/testDebug/screenshots/app/onboarding_phone.png b/app/src/testDebug/screenshots/app/onboarding_phone.png new file mode 100644 index 0000000..16ef960 Binary files /dev/null and b/app/src/testDebug/screenshots/app/onboarding_phone.png differ diff --git a/app/src/testDebug/screenshots/app/onboarding_small.png b/app/src/testDebug/screenshots/app/onboarding_small.png new file mode 100644 index 0000000..ae3f1f4 Binary files /dev/null and b/app/src/testDebug/screenshots/app/onboarding_small.png differ diff --git a/app/src/testDebug/screenshots/app/onboarding_tablet.png b/app/src/testDebug/screenshots/app/onboarding_tablet.png new file mode 100644 index 0000000..4be7eec Binary files /dev/null and b/app/src/testDebug/screenshots/app/onboarding_tablet.png differ diff --git a/app/src/testDebug/screenshots/app/player_dark_phone.png b/app/src/testDebug/screenshots/app/player_dark_phone.png new file mode 100644 index 0000000..352b1b7 Binary files /dev/null and b/app/src/testDebug/screenshots/app/player_dark_phone.png differ diff --git a/app/src/testDebug/screenshots/app/player_dark_small.png b/app/src/testDebug/screenshots/app/player_dark_small.png new file mode 100644 index 0000000..e7415d5 Binary files /dev/null and b/app/src/testDebug/screenshots/app/player_dark_small.png differ diff --git a/app/src/testDebug/screenshots/app/player_dark_tablet.png b/app/src/testDebug/screenshots/app/player_dark_tablet.png new file mode 100644 index 0000000..198206d Binary files /dev/null and b/app/src/testDebug/screenshots/app/player_dark_tablet.png differ diff --git a/app/src/testDebug/screenshots/app/player_phone.png b/app/src/testDebug/screenshots/app/player_phone.png new file mode 100644 index 0000000..3243097 Binary files /dev/null and b/app/src/testDebug/screenshots/app/player_phone.png differ diff --git a/app/src/testDebug/screenshots/app/player_small.png b/app/src/testDebug/screenshots/app/player_small.png new file mode 100644 index 0000000..11d9870 Binary files /dev/null and b/app/src/testDebug/screenshots/app/player_small.png differ diff --git a/app/src/testDebug/screenshots/app/player_tablet.png b/app/src/testDebug/screenshots/app/player_tablet.png new file mode 100644 index 0000000..347deed Binary files /dev/null and b/app/src/testDebug/screenshots/app/player_tablet.png differ diff --git a/app/src/testDebug/screenshots/app/settings_phone.png b/app/src/testDebug/screenshots/app/settings_phone.png new file mode 100644 index 0000000..dfda367 Binary files /dev/null and b/app/src/testDebug/screenshots/app/settings_phone.png differ diff --git a/app/src/testDebug/screenshots/app/settings_small.png b/app/src/testDebug/screenshots/app/settings_small.png new file mode 100644 index 0000000..3158e94 Binary files /dev/null and b/app/src/testDebug/screenshots/app/settings_small.png differ diff --git a/app/src/testDebug/screenshots/app/settings_tablet.png b/app/src/testDebug/screenshots/app/settings_tablet.png new file mode 100644 index 0000000..6e5c254 Binary files /dev/null and b/app/src/testDebug/screenshots/app/settings_tablet.png differ diff --git a/app/src/testDebug/screenshots/chrome/box_indicator_connected.png b/app/src/testDebug/screenshots/chrome/box_indicator_connected.png new file mode 100644 index 0000000..bbfe60e Binary files /dev/null and b/app/src/testDebug/screenshots/chrome/box_indicator_connected.png differ diff --git a/app/src/testDebug/screenshots/chrome/box_indicator_degraded.png b/app/src/testDebug/screenshots/chrome/box_indicator_degraded.png new file mode 100644 index 0000000..92af142 Binary files /dev/null and b/app/src/testDebug/screenshots/chrome/box_indicator_degraded.png differ diff --git a/app/src/testDebug/screenshots/chrome/box_indicator_disconnected.png b/app/src/testDebug/screenshots/chrome/box_indicator_disconnected.png new file mode 100644 index 0000000..1f1b699 Binary files /dev/null and b/app/src/testDebug/screenshots/chrome/box_indicator_disconnected.png differ diff --git a/app/src/testDebug/screenshots/chrome/box_indicator_switchable.png b/app/src/testDebug/screenshots/chrome/box_indicator_switchable.png new file mode 100644 index 0000000..6d45b82 Binary files /dev/null and b/app/src/testDebug/screenshots/chrome/box_indicator_switchable.png differ diff --git a/app/src/testDebug/screenshots/chrome/mini_player_paused_dark.png b/app/src/testDebug/screenshots/chrome/mini_player_paused_dark.png new file mode 100644 index 0000000..d6d77ca Binary files /dev/null and b/app/src/testDebug/screenshots/chrome/mini_player_paused_dark.png differ diff --git a/app/src/testDebug/screenshots/chrome/mini_player_playing.png b/app/src/testDebug/screenshots/chrome/mini_player_playing.png new file mode 100644 index 0000000..3673e3f Binary files /dev/null and b/app/src/testDebug/screenshots/chrome/mini_player_playing.png differ diff --git a/app/src/testDebug/screenshots/chrome/offline_banner.png b/app/src/testDebug/screenshots/chrome/offline_banner.png new file mode 100644 index 0000000..8ee9492 Binary files /dev/null and b/app/src/testDebug/screenshots/chrome/offline_banner.png differ diff --git a/app/src/testDebug/screenshots/library/albums_light.png b/app/src/testDebug/screenshots/library/albums_light.png new file mode 100644 index 0000000..a2dc268 Binary files /dev/null and b/app/src/testDebug/screenshots/library/albums_light.png differ diff --git a/app/src/testDebug/screenshots/library/empty_light.png b/app/src/testDebug/screenshots/library/empty_light.png new file mode 100644 index 0000000..a4836e1 Binary files /dev/null and b/app/src/testDebug/screenshots/library/empty_light.png differ diff --git a/app/src/testDebug/screenshots/library/folders_dark.png b/app/src/testDebug/screenshots/library/folders_dark.png new file mode 100644 index 0000000..5aabae5 Binary files /dev/null and b/app/src/testDebug/screenshots/library/folders_dark.png differ diff --git a/app/src/testDebug/screenshots/library/folders_de.png b/app/src/testDebug/screenshots/library/folders_de.png new file mode 100644 index 0000000..dacbad2 Binary files /dev/null and b/app/src/testDebug/screenshots/library/folders_de.png differ diff --git a/app/src/testDebug/screenshots/library/folders_light.png b/app/src/testDebug/screenshots/library/folders_light.png new file mode 100644 index 0000000..0edc3d2 Binary files /dev/null and b/app/src/testDebug/screenshots/library/folders_light.png differ diff --git a/app/src/testDebug/screenshots/library/search_empty_light.png b/app/src/testDebug/screenshots/library/search_empty_light.png new file mode 100644 index 0000000..cbea007 Binary files /dev/null and b/app/src/testDebug/screenshots/library/search_empty_light.png differ diff --git a/app/src/testDebug/screenshots/library/search_light.png b/app/src/testDebug/screenshots/library/search_light.png new file mode 100644 index 0000000..5e2f956 Binary files /dev/null and b/app/src/testDebug/screenshots/library/search_light.png differ diff --git a/app/src/testDebug/screenshots/library/tracks_light.png b/app/src/testDebug/screenshots/library/tracks_light.png new file mode 100644 index 0000000..0879be1 Binary files /dev/null and b/app/src/testDebug/screenshots/library/tracks_light.png differ diff --git a/app/src/testDebug/screenshots/player/idle_de.png b/app/src/testDebug/screenshots/player/idle_de.png new file mode 100644 index 0000000..93191d1 Binary files /dev/null and b/app/src/testDebug/screenshots/player/idle_de.png differ diff --git a/app/src/testDebug/screenshots/player/idle_light.png b/app/src/testDebug/screenshots/player/idle_light.png new file mode 100644 index 0000000..a6c6514 Binary files /dev/null and b/app/src/testDebug/screenshots/player/idle_light.png differ diff --git a/app/src/testDebug/screenshots/player/paused_light.png b/app/src/testDebug/screenshots/player/paused_light.png new file mode 100644 index 0000000..5dd53d8 Binary files /dev/null and b/app/src/testDebug/screenshots/player/paused_light.png differ diff --git a/app/src/testDebug/screenshots/player/playing_dark.png b/app/src/testDebug/screenshots/player/playing_dark.png new file mode 100644 index 0000000..f878b70 Binary files /dev/null and b/app/src/testDebug/screenshots/player/playing_dark.png differ diff --git a/app/src/testDebug/screenshots/player/playing_landscape.png b/app/src/testDebug/screenshots/player/playing_landscape.png new file mode 100644 index 0000000..b3a26cd Binary files /dev/null and b/app/src/testDebug/screenshots/player/playing_landscape.png differ diff --git a/app/src/testDebug/screenshots/player/playing_light.png b/app/src/testDebug/screenshots/player/playing_light.png new file mode 100644 index 0000000..6836528 Binary files /dev/null and b/app/src/testDebug/screenshots/player/playing_light.png differ diff --git a/app/src/testDebug/screenshots/player/sleep_timer_light.png b/app/src/testDebug/screenshots/player/sleep_timer_light.png new file mode 100644 index 0000000..32cde5e Binary files /dev/null and b/app/src/testDebug/screenshots/player/sleep_timer_light.png differ diff --git a/app/src/testDebug/screenshots/player/web_radio_light.png b/app/src/testDebug/screenshots/player/web_radio_light.png new file mode 100644 index 0000000..b5faa05 Binary files /dev/null and b/app/src/testDebug/screenshots/player/web_radio_light.png differ diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..c0530ab --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.ksp) apply false +} diff --git a/core-data/build.gradle.kts b/core-data/build.gradle.kts new file mode 100644 index 0000000..2fde546 --- /dev/null +++ b/core-data/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "app.coilforphoniebox.data" + compileSdk = 36 + + defaultConfig { + minSdk = 26 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + +dependencies { + api(project(":core-domain")) + implementation(project(":core-transport")) + + implementation(libs.androidx.core.ktx) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + + implementation(libs.androidx.datastore.preferences) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/core-data/schemas/app.coilforphoniebox.data.db.CoilDatabase/1.json b/core-data/schemas/app.coilforphoniebox.data.db.CoilDatabase/1.json new file mode 100644 index 0000000..614396e --- /dev/null +++ b/core-data/schemas/app.coilforphoniebox.data.db.CoilDatabase/1.json @@ -0,0 +1,424 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "5ddd7199d20c9639ef0ee2eada4fb194", + "entities": [ + { + "tableName": "boxes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `displayName` TEXT NOT NULL, `host` TEXT NOT NULL, `rpcPort` INTEGER NOT NULL, `pubPort` INTEGER NOT NULL, `addedAt` INTEGER NOT NULL, `autoSessionEnabled` INTEGER NOT NULL, `networkSsid` TEXT, `lastSeenAt` INTEGER, `sortIndex` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rpcPort", + "columnName": "rpcPort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pubPort", + "columnName": "pubPort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoSessionEnabled", + "columnName": "autoSessionEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkSsid", + "columnName": "networkSsid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSeenAt", + "columnName": "lastSeenAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sortIndex", + "columnName": "sortIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "library_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`boxId` TEXT NOT NULL, `path` TEXT NOT NULL, `parentPath` TEXT, `displayName` TEXT NOT NULL, `hasChildren` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, `contentCachedAt` INTEGER, PRIMARY KEY(`boxId`, `path`), FOREIGN KEY(`boxId`) REFERENCES `boxes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "boxId", + "columnName": "boxId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentPath", + "columnName": "parentPath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasChildren", + "columnName": "hasChildren", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cachedAt", + "columnName": "cachedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCachedAt", + "columnName": "contentCachedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "boxId", + "path" + ] + }, + "indices": [ + { + "name": "index_library_folders_boxId_parentPath", + "unique": false, + "columnNames": [ + "boxId", + "parentPath" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_folders_boxId_parentPath` ON `${TABLE_NAME}` (`boxId`, `parentPath`)" + } + ], + "foreignKeys": [ + { + "table": "boxes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "boxId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "library_tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`boxId` TEXT NOT NULL, `url` TEXT NOT NULL, `parentPath` TEXT, `title` TEXT, `artist` TEXT, `album` TEXT, `trackNo` INTEGER, `durationSeconds` REAL, PRIMARY KEY(`boxId`, `url`), FOREIGN KEY(`boxId`) REFERENCES `boxes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "boxId", + "columnName": "boxId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentPath", + "columnName": "parentPath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "trackNo", + "columnName": "trackNo", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "boxId", + "url" + ] + }, + "indices": [ + { + "name": "index_library_tracks_boxId_parentPath", + "unique": false, + "columnNames": [ + "boxId", + "parentPath" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_tracks_boxId_parentPath` ON `${TABLE_NAME}` (`boxId`, `parentPath`)" + } + ], + "foreignKeys": [ + { + "table": "boxes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "boxId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "library_albums", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`boxId` TEXT NOT NULL, `albumArtist` TEXT NOT NULL, `album` TEXT NOT NULL, `coverFile` TEXT, `cachedAt` INTEGER NOT NULL, PRIMARY KEY(`boxId`, `albumArtist`, `album`), FOREIGN KEY(`boxId`) REFERENCES `boxes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "boxId", + "columnName": "boxId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "albumArtist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coverFile", + "columnName": "coverFile", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cachedAt", + "columnName": "cachedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "boxId", + "albumArtist", + "album" + ] + }, + "indices": [ + { + "name": "index_library_albums_boxId", + "unique": false, + "columnNames": [ + "boxId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_albums_boxId` ON `${TABLE_NAME}` (`boxId`)" + } + ], + "foreignKeys": [ + { + "table": "boxes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "boxId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `boxId` TEXT NOT NULL, `label` TEXT NOT NULL, `type` TEXT NOT NULL, `folder` TEXT, `albumArtist` TEXT, `album` TEXT, `coverFile` TEXT, `sortIndex` INTEGER NOT NULL, `launchCount` INTEGER NOT NULL, `shortcutPinned` INTEGER NOT NULL, FOREIGN KEY(`boxId`) REFERENCES `boxes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "boxId", + "columnName": "boxId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "folder", + "columnName": "folder", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumArtist", + "columnName": "albumArtist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverFile", + "columnName": "coverFile", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sortIndex", + "columnName": "sortIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchCount", + "columnName": "launchCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shortcutPinned", + "columnName": "shortcutPinned", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_favorites_boxId", + "unique": false, + "columnNames": [ + "boxId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_favorites_boxId` ON `${TABLE_NAME}` (`boxId`)" + } + ], + "foreignKeys": [ + { + "table": "boxes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "boxId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "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, '5ddd7199d20c9639ef0ee2eada4fb194')" + ] + } +} \ No newline at end of file diff --git a/core-data/schemas/app.coilforphoniebox.data.db.CoilDatabase/2.json b/core-data/schemas/app.coilforphoniebox.data.db.CoilDatabase/2.json new file mode 100644 index 0000000..a7f63b2 --- /dev/null +++ b/core-data/schemas/app.coilforphoniebox.data.db.CoilDatabase/2.json @@ -0,0 +1,430 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "adce28a4aa580b7aa6d8be797d1dea05", + "entities": [ + { + "tableName": "boxes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `displayName` TEXT NOT NULL, `host` TEXT NOT NULL, `rpcPort` INTEGER NOT NULL, `pubPort` INTEGER NOT NULL, `addedAt` INTEGER NOT NULL, `autoSessionEnabled` INTEGER NOT NULL, `networkSsid` TEXT, `lastSeenAt` INTEGER, `sortIndex` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rpcPort", + "columnName": "rpcPort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pubPort", + "columnName": "pubPort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoSessionEnabled", + "columnName": "autoSessionEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkSsid", + "columnName": "networkSsid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSeenAt", + "columnName": "lastSeenAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sortIndex", + "columnName": "sortIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "library_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`boxId` TEXT NOT NULL, `path` TEXT NOT NULL, `parentPath` TEXT, `displayName` TEXT NOT NULL, `hasChildren` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, `contentCachedAt` INTEGER, PRIMARY KEY(`boxId`, `path`), FOREIGN KEY(`boxId`) REFERENCES `boxes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "boxId", + "columnName": "boxId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentPath", + "columnName": "parentPath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasChildren", + "columnName": "hasChildren", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cachedAt", + "columnName": "cachedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCachedAt", + "columnName": "contentCachedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "boxId", + "path" + ] + }, + "indices": [ + { + "name": "index_library_folders_boxId_parentPath", + "unique": false, + "columnNames": [ + "boxId", + "parentPath" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_folders_boxId_parentPath` ON `${TABLE_NAME}` (`boxId`, `parentPath`)" + } + ], + "foreignKeys": [ + { + "table": "boxes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "boxId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "library_tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`boxId` TEXT NOT NULL, `url` TEXT NOT NULL, `parentPath` TEXT, `title` TEXT, `artist` TEXT, `album` TEXT, `trackNo` INTEGER, `durationSeconds` REAL, PRIMARY KEY(`boxId`, `url`), FOREIGN KEY(`boxId`) REFERENCES `boxes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "boxId", + "columnName": "boxId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentPath", + "columnName": "parentPath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "trackNo", + "columnName": "trackNo", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "boxId", + "url" + ] + }, + "indices": [ + { + "name": "index_library_tracks_boxId_parentPath", + "unique": false, + "columnNames": [ + "boxId", + "parentPath" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_tracks_boxId_parentPath` ON `${TABLE_NAME}` (`boxId`, `parentPath`)" + } + ], + "foreignKeys": [ + { + "table": "boxes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "boxId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "library_albums", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`boxId` TEXT NOT NULL, `albumArtist` TEXT NOT NULL, `album` TEXT NOT NULL, `coverFile` TEXT, `cachedAt` INTEGER NOT NULL, PRIMARY KEY(`boxId`, `albumArtist`, `album`), FOREIGN KEY(`boxId`) REFERENCES `boxes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "boxId", + "columnName": "boxId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "albumArtist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coverFile", + "columnName": "coverFile", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cachedAt", + "columnName": "cachedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "boxId", + "albumArtist", + "album" + ] + }, + "indices": [ + { + "name": "index_library_albums_boxId", + "unique": false, + "columnNames": [ + "boxId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_albums_boxId` ON `${TABLE_NAME}` (`boxId`)" + } + ], + "foreignKeys": [ + { + "table": "boxes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "boxId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `boxId` TEXT NOT NULL, `label` TEXT NOT NULL, `type` TEXT NOT NULL, `folder` TEXT, `albumArtist` TEXT, `album` TEXT, `trackUrl` TEXT, `coverFile` TEXT, `sortIndex` INTEGER NOT NULL, `launchCount` INTEGER NOT NULL, `shortcutPinned` INTEGER NOT NULL, FOREIGN KEY(`boxId`) REFERENCES `boxes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "boxId", + "columnName": "boxId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "folder", + "columnName": "folder", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumArtist", + "columnName": "albumArtist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "trackUrl", + "columnName": "trackUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverFile", + "columnName": "coverFile", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sortIndex", + "columnName": "sortIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchCount", + "columnName": "launchCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shortcutPinned", + "columnName": "shortcutPinned", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_favorites_boxId", + "unique": false, + "columnNames": [ + "boxId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_favorites_boxId` ON `${TABLE_NAME}` (`boxId`)" + } + ], + "foreignKeys": [ + { + "table": "boxes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "boxId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "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, 'adce28a4aa580b7aa6d8be797d1dea05')" + ] + } +} \ No newline at end of file diff --git a/core-data/schemas/app.coilforphoniebox.data.db.CoilDatabase/3.json b/core-data/schemas/app.coilforphoniebox.data.db.CoilDatabase/3.json new file mode 100644 index 0000000..5485147 --- /dev/null +++ b/core-data/schemas/app.coilforphoniebox.data.db.CoilDatabase/3.json @@ -0,0 +1,451 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "7cca3269b0e9a20ae87e581849bf4813", + "entities": [ + { + "tableName": "boxes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `displayName` TEXT NOT NULL, `host` TEXT NOT NULL, `rpcPort` INTEGER NOT NULL, `pubPort` INTEGER NOT NULL, `addedAt` INTEGER NOT NULL, `autoSessionEnabled` INTEGER NOT NULL, `networkSsid` TEXT, `lastSeenAt` INTEGER, `sortIndex` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rpcPort", + "columnName": "rpcPort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pubPort", + "columnName": "pubPort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoSessionEnabled", + "columnName": "autoSessionEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkSsid", + "columnName": "networkSsid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSeenAt", + "columnName": "lastSeenAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sortIndex", + "columnName": "sortIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "library_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`boxId` TEXT NOT NULL, `path` TEXT NOT NULL, `parentPath` TEXT, `displayName` TEXT NOT NULL, `hasChildren` INTEGER NOT NULL, `searchText` TEXT NOT NULL DEFAULT '', `cachedAt` INTEGER NOT NULL, `contentCachedAt` INTEGER, PRIMARY KEY(`boxId`, `path`), FOREIGN KEY(`boxId`) REFERENCES `boxes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "boxId", + "columnName": "boxId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentPath", + "columnName": "parentPath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasChildren", + "columnName": "hasChildren", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "searchText", + "columnName": "searchText", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "cachedAt", + "columnName": "cachedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCachedAt", + "columnName": "contentCachedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "boxId", + "path" + ] + }, + "indices": [ + { + "name": "index_library_folders_boxId_parentPath", + "unique": false, + "columnNames": [ + "boxId", + "parentPath" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_folders_boxId_parentPath` ON `${TABLE_NAME}` (`boxId`, `parentPath`)" + } + ], + "foreignKeys": [ + { + "table": "boxes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "boxId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "library_tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`boxId` TEXT NOT NULL, `url` TEXT NOT NULL, `parentPath` TEXT, `title` TEXT, `artist` TEXT, `album` TEXT, `trackNo` INTEGER, `durationSeconds` REAL, `searchText` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`boxId`, `url`), FOREIGN KEY(`boxId`) REFERENCES `boxes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "boxId", + "columnName": "boxId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentPath", + "columnName": "parentPath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "trackNo", + "columnName": "trackNo", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "searchText", + "columnName": "searchText", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "boxId", + "url" + ] + }, + "indices": [ + { + "name": "index_library_tracks_boxId_parentPath", + "unique": false, + "columnNames": [ + "boxId", + "parentPath" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_tracks_boxId_parentPath` ON `${TABLE_NAME}` (`boxId`, `parentPath`)" + } + ], + "foreignKeys": [ + { + "table": "boxes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "boxId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "library_albums", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`boxId` TEXT NOT NULL, `albumArtist` TEXT NOT NULL, `album` TEXT NOT NULL, `coverFile` TEXT, `searchText` TEXT NOT NULL DEFAULT '', `cachedAt` INTEGER NOT NULL, PRIMARY KEY(`boxId`, `albumArtist`, `album`), FOREIGN KEY(`boxId`) REFERENCES `boxes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "boxId", + "columnName": "boxId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "albumArtist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coverFile", + "columnName": "coverFile", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchText", + "columnName": "searchText", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "cachedAt", + "columnName": "cachedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "boxId", + "albumArtist", + "album" + ] + }, + "indices": [ + { + "name": "index_library_albums_boxId", + "unique": false, + "columnNames": [ + "boxId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_albums_boxId` ON `${TABLE_NAME}` (`boxId`)" + } + ], + "foreignKeys": [ + { + "table": "boxes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "boxId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `boxId` TEXT NOT NULL, `label` TEXT NOT NULL, `type` TEXT NOT NULL, `folder` TEXT, `albumArtist` TEXT, `album` TEXT, `trackUrl` TEXT, `coverFile` TEXT, `sortIndex` INTEGER NOT NULL, `launchCount` INTEGER NOT NULL, `shortcutPinned` INTEGER NOT NULL, FOREIGN KEY(`boxId`) REFERENCES `boxes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "boxId", + "columnName": "boxId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "folder", + "columnName": "folder", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumArtist", + "columnName": "albumArtist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "trackUrl", + "columnName": "trackUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverFile", + "columnName": "coverFile", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sortIndex", + "columnName": "sortIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchCount", + "columnName": "launchCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shortcutPinned", + "columnName": "shortcutPinned", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_favorites_boxId", + "unique": false, + "columnNames": [ + "boxId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_favorites_boxId` ON `${TABLE_NAME}` (`boxId`)" + } + ], + "foreignKeys": [ + { + "table": "boxes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "boxId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "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, '7cca3269b0e9a20ae87e581849bf4813')" + ] + } +} \ No newline at end of file diff --git a/core-data/src/main/kotlin/app/coilforphoniebox/data/db/BoxDao.kt b/core-data/src/main/kotlin/app/coilforphoniebox/data/db/BoxDao.kt new file mode 100644 index 0000000..dbc71c7 --- /dev/null +++ b/core-data/src/main/kotlin/app/coilforphoniebox/data/db/BoxDao.kt @@ -0,0 +1,36 @@ +package app.coilforphoniebox.data.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import kotlinx.coroutines.flow.Flow + +@Dao +interface BoxDao { + + @Query("SELECT * FROM boxes ORDER BY sortIndex ASC, addedAt ASC") + fun observeAll(): Flow> + + @Query("SELECT * FROM boxes WHERE id = :id") + suspend fun find(id: String): BoxEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(box: BoxEntity) + + @Update + suspend fun update(box: BoxEntity) + + @Query("DELETE FROM boxes WHERE id = :id") + suspend fun delete(id: String) + + @Query("UPDATE boxes SET lastSeenAt = :at WHERE id = :id") + suspend fun markSeen(id: String, at: Long) + + @Query("SELECT COALESCE(MAX(sortIndex), -1) FROM boxes") + suspend fun maxSortIndex(): Int + + @Query("SELECT * FROM boxes WHERE host = :host AND rpcPort = :rpcPort LIMIT 1") + suspend fun findByAddress(host: String, rpcPort: Int): BoxEntity? +} diff --git a/core-data/src/main/kotlin/app/coilforphoniebox/data/db/CoilDatabase.kt b/core-data/src/main/kotlin/app/coilforphoniebox/data/db/CoilDatabase.kt new file mode 100644 index 0000000..8f98868 --- /dev/null +++ b/core-data/src/main/kotlin/app/coilforphoniebox/data/db/CoilDatabase.kt @@ -0,0 +1,25 @@ +package app.coilforphoniebox.data.db + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + entities = [ + BoxEntity::class, + LibraryFolderEntity::class, + LibraryTrackEntity::class, + LibraryAlbumEntity::class, + FavoriteEntity::class, + ], + version = 3, + exportSchema = true, +) +abstract class CoilDatabase : RoomDatabase() { + abstract fun boxDao(): BoxDao + abstract fun libraryDao(): LibraryDao + abstract fun favoriteDao(): FavoriteDao + + companion object { + const val NAME = "coil.db" + } +} diff --git a/core-data/src/main/kotlin/app/coilforphoniebox/data/db/Entities.kt b/core-data/src/main/kotlin/app/coilforphoniebox/data/db/Entities.kt new file mode 100644 index 0000000..69fb2df --- /dev/null +++ b/core-data/src/main/kotlin/app/coilforphoniebox/data/db/Entities.kt @@ -0,0 +1,142 @@ +package app.coilforphoniebox.data.db + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Schema per implementation plan §6.3. + * + * Every table carries `boxId` from the very first commit, even though the launch UI is + * single-box oriented. Adding the column later would mean a Room migration across + * every table plus a rewrite of the shortcut deep link format — cheap now, expensive + * in six months (§7.6). + * + * `ON DELETE CASCADE` throughout means removing a box takes its library cache and its + * favourites with it in one step. + */ +@Entity(tableName = "boxes") +data class BoxEntity( + @PrimaryKey val id: String, + val displayName: String, + val host: String, + val rpcPort: Int, + val pubPort: Int, + val addedAt: Long, + val autoSessionEnabled: Boolean, + val networkSsid: String?, + val lastSeenAt: Long?, + val sortIndex: Int, +) + +@Entity( + tableName = "library_folders", + primaryKeys = ["boxId", "path"], + foreignKeys = [ + ForeignKey( + entity = BoxEntity::class, + parentColumns = ["id"], + childColumns = ["boxId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("boxId", "parentPath")], +) +data class LibraryFolderEntity( + val boxId: String, + /** Relative to the box's music library root — exactly what `play_folder` takes. */ + val path: String, + val parentPath: String?, + val displayName: String, + val hasChildren: Boolean, + /** Folded name for searching; see [SearchText]. Not indexed — `LIKE '%x%'` cannot use one. */ + @ColumnInfo(defaultValue = "") val searchText: String, + /** When this row was written, i.e. when its parent level was last scanned. */ + val cachedAt: Long, + /** + * When *this folder's own* contents were last scanned, as opposed to when the row + * itself was written. Drives the "Updated 3 days ago" hint for a level, and is null + * for a folder that has been seen in a listing but never opened. + */ + val contentCachedAt: Long?, +) + +@Entity( + tableName = "library_tracks", + primaryKeys = ["boxId", "url"], + foreignKeys = [ + ForeignKey( + entity = BoxEntity::class, + parentColumns = ["id"], + childColumns = ["boxId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("boxId", "parentPath")], +) +data class LibraryTrackEntity( + val boxId: String, + val url: String, + val parentPath: String?, + val title: String?, + val artist: String?, + val album: String?, + val trackNo: Int?, + val durationSeconds: Double?, + /** Folded title, artist, album and file name; see [SearchText]. */ + @ColumnInfo(defaultValue = "") val searchText: String, +) + +@Entity( + tableName = "library_albums", + primaryKeys = ["boxId", "albumArtist", "album"], + foreignKeys = [ + ForeignKey( + entity = BoxEntity::class, + parentColumns = ["id"], + childColumns = ["boxId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("boxId")], +) +data class LibraryAlbumEntity( + val boxId: String, + val albumArtist: String, + val album: String, + val coverFile: String?, + /** Folded album and album artist; see [SearchText]. */ + @ColumnInfo(defaultValue = "") val searchText: String, + val cachedAt: Long, +) + +@Entity( + tableName = "favorites", + foreignKeys = [ + ForeignKey( + entity = BoxEntity::class, + parentColumns = ["id"], + childColumns = ["boxId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("boxId")], +) +data class FavoriteEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0L, + val boxId: String, + val label: String, + /** `FOLDER`, `ALBUM` or `TRACK`, stored as its name so the column stays readable. */ + val type: String, + val folder: String?, + val albumArtist: String?, + val album: String?, + /** MPD URL of a single file, set only for a `TRACK` row (schema version 2). */ + val trackUrl: String?, + val coverFile: String?, + val sortIndex: Int, + val launchCount: Int, + val shortcutPinned: Boolean, +) diff --git a/core-data/src/main/kotlin/app/coilforphoniebox/data/db/FavoriteDao.kt b/core-data/src/main/kotlin/app/coilforphoniebox/data/db/FavoriteDao.kt new file mode 100644 index 0000000..e828d16 --- /dev/null +++ b/core-data/src/main/kotlin/app/coilforphoniebox/data/db/FavoriteDao.kt @@ -0,0 +1,64 @@ +package app.coilforphoniebox.data.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow + +@Dao +interface FavoriteDao { + + @Query("SELECT * FROM favorites WHERE boxId = :boxId ORDER BY sortIndex ASC, id ASC") + fun observeForBox(boxId: String): Flow> + + /** Feeds the launcher's dynamic shortcuts: the ones actually used (§9). */ + @Query("SELECT * FROM favorites WHERE boxId = :boxId ORDER BY launchCount DESC, sortIndex ASC LIMIT :limit") + fun observeMostLaunched(boxId: String, limit: Int): Flow> + + @Query("SELECT * FROM favorites WHERE id = :id") + suspend fun find(id: Long): FavoriteEntity? + + @Query( + "SELECT * FROM favorites WHERE boxId = :boxId AND type = 'FOLDER' AND folder = :folder LIMIT 1", + ) + suspend fun findFolder(boxId: String, folder: String): FavoriteEntity? + + @Query( + "SELECT * FROM favorites WHERE boxId = :boxId AND type = 'ALBUM' " + + "AND albumArtist = :albumArtist AND album = :album LIMIT 1", + ) + suspend fun findAlbum(boxId: String, albumArtist: String, album: String): FavoriteEntity? + + @Query( + "SELECT * FROM favorites WHERE boxId = :boxId AND type = 'TRACK' AND trackUrl = :trackUrl LIMIT 1", + ) + suspend fun findTrack(boxId: String, trackUrl: String): FavoriteEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(favorite: FavoriteEntity): Long + + @Query("DELETE FROM favorites WHERE id = :id") + suspend fun delete(id: Long) + + @Query("UPDATE favorites SET label = :label WHERE id = :id") + suspend fun rename(id: Long, label: String) + + @Query("UPDATE favorites SET sortIndex = :sortIndex WHERE id = :id") + suspend fun setSortIndex(id: Long, sortIndex: Int) + + @Query("UPDATE favorites SET launchCount = launchCount + 1 WHERE id = :id") + suspend fun incrementLaunchCount(id: Long) + + @Query("UPDATE favorites SET shortcutPinned = :pinned WHERE id = :id") + suspend fun setPinned(id: Long, pinned: Boolean) + + @Query("SELECT COALESCE(MAX(sortIndex), -1) FROM favorites WHERE boxId = :boxId") + suspend fun maxSortIndex(boxId: String): Int + + @Transaction + suspend fun applyOrder(ids: List) { + ids.forEachIndexed { index, id -> setSortIndex(id, index) } + } +} diff --git a/core-data/src/main/kotlin/app/coilforphoniebox/data/db/LibraryDao.kt b/core-data/src/main/kotlin/app/coilforphoniebox/data/db/LibraryDao.kt new file mode 100644 index 0000000..def44b4 --- /dev/null +++ b/core-data/src/main/kotlin/app/coilforphoniebox/data/db/LibraryDao.kt @@ -0,0 +1,172 @@ +package app.coilforphoniebox.data.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow + +@Dao +interface LibraryDao { + + // ------------------------------------------------------------------ read + + @Query( + "SELECT * FROM library_folders WHERE boxId = :boxId AND parentPath = :parentPath " + + "ORDER BY displayName COLLATE NOCASE ASC", + ) + fun observeFolders(boxId: String, parentPath: String): Flow> + + @Query( + "SELECT * FROM library_tracks WHERE boxId = :boxId AND parentPath = :parentPath " + + "ORDER BY trackNo IS NULL, trackNo ASC, title COLLATE NOCASE ASC", + ) + fun observeTracks(boxId: String, parentPath: String): Flow> + + @Query("SELECT contentCachedAt FROM library_folders WHERE boxId = :boxId AND path = :path") + fun observeContentCachedAt(boxId: String, path: String): Flow + + @Query( + "SELECT * FROM library_albums WHERE boxId = :boxId " + + "ORDER BY albumArtist COLLATE NOCASE ASC, album COLLATE NOCASE ASC", + ) + fun observeAlbums(boxId: String): Flow> + + @Query("SELECT MAX(cachedAt) FROM library_albums WHERE boxId = :boxId") + fun observeAlbumsCachedAt(boxId: String): Flow + + @Query("SELECT MAX(cachedAt) FROM library_albums WHERE boxId = :boxId") + suspend fun albumsCachedAt(boxId: String): Long? + + // ---------------------------------------------------------------- search + + /** + * Searches the cache, never the box: there is no search RPC in the Phoniebox protocol, + * and the alternative — walking the tree on every keystroke — is precisely the traffic + * that delays card detection (§6). + * + * [pattern] is a folded `LIKE` pattern from `SearchText.pattern`; a contains-match cannot + * use an index, so [limit] is what keeps a one-letter query cheap. + */ + @Query( + "SELECT * FROM library_folders WHERE boxId = :boxId AND searchText LIKE :pattern ESCAPE '\\' " + + "ORDER BY displayName COLLATE NOCASE ASC LIMIT :limit", + ) + fun searchFolders(boxId: String, pattern: String, limit: Int): Flow> + + @Query( + "SELECT * FROM library_tracks WHERE boxId = :boxId AND searchText LIKE :pattern ESCAPE '\\' " + + "ORDER BY title COLLATE NOCASE ASC LIMIT :limit", + ) + fun searchTracks(boxId: String, pattern: String, limit: Int): Flow> + + @Query( + "SELECT * FROM library_albums WHERE boxId = :boxId AND searchText LIKE :pattern ESCAPE '\\' " + + "ORDER BY album COLLATE NOCASE ASC LIMIT :limit", + ) + fun searchAlbums(boxId: String, pattern: String, limit: Int): Flow> + + /** Folders whose own contents have never been fetched — the crawl's work list. */ + @Query( + "SELECT * FROM library_folders WHERE boxId = :boxId AND contentCachedAt IS NULL " + + "ORDER BY path ASC LIMIT :limit", + ) + suspend fun foldersNeedingContent(boxId: String, limit: Int): List + + @Query("SELECT COUNT(*) FROM library_folders WHERE boxId = :boxId") + suspend fun folderCount(boxId: String): Int + + // ----------------------------------------------------------------- write + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertFolders(folders: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertTracks(tracks: List) + + @Query("DELETE FROM library_folders WHERE boxId = :boxId AND parentPath = :parentPath") + suspend fun deleteFoldersIn(boxId: String, parentPath: String) + + @Query("DELETE FROM library_tracks WHERE boxId = :boxId AND parentPath = :parentPath") + suspend fun deleteTracksIn(boxId: String, parentPath: String) + + @Query("UPDATE library_folders SET contentCachedAt = :at WHERE boxId = :boxId AND path = :path") + suspend fun stampContent(boxId: String, path: String, at: Long) + + @Query("SELECT * FROM library_folders WHERE boxId = :boxId AND path = :path") + suspend fun findFolder(boxId: String, path: String): LibraryFolderEntity? + + /** + * Replaces exactly one level. Anything that disappeared on the box disappears here + * too, which is what makes a refresh after a rescan show removals as well as + * additions. + */ + @Transaction + suspend fun replaceLevel( + boxId: String, + path: String, + folders: List, + tracks: List, + at: Long, + ) { + deleteFoldersIn(boxId, path) + deleteTracksIn(boxId, path) + upsertFolders(folders) + upsertTracks(tracks) + + // The row for the level itself carries its freshness stamp. The library root has + // no parent to have introduced it, so it is created here on first refresh. + val own = findFolder(boxId, path) + if (own == null) { + upsertFolders( + listOf( + LibraryFolderEntity( + boxId = boxId, + path = path, + parentPath = path.substringBeforeLast('/', missingDelimiterValue = "") + .takeIf { it != path }, + displayName = path.substringAfterLast('/'), + hasChildren = folders.isNotEmpty(), + searchText = SearchText.haystack(path.substringAfterLast('/')), + cachedAt = at, + contentCachedAt = at, + ), + ), + ) + } else { + stampContent(boxId, path, at) + } + } + + @Transaction + suspend fun replaceAlbums(boxId: String, albums: List) { + // Covers are fetched one at a time as the grid scrolls, so keep the ones already + // resolved rather than making the whole grid ask again after every refresh. + val existingCovers = coversFor(boxId).associate { (it.albumArtist to it.album) to it.coverFile } + deleteAlbums(boxId) + upsertAlbums( + albums.map { album -> + album.copy(coverFile = album.coverFile ?: existingCovers[album.albumArtist to album.album]) + }, + ) + } + + @Query("SELECT * FROM library_albums WHERE boxId = :boxId AND coverFile IS NOT NULL") + suspend fun coversFor(boxId: String): List + + @Query("DELETE FROM library_albums WHERE boxId = :boxId") + suspend fun deleteAlbums(boxId: String) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAlbums(albums: List) + + @Query( + "UPDATE library_albums SET coverFile = :coverFile " + + "WHERE boxId = :boxId AND albumArtist = :albumArtist AND album = :album", + ) + suspend fun setAlbumCover(boxId: String, albumArtist: String, album: String, coverFile: String) + + @Query("SELECT * FROM library_albums WHERE boxId = :boxId AND albumArtist = :albumArtist AND album = :album") + suspend fun findAlbum(boxId: String, albumArtist: String, album: String): LibraryAlbumEntity? +} diff --git a/core-data/src/main/kotlin/app/coilforphoniebox/data/db/Mappers.kt b/core-data/src/main/kotlin/app/coilforphoniebox/data/db/Mappers.kt new file mode 100644 index 0000000..222c7ea --- /dev/null +++ b/core-data/src/main/kotlin/app/coilforphoniebox/data/db/Mappers.kt @@ -0,0 +1,129 @@ +package app.coilforphoniebox.data.db + +import app.coilforphoniebox.domain.model.Box +import app.coilforphoniebox.domain.model.Favorite +import app.coilforphoniebox.domain.model.FavoriteType +import app.coilforphoniebox.domain.model.LibraryAlbum +import app.coilforphoniebox.domain.model.LibraryFolder +import app.coilforphoniebox.domain.model.LibraryTrack + +internal fun BoxEntity.toDomain() = Box( + id = id, + displayName = displayName, + host = host, + rpcPort = rpcPort, + pubPort = pubPort, + addedAt = addedAt, + autoSessionEnabled = autoSessionEnabled, + networkSsid = networkSsid, + lastSeenAt = lastSeenAt, + sortIndex = sortIndex, +) + +internal fun Box.toEntity() = BoxEntity( + id = id, + displayName = displayName, + host = host, + rpcPort = rpcPort, + pubPort = pubPort, + addedAt = addedAt, + autoSessionEnabled = autoSessionEnabled, + networkSsid = networkSsid, + lastSeenAt = lastSeenAt, + sortIndex = sortIndex, +) + +internal fun LibraryFolderEntity.toDomain() = LibraryFolder( + boxId = boxId, + path = path, + parentPath = parentPath, + displayName = displayName, + hasChildren = hasChildren, + cachedAt = cachedAt, +) + +internal fun LibraryFolder.toEntity(contentCachedAt: Long? = null) = LibraryFolderEntity( + boxId = boxId, + path = path, + parentPath = parentPath, + displayName = displayName, + hasChildren = hasChildren, + // Folded once, on the way in, so a query never has to scan unfolded text. + searchText = SearchText.haystack(displayName), + cachedAt = cachedAt, + contentCachedAt = contentCachedAt, +) + +internal fun LibraryTrackEntity.toDomain() = LibraryTrack( + boxId = boxId, + url = url, + parentPath = parentPath, + title = title, + artist = artist, + album = album, + trackNo = trackNo, + durationSeconds = durationSeconds, +) + +internal fun LibraryTrack.toEntity() = LibraryTrackEntity( + boxId = boxId, + url = url, + parentPath = parentPath, + title = title, + artist = artist, + album = album, + trackNo = trackNo, + durationSeconds = durationSeconds, + // The file name is in the haystack because an untagged track is shown by it, and what + // is on screen is what a user expects to be able to search for. + searchText = SearchText.haystack(title, artist, album, url.substringAfterLast('/')), +) + +internal fun LibraryAlbumEntity.toDomain() = LibraryAlbum( + boxId = boxId, + albumArtist = albumArtist, + album = album, + coverFile = coverFile, + cachedAt = cachedAt, +) + +internal fun LibraryAlbum.toEntity() = LibraryAlbumEntity( + boxId = boxId, + albumArtist = albumArtist, + album = album, + coverFile = coverFile, + searchText = SearchText.haystack(album, albumArtist), + cachedAt = cachedAt, +) + +internal fun FavoriteEntity.toDomain() = Favorite( + id = id, + boxId = boxId, + label = label, + // An unrecognised value can only come from an edited or imported file; treating it + // as a folder keeps the row visible and removable instead of crashing the list. + type = runCatching { FavoriteType.valueOf(type) }.getOrDefault(FavoriteType.FOLDER), + folder = folder, + albumArtist = albumArtist, + album = album, + trackUrl = trackUrl, + coverFile = coverFile, + sortIndex = sortIndex, + launchCount = launchCount, + shortcutPinned = shortcutPinned, +) + +internal fun Favorite.toEntity() = FavoriteEntity( + id = id, + boxId = boxId, + label = label, + type = type.name, + folder = folder, + albumArtist = albumArtist, + album = album, + trackUrl = trackUrl, + coverFile = coverFile, + sortIndex = sortIndex, + launchCount = launchCount, + shortcutPinned = shortcutPinned, +) diff --git a/core-data/src/main/kotlin/app/coilforphoniebox/data/db/Migrations.kt b/core-data/src/main/kotlin/app/coilforphoniebox/data/db/Migrations.kt new file mode 100644 index 0000000..d8c0418 --- /dev/null +++ b/core-data/src/main/kotlin/app/coilforphoniebox/data/db/Migrations.kt @@ -0,0 +1,41 @@ +package app.coilforphoniebox.data.db + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Schema history. + * + * Favourites and box configuration are the only data a user cannot get back from the box, + * so every schema change is migrated rather than dropped — even before the first release, + * where a destructive fallback would silently empty a tester's favourites. + */ +internal val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + // Single tracks became favouritable; existing rows are folders or albums and + // leave the new column null. + db.execSQL("ALTER TABLE favorites ADD COLUMN trackUrl TEXT") + } +} + +/** + * Adds the folded search columns. + * + * The existing library rows are dropped rather than backfilled: folding needs accent removal, + * which SQLite cannot do, and the library cache is the one thing in this database that *is* + * disposable — the next visit to a folder or the albums tab fetches it again. Boxes and + * favourites are untouched, which is the part that matters. + */ +internal val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE library_folders ADD COLUMN searchText TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE library_tracks ADD COLUMN searchText TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE library_albums ADD COLUMN searchText TEXT NOT NULL DEFAULT ''") + + db.execSQL("DELETE FROM library_tracks") + db.execSQL("DELETE FROM library_folders") + db.execSQL("DELETE FROM library_albums") + } +} + +internal val ALL_MIGRATIONS = arrayOf(MIGRATION_1_2, MIGRATION_2_3) diff --git a/core-data/src/main/kotlin/app/coilforphoniebox/data/db/SearchText.kt b/core-data/src/main/kotlin/app/coilforphoniebox/data/db/SearchText.kt new file mode 100644 index 0000000..38a72a3 --- /dev/null +++ b/core-data/src/main/kotlin/app/coilforphoniebox/data/db/SearchText.kt @@ -0,0 +1,48 @@ +package app.coilforphoniebox.data.db + +import java.text.Normalizer +import java.util.Locale + +/** + * How library text is matched when searching. + * + * Everything is folded to lower case with accents removed, on the way *into* the database + * and again for the query, so `bar`, `Bär` and `BÄR` all find each other. This cannot be + * done in SQL: SQLite's `LIKE` and `lower()` are case-insensitive for ASCII only, which + * would leave every umlaut in the German, French, Spanish and Dutch libraries unmatched + * unless the user typed the exact case — and umlauts are everywhere in the audiobook names + * this app exists to start. + * + * `Locale.ROOT` on purpose: the fold has to agree between the row that was written on one + * day and the query typed on another, so it must not depend on the phone's current locale. + * + * Known limit: `ß` is not decomposed by NFD, so "Strasse" does not find "Straße". Adding + * that means a list of language-specific pairs, which is a bigger decision than it looks. + */ +internal object SearchText { + + private val combiningMarks = "\\p{Mn}+".toRegex() + + fun fold(text: String): String = + Normalizer.normalize(text.lowercase(Locale.ROOT), Normalizer.Form.NFD) + .replace(combiningMarks, "") + + /** The folded haystack stored on a row; blank and missing parts drop out. */ + fun haystack(vararg parts: String?): String = parts + .asSequence() + .filterNotNull() + .filter { it.isNotBlank() } + .joinToString(separator = " ") { fold(it) } + + /** + * A `LIKE` pattern for [query]. `%`, `_` and the escape character itself are neutralised, + * so a user typing `100%` searches for that text instead of matching everything. + */ + fun pattern(query: String): String { + val escaped = fold(query) + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + return "%$escaped%" + } +} diff --git a/core-data/src/main/kotlin/app/coilforphoniebox/data/di/DataModule.kt b/core-data/src/main/kotlin/app/coilforphoniebox/data/di/DataModule.kt new file mode 100644 index 0000000..f08acea --- /dev/null +++ b/core-data/src/main/kotlin/app/coilforphoniebox/data/di/DataModule.kt @@ -0,0 +1,87 @@ +package app.coilforphoniebox.data.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.room.Room +import app.coilforphoniebox.data.db.ALL_MIGRATIONS +import app.coilforphoniebox.data.db.BoxDao +import app.coilforphoniebox.data.db.CoilDatabase +import app.coilforphoniebox.data.db.FavoriteDao +import app.coilforphoniebox.data.db.LibraryDao +import app.coilforphoniebox.data.repository.BackupRepositoryImpl +import app.coilforphoniebox.data.repository.BoxRepositoryImpl +import app.coilforphoniebox.data.repository.FavoriteRepositoryImpl +import app.coilforphoniebox.data.repository.LibraryRepositoryImpl +import app.coilforphoniebox.data.repository.PlayerRepositoryImpl +import app.coilforphoniebox.data.repository.SettingsRepositoryImpl +import app.coilforphoniebox.domain.repository.BackupRepository +import app.coilforphoniebox.domain.repository.BoxRepository +import app.coilforphoniebox.domain.repository.FavoriteRepository +import app.coilforphoniebox.domain.repository.LibraryRepository +import app.coilforphoniebox.domain.repository.PlayerRepository +import app.coilforphoniebox.domain.repository.SettingsRepository +import dagger.Binds +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) +object DatabaseModule { + + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): CoilDatabase = + // Room switches SQLite's foreign key enforcement on for us, which is what makes + // the ON DELETE CASCADE on every child table actually fire. + Room.databaseBuilder(context, CoilDatabase::class.java, CoilDatabase.NAME) + // No destructive fallback on purpose: favourites are the one thing in here + // that cannot be rebuilt from the box. + .addMigrations(*ALL_MIGRATIONS) + .build() + + @Provides + fun provideBoxDao(database: CoilDatabase): BoxDao = database.boxDao() + + @Provides + fun provideLibraryDao(database: CoilDatabase): LibraryDao = database.libraryDao() + + @Provides + fun provideFavoriteDao(database: CoilDatabase): FavoriteDao = database.favoriteDao() + + @Provides + @Singleton + fun providePreferences(@ApplicationContext context: Context): DataStore = + PreferenceDataStoreFactory.create { + context.preferencesDataStoreFile("coil_settings") + } +} + +@Module +@InstallIn(SingletonComponent::class) +interface RepositoryModule { + + @Binds + fun bindBoxRepository(impl: BoxRepositoryImpl): BoxRepository + + @Binds + fun bindPlayerRepository(impl: PlayerRepositoryImpl): PlayerRepository + + @Binds + fun bindLibraryRepository(impl: LibraryRepositoryImpl): LibraryRepository + + @Binds + fun bindFavoriteRepository(impl: FavoriteRepositoryImpl): FavoriteRepository + + @Binds + fun bindSettingsRepository(impl: SettingsRepositoryImpl): SettingsRepository + + @Binds + fun bindBackupRepository(impl: BackupRepositoryImpl): BackupRepository +} diff --git a/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/BackupRepositoryImpl.kt b/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/BackupRepositoryImpl.kt new file mode 100644 index 0000000..662fb10 --- /dev/null +++ b/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/BackupRepositoryImpl.kt @@ -0,0 +1,187 @@ +package app.coilforphoniebox.data.repository + +import app.coilforphoniebox.data.db.BoxDao +import app.coilforphoniebox.data.db.FavoriteDao +import app.coilforphoniebox.data.db.toDomain +import app.coilforphoniebox.data.db.toEntity +import app.coilforphoniebox.data.settings.SettingsStore +import app.coilforphoniebox.domain.model.Box +import app.coilforphoniebox.domain.model.Favorite +import app.coilforphoniebox.domain.model.FavoriteType +import app.coilforphoniebox.domain.model.SessionMode +import app.coilforphoniebox.domain.model.ThemeMode +import app.coilforphoniebox.domain.repository.BackupRepository +import kotlinx.coroutines.flow.first +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Settings export and import. + * + * Play Store builds and GitHub APKs carry different signatures, so moving between them + * means uninstalling first and losing app data. That cannot be fixed, only made + * survivable — which is what this is for (§13.2). + * + * The library cache is deliberately not exported: it is rebuilt from the box in + * seconds, and including it would make the file large for no gain. + */ +@Singleton +class BackupRepositoryImpl @Inject constructor( + private val boxDao: BoxDao, + private val favoriteDao: FavoriteDao, + private val settings: SettingsStore, +) : BackupRepository { + + @Serializable + private data class BackupFile( + val version: Int = FORMAT_VERSION, + @SerialName("boxes") val boxes: List = emptyList(), + @SerialName("settings") val settings: SettingsBackup = SettingsBackup(), + ) + + @Serializable + private data class BoxBackup( + val displayName: String, + val host: String, + val rpcPort: Int, + val pubPort: Int, + val autoSessionEnabled: Boolean = false, + val networkSsid: String? = null, + val favorites: List = emptyList(), + ) + + @Serializable + private data class FavoriteBackup( + val label: String, + val type: String, + val folder: String? = null, + val albumArtist: String? = null, + val album: String? = null, + /** Added with format version 2, together with `TRACK` favourites. */ + val trackUrl: String? = null, + val sortIndex: Int = 0, + ) + + @Serializable + private data class SettingsBackup( + val themeMode: String = ThemeMode.SYSTEM.name, + val dynamicColor: Boolean = false, + val sessionMode: String = SessionMode.APP_ONLY.name, + ) + + override suspend fun export(): String { + val boxes = boxDao.observeAll().first().map { it.toDomain() } + val current = settings.current() + + val file = BackupFile( + boxes = boxes.map { box -> + BoxBackup( + displayName = box.displayName, + host = box.host, + rpcPort = box.rpcPort, + pubPort = box.pubPort, + autoSessionEnabled = box.autoSessionEnabled, + networkSsid = box.networkSsid, + favorites = favoriteDao.observeForBox(box.id).first().map { entity -> + val favorite = entity.toDomain() + FavoriteBackup( + label = favorite.label, + type = favorite.type.name, + folder = favorite.folder, + albumArtist = favorite.albumArtist, + album = favorite.album, + trackUrl = favorite.trackUrl, + sortIndex = favorite.sortIndex, + ) + }, + ) + }, + settings = SettingsBackup( + themeMode = current.themeMode.name, + dynamicColor = current.dynamicColor, + sessionMode = current.sessionMode.name, + ), + ) + return codec.encodeToString(file) + } + + override suspend fun importFrom(content: String): Result = runCatching { + val file = codec.decodeFromString(content) + require(file.version <= FORMAT_VERSION) { "Unsupported backup version ${file.version}" } + + for (backup in file.boxes) { + // Matched on address, so importing the same file twice does not produce a + // second copy of every box — and does not orphan the existing favourites. + val existing = boxDao.findByAddress(backup.host, backup.rpcPort) + val boxId = existing?.id ?: java.util.UUID.randomUUID().toString() + + if (existing == null) { + boxDao.insert( + Box( + id = boxId, + displayName = backup.displayName, + host = backup.host, + rpcPort = backup.rpcPort, + pubPort = backup.pubPort, + addedAt = System.currentTimeMillis(), + autoSessionEnabled = backup.autoSessionEnabled, + networkSsid = backup.networkSsid, + sortIndex = boxDao.maxSortIndex() + 1, + ).toEntity(), + ) + } + + val existingFavorites = favoriteDao.observeForBox(boxId).first().map { it.toDomain() } + for (favorite in backup.favorites) { + val type = runCatching { FavoriteType.valueOf(favorite.type) } + .getOrDefault(FavoriteType.FOLDER) + val alreadyThere = existingFavorites.any { + it.type == type && it.folder == favorite.folder && + it.albumArtist == favorite.albumArtist && it.album == favorite.album && + it.trackUrl == favorite.trackUrl + } + if (alreadyThere) continue + + favoriteDao.insert( + Favorite( + boxId = boxId, + label = favorite.label, + type = type, + folder = favorite.folder, + albumArtist = favorite.albumArtist, + album = favorite.album, + trackUrl = favorite.trackUrl, + sortIndex = favorite.sortIndex, + ).toEntity(), + ) + } + } + + settings.setThemeMode( + ThemeMode.entries.firstOrNull { it.name == file.settings.themeMode } ?: ThemeMode.SYSTEM, + ) + settings.setDynamicColor(file.settings.dynamicColor) + settings.setSessionMode( + SessionMode.entries.firstOrNull { it.name == file.settings.sessionMode } + ?: SessionMode.APP_ONLY, + ) + } + + private val codec = Json { + prettyPrint = true + ignoreUnknownKeys = true + } + + private companion object { + /** + * 2 added `trackUrl`. The bump is deliberate even though the field is optional: + * an older build would import a `TRACK` row without its URL, which is a + * favourite that cannot play. Refusing the file says so instead. + */ + const val FORMAT_VERSION = 2 + } +} diff --git a/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/BoxRepositoryImpl.kt b/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/BoxRepositoryImpl.kt new file mode 100644 index 0000000..4d26e13 --- /dev/null +++ b/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/BoxRepositoryImpl.kt @@ -0,0 +1,76 @@ +package app.coilforphoniebox.data.repository + +import app.coilforphoniebox.data.db.BoxDao +import app.coilforphoniebox.data.db.toDomain +import app.coilforphoniebox.data.db.toEntity +import app.coilforphoniebox.data.settings.SettingsStore +import app.coilforphoniebox.domain.model.Box +import app.coilforphoniebox.domain.model.ConnectionTestResult +import app.coilforphoniebox.domain.repository.BoxRepository +import app.coilforphoniebox.transport.BoxProbe +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BoxRepositoryImpl @Inject constructor( + private val dao: BoxDao, + private val settings: SettingsStore, + private val probe: BoxProbe, +) : BoxRepository { + + override val boxes: Flow> = + dao.observeAll().map { entities -> entities.map { it.toDomain() } } + + /** + * Falls back to the first configured box when no active id is stored, which covers + * a fresh install, an imported settings file and a restored backup alike — all + * cases where a box exists but nothing has selected one yet. + */ + override val activeBox: Flow = + combine(boxes, settings.settings.map { it.activeBoxId }.distinctUntilChanged()) { list, activeId -> + list.firstOrNull { it.id == activeId } ?: list.firstOrNull() + }.distinctUntilChanged() + + override suspend fun box(boxId: String): Box? = dao.find(boxId)?.toDomain() + + override suspend fun add(displayName: String, host: String, rpcPort: Int, pubPort: Int): Box { + val box = Box( + id = UUID.randomUUID().toString(), + displayName = displayName, + host = host, + rpcPort = rpcPort, + pubPort = pubPort, + addedAt = System.currentTimeMillis(), + sortIndex = dao.maxSortIndex() + 1, + ) + dao.insert(box.toEntity()) + + // The first box added becomes the active one; later ones do not steal focus. + if (settings.current().activeBoxId == null) settings.setActiveBoxId(box.id) + return box + } + + override suspend fun update(box: Box) = dao.update(box.toEntity()) + + override suspend fun delete(boxId: String) { + dao.delete(boxId) + if (settings.current().activeBoxId == boxId) { + // Point at whatever is left rather than leaving a dangling id behind. + settings.setActiveBoxId(boxes.first().firstOrNull()?.id) + } + } + + override suspend fun setActive(boxId: String) = settings.setActiveBoxId(boxId) + + override suspend fun markSeen(boxId: String) = + dao.markSeen(boxId, System.currentTimeMillis()) + + override suspend fun testConnection(host: String, rpcPort: Int): ConnectionTestResult = + probe.probe(host, rpcPort) +} diff --git a/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/FavoriteRepositoryImpl.kt b/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/FavoriteRepositoryImpl.kt new file mode 100644 index 0000000..9029089 --- /dev/null +++ b/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/FavoriteRepositoryImpl.kt @@ -0,0 +1,47 @@ +package app.coilforphoniebox.data.repository + +import app.coilforphoniebox.data.db.FavoriteDao +import app.coilforphoniebox.data.db.toDomain +import app.coilforphoniebox.data.db.toEntity +import app.coilforphoniebox.domain.model.Favorite +import app.coilforphoniebox.domain.model.PlayTarget +import app.coilforphoniebox.domain.repository.FavoriteRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FavoriteRepositoryImpl @Inject constructor( + private val dao: FavoriteDao, +) : FavoriteRepository { + + override fun favorites(boxId: String): Flow> = + dao.observeForBox(boxId).map { list -> list.map { it.toDomain() } } + + override fun mostLaunched(boxId: String, limit: Int): Flow> = + dao.observeMostLaunched(boxId, limit).map { list -> list.map { it.toDomain() } } + + override suspend fun favorite(id: Long): Favorite? = dao.find(id)?.toDomain() + + override suspend fun matching(boxId: String, target: PlayTarget): Favorite? = when (target) { + is PlayTarget.Folder -> dao.findFolder(boxId, target.path) + is PlayTarget.Album -> dao.findAlbum(boxId, target.albumArtist, target.album) + is PlayTarget.Track -> dao.findTrack(boxId, target.url) + }?.toDomain() + + override suspend fun add(favorite: Favorite): Long { + val sortIndex = dao.maxSortIndex(favorite.boxId) + 1 + return dao.insert(favorite.copy(sortIndex = sortIndex).toEntity()) + } + + override suspend fun remove(id: Long) = dao.delete(id) + + override suspend fun rename(id: Long, label: String) = dao.rename(id, label) + + override suspend fun reorder(ids: List) = dao.applyOrder(ids) + + override suspend fun recordLaunch(id: Long) = dao.incrementLaunchCount(id) + + override suspend fun setPinned(id: Long, pinned: Boolean) = dao.setPinned(id, pinned) +} diff --git a/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/LibraryRepositoryImpl.kt b/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/LibraryRepositoryImpl.kt new file mode 100644 index 0000000..055fee4 --- /dev/null +++ b/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/LibraryRepositoryImpl.kt @@ -0,0 +1,249 @@ +package app.coilforphoniebox.data.repository + +import android.util.Log +import app.coilforphoniebox.data.db.LibraryDao +import app.coilforphoniebox.data.db.SearchText +import app.coilforphoniebox.data.db.toDomain +import app.coilforphoniebox.data.db.toEntity +import app.coilforphoniebox.domain.model.FolderContent +import app.coilforphoniebox.domain.model.LibraryAlbum +import app.coilforphoniebox.domain.model.LibraryIndexResult +import app.coilforphoniebox.domain.model.LibraryIndexState +import app.coilforphoniebox.domain.model.LibrarySearchResults +import app.coilforphoniebox.domain.model.PlaybackState +import app.coilforphoniebox.domain.repository.LibraryRepository +import app.coilforphoniebox.transport.Commands +import app.coilforphoniebox.transport.ConnectionManager +import app.coilforphoniebox.transport.LibraryParser +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Library data, with Room as the single source of truth (§6.1). + * + * Reads always come from the database, so the library stays browsable while the box is + * off — only playback fails then, with a clear message. A refresh writes to the + * database and the UI updates as a consequence, never directly from an RPC result. + */ +@Singleton +class LibraryRepositoryImpl @Inject constructor( + private val dao: LibraryDao, + private val transport: ConnectionManager, +) : LibraryRepository { + + /** + * Cover lookups are one RPC each. The box processes RPCs strictly sequentially and + * shares that socket with its own card reader, so a fast scroll through a big album + * grid must not turn into fifty queued calls. + */ + private val coverPermits = Semaphore(permits = 2) + + override fun folderContent(boxId: String, path: String): Flow = combine( + dao.observeFolders(boxId, path), + dao.observeTracks(boxId, path), + dao.observeContentCachedAt(boxId, path), + ) { folders, tracks, cachedAt -> + FolderContent( + path = path, + folders = folders.filter { it.path != path }.map { it.toDomain() }, + tracks = tracks.map { it.toDomain() }, + cachedAt = cachedAt, + ) + } + + override suspend fun refreshFolder(boxId: String, path: String): Result = + transport.call(Commands.folderContent(path)).mapCatching { result -> + val now = System.currentTimeMillis() + val content = LibraryParser.folderContent(boxId, path, result, now) + dao.replaceLevel( + boxId = boxId, + path = path, + folders = content.folders.map { it.toEntity(contentCachedAt = null) }, + tracks = content.tracks.map { it.toEntity() }, + at = now, + ) + } + + override fun albums(boxId: String): Flow> = + dao.observeAlbums(boxId).map { albums -> albums.map { it.toDomain() } } + + override fun albumsCachedAt(boxId: String): Flow = dao.observeAlbumsCachedAt(boxId) + + override suspend fun refreshAlbums(boxId: String): Result = + transport.call(Commands.listAlbums).mapCatching { result -> + val now = System.currentTimeMillis() + val albums = LibraryParser.albums(boxId, result, now) + dao.replaceAlbums(boxId, albums.map { it.toEntity() }) + } + + override suspend fun ensureAlbumCover(boxId: String, albumArtist: String, album: String) { + val existing = dao.findAlbum(boxId, albumArtist, album) + if (existing == null || existing.coverFile != null) return + + coverPermits.withPermit { + // Another caller may have resolved it while this one waited for a permit. + if (dao.findAlbum(boxId, albumArtist, album)?.coverFile != null) return + + // The box answers the first request with CACHE_PENDING and extracts the artwork + // on a worker thread, so asking once never yields a file name. + repeat(COVER_ATTEMPTS) { attempt -> + if (attempt > 0) delay(COVER_RETRY_MILLIS) + + val payload = transport.call(Commands.albumCoverArt(albumArtist, album)) + .getOrNull() ?: return + + when (val art = LibraryParser.coverArt(payload)) { + is LibraryParser.CoverArt.Available -> { + dao.setAlbumCover(boxId, albumArtist, album, art.fileName) + return + } + + // Nothing to show, and nothing to gain from asking again. + LibraryParser.CoverArt.Missing -> return + + LibraryParser.CoverArt.Pending -> Unit + } + } + } + } + + override suspend fun rescanBoxLibrary(boxId: String): Result = + transport.call(Commands.updateLibrary).mapCatching { + // `update` hands the scan to MPD and returns a job id straight away. + // `update_wait` would block the box's RPC loop until the scan finished, which + // is exactly what to avoid (§6.4) — so wait a moment and reload, and let the + // user pull to refresh if that was too quick. + delay(RESCAN_SETTLE_MILLIS) + refreshAlbums(boxId) + refreshFolder(boxId, FolderContent.ROOT) + Unit + } + + /** + * Opportunistic refresh at app start: only when the cache is genuinely old *and* + * the box is idle, because a blocking call bothers nobody when nothing is playing + * but delays card detection when something is (§6.4). + */ + override suspend fun refreshIfStaleAndIdle(boxId: String) { + if (transport.currentStatus().state != PlaybackState.STOP) return + + val cachedAt = dao.albumsCachedAt(boxId) + val stale = cachedAt == null || System.currentTimeMillis() - cachedAt > STALE_AFTER_MILLIS + if (!stale) return + + Log.i(TAG, "Album cache is stale and the box is idle; refreshing") + refreshAlbums(boxId) + } + + override fun search(boxId: String, query: String): Flow { + val trimmed = query.trim() + // A blank query means "not searching", not "everything": the second would pull the + // whole cache into memory to display a list nobody asked for. + if (trimmed.isEmpty()) return flowOf(LibrarySearchResults()) + + val pattern = SearchText.pattern(trimmed) + return combine( + dao.searchFolders(boxId, pattern, SEARCH_LIMIT), + dao.searchAlbums(boxId, pattern, SEARCH_LIMIT), + dao.searchTracks(boxId, pattern, SEARCH_LIMIT), + ) { folders, albums, tracks -> + LibrarySearchResults( + query = trimmed, + // The row a level carries for itself has a blank name at the library root + // only; every other one is a real folder and belongs in the results. + folders = folders.filter { it.displayName.isNotBlank() }.map { it.toDomain() }, + albums = albums.map { it.toDomain() }, + tracks = tracks.map { it.toDomain() }, + ) + } + } + + private val _indexState = MutableStateFlow(LibraryIndexState()) + override val indexState: StateFlow = _indexState.asStateFlow() + + override suspend fun indexLibrary(boxId: String): LibraryIndexResult { + // Nothing here is worth a card read that does not register while a story is playing. + if (transport.currentStatus().state != PlaybackState.STOP) return LibraryIndexResult.BoxBusy + + _indexState.value = LibraryIndexState(running = true, foldersScanned = 0) + var scanned = 0 + // A level that fails keeps its null stamp, so without this it would be handed back + // by the next query for ever. + val attempted = mutableSetOf() + + try { + if (dao.folderCount(boxId) == 0) { + refreshFolder(boxId, FolderContent.ROOT) + .onFailure { return LibraryIndexResult.Interrupted(scanned) } + } + + while (scanned < INDEX_FOLDER_CAP) { + val batch = dao.foldersNeedingContent(boxId, INDEX_BATCH) + .filter { it.path !in attempted } + if (batch.isEmpty()) { + Log.i(TAG, "Library index complete after $scanned folders") + return LibraryIndexResult.Finished(scanned, stoppedAtCap = false) + } + + for (folder in batch) { + if (scanned >= INDEX_FOLDER_CAP) break + attempted += folder.path + + // The pause is the whole reason this is tolerable: it hands the box's + // sequential RPC loop back between levels so its card reader gets a turn. + delay(INDEX_PAUSE_MILLIS) + val result = refreshFolder(boxId, folder.path) + scanned++ + _indexState.value = LibraryIndexState(running = true, foldersScanned = scanned) + + // One failure is the box going away, not a bad folder — every path here + // came from the box itself. + if (result.isFailure) { + Log.i(TAG, "Library index interrupted after $scanned folders") + return LibraryIndexResult.Interrupted(scanned) + } + } + } + + // Said out loud rather than passed off as complete: a library bigger than the cap + // is partly unsearchable and the user needs to know which half they have. + Log.i(TAG, "Library index stopped at its cap of $INDEX_FOLDER_CAP folders") + return LibraryIndexResult.Finished(scanned, stoppedAtCap = true) + } finally { + _indexState.value = LibraryIndexState(running = false, foldersScanned = scanned) + } + } + + private companion object { + const val TAG = "CoilLibrary" + const val COVER_ATTEMPTS = 3 + const val COVER_RETRY_MILLIS = 1_000L + const val RESCAN_SETTLE_MILLIS = 3_000L + const val STALE_AFTER_MILLIS = 7L * 24 * 60 * 60 * 1000 + + /** Per kind, so one very common word cannot produce a list of thousands. */ + const val SEARCH_LIMIT = 60 + + /** How many unvisited folders to claim per database round trip. */ + const val INDEX_BATCH = 32 + + /** + * A backstop for a pathological library, not a target: 5000 levels at the pause + * below is already the better part of an hour. + */ + const val INDEX_FOLDER_CAP = 5_000 + + /** Long enough for the box's own RFID poll to get between two levels. */ + const val INDEX_PAUSE_MILLIS = 250L + } +} diff --git a/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/PlayerRepositoryImpl.kt b/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/PlayerRepositoryImpl.kt new file mode 100644 index 0000000..46189a5 --- /dev/null +++ b/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/PlayerRepositoryImpl.kt @@ -0,0 +1,199 @@ +package app.coilforphoniebox.data.repository + +import app.coilforphoniebox.domain.model.Box +import app.coilforphoniebox.domain.model.ConnectionState +import app.coilforphoniebox.domain.model.PlayTarget +import app.coilforphoniebox.domain.model.PlayerStatus +import app.coilforphoniebox.domain.model.RepeatMode +import app.coilforphoniebox.domain.model.SleepTimerStatus +import app.coilforphoniebox.domain.model.VolumeStatus +import app.coilforphoniebox.domain.repository.PlayerRepository +import app.coilforphoniebox.transport.Commands +import app.coilforphoniebox.transport.ConnectionManager +import app.coilforphoniebox.transport.LibraryParser +import app.coilforphoniebox.transport.di.TransportScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonElement +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Live player state and playback commands, both straight from the active box. + * + * Nothing here touches the database: this is the one class of data that is always live + * and never persisted (§6.2). + */ +@Singleton +class PlayerRepositoryImpl @Inject constructor( + private val transport: ConnectionManager, + @TransportScope private val scope: CoroutineScope, +) : PlayerRepository { + + override val status: Flow = transport.status + override val volume: Flow = transport.volume + override val connectionState: Flow = transport.connection + override val boxVersion: Flow = transport.boxVersion + override val sleepTimer: Flow = transport.sleepTimer + + /** + * Resolved covers, keyed on box and song because the same file name means different + * art on a different box. + */ + private val resolvedCovers = ConcurrentHashMap() + + /** Songs the box has told us have no artwork, so they are not asked about again. */ + private val songsWithoutArt: MutableSet = ConcurrentHashMap.newKeySet() + + private val _coverUrl = MutableStateFlow(null) + + /** + * Deliberately a [StateFlow] with a value from the start, resolved in the background, + * rather than a flow that maps the current song to an RPC. + * + * Every screen combines this with the rest of the player state, and `combine` emits + * nothing until *all* of its inputs have emitted once. A cover flow whose first value + * needs a network round trip therefore freezes the whole player — title, progress and + * controls included — for as long as the lookup takes, or forever if it keeps being + * restarted. Cover art is the least important thing on the screen; it must never be + * able to hold up the rest. + */ + override val coverUrl: StateFlow = _coverUrl.asStateFlow() + + init { + scope.launch { resolveCoversForCurrentSong() } + } + + private suspend fun resolveCoversForCurrentSong() { + var lastKey: String? = null + + combine( + transport.status.map { it.file }.distinctUntilChanged(), + transport.activeBox, + ) { file, box -> file to box } + // Only the newest song matters; a lookup for the previous one is abandoned. + .collectLatest { (file, box) -> + val key = cacheKey(file, box) + if (key != lastKey) { + // Showing the previous song's artwork would be worse than showing none. + _coverUrl.value = null + lastKey = key + } + if (file != null && box != null) { + _coverUrl.value = resolveCover(file, box) + } + } + } + + /** + * The box answers the first request for a song with `CACHE_PENDING` and extracts the + * artwork on a worker thread, so a single request never returns a usable name. This + * asks again a few times before giving up. + */ + private suspend fun resolveCover(file: String, box: Box): String? { + val key = "${box.id}|$file" + if (key in songsWithoutArt) return null + resolvedCovers[key]?.let { return box.coverUrl(it) } + + repeat(COVER_ATTEMPTS) { attempt -> + if (attempt > 0) delay(COVER_RETRY_MILLIS) + + val payload = transport.call(Commands.singleCoverArt(file)).getOrNull() ?: return null + when (val art = LibraryParser.coverArt(payload)) { + is LibraryParser.CoverArt.Available -> { + resolvedCovers[key] = art.fileName + return box.coverUrl(art.fileName) + } + + LibraryParser.CoverArt.Missing -> { + songsWithoutArt += key + return null + } + + // Still extracting; fall through to the next attempt. + LibraryParser.CoverArt.Pending -> Unit + } + } + // Not cached as missing: the next song change or reconnect may find it ready. + return null + } + + private fun cacheKey(file: String?, box: Box?): String? = + if (file == null || box == null) null else "${box.id}|$file" + + override suspend fun play(): Result = transport.call(Commands.play).unit() + + override suspend fun pause(): Result = transport.call(Commands.pause).unit() + + override suspend fun toggle(): Result = transport.call(Commands.toggle).unit() + + override suspend fun next(): Result = transport.call(Commands.next).unit() + + override suspend fun previous(): Result = transport.call(Commands.previous).unit() + + override suspend fun seekTo(positionSeconds: Double): Result = + transport.call(Commands.seek(positionSeconds.coerceAtLeast(0.0))).unit() + + override suspend fun setShuffle(enabled: Boolean): Result = + transport.call(Commands.shuffle(enabled)).unit() + + override suspend fun setRepeat(mode: RepeatMode): Result = + transport.call(Commands.repeat(mode)).unit() + + override suspend fun setVolume(level: Int): Result { + // The box clamps to its soft maximum anyway; clamping here keeps the slider and + // the volume keys from ever asking for something it will silently refuse. + val max = transport.currentVolume().maxLevel + return transport.call(Commands.setVolume(level.coerceIn(0, max))).unit() + } + + override suspend fun changeVolume(step: Int): Result = + transport.call(Commands.changeVolume(step)).unit() + + override suspend fun toggleMute(): Result = + transport.call(Commands.mute(!transport.currentVolume().muted)).unit() + + override suspend fun startSleepTimer(minutes: Int): Result { + // `GenericTimerClass.start` logs "Ignoring start command" and returns when its timer + // is already alive, so setting 30 minutes over a running 60 would silently keep the + // 60. Cancel first, and only then start. + if (transport.currentSleepTimer().running) { + transport.call(Commands.cancelSleepTimer) + .onFailure { return Result.failure(it) } + } + val seconds = minutes.coerceAtLeast(1) * SECONDS_PER_MINUTE + return transport.call(Commands.startSleepTimer(seconds)).unit() + } + + override suspend fun cancelSleepTimer(): Result = + transport.call(Commands.cancelSleepTimer).unit() + + override suspend fun refreshSleepTimer(): Result = transport.refreshSleepTimer() + + override suspend fun play(target: PlayTarget): Result = + transport.call(Commands.play(target)).unit() + + override suspend fun playOn(boxId: String, target: PlayTarget): Result = + transport.callOn(boxId, Commands.play(target)).unit() + + private fun Result.unit(): Result = map { } + + private companion object { + /** Four tries over roughly two seconds, which covers an ordinary extraction. */ + const val COVER_ATTEMPTS = 4 + const val COVER_RETRY_MILLIS = 800L + + /** The timer plugin takes `wait_seconds`; the UI offers whole minutes. */ + const val SECONDS_PER_MINUTE = 60 + } +} diff --git a/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/SettingsRepositoryImpl.kt b/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/SettingsRepositoryImpl.kt new file mode 100644 index 0000000..037f5e2 --- /dev/null +++ b/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/SettingsRepositoryImpl.kt @@ -0,0 +1,29 @@ +package app.coilforphoniebox.data.repository + +import app.coilforphoniebox.data.settings.SettingsStore +import app.coilforphoniebox.domain.model.AppSettings +import app.coilforphoniebox.domain.model.SessionMode +import app.coilforphoniebox.domain.model.ThemeMode +import app.coilforphoniebox.domain.repository.SettingsRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SettingsRepositoryImpl @Inject constructor( + private val store: SettingsStore, +) : SettingsRepository { + + override val settings: Flow = store.settings + + override suspend fun current(): AppSettings = store.current() + + override suspend fun setThemeMode(mode: ThemeMode) = store.setThemeMode(mode) + + override suspend fun setDynamicColor(enabled: Boolean) = store.setDynamicColor(enabled) + + override suspend fun setSessionMode(mode: SessionMode) = store.setSessionMode(mode) + + override suspend fun setOnboardingComplete(complete: Boolean) = + store.setOnboardingComplete(complete) +} diff --git a/core-data/src/main/kotlin/app/coilforphoniebox/data/settings/SettingsStore.kt b/core-data/src/main/kotlin/app/coilforphoniebox/data/settings/SettingsStore.kt new file mode 100644 index 0000000..e008efb --- /dev/null +++ b/core-data/src/main/kotlin/app/coilforphoniebox/data/settings/SettingsStore.kt @@ -0,0 +1,69 @@ +package app.coilforphoniebox.data.settings + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import app.coilforphoniebox.domain.model.AppSettings +import app.coilforphoniebox.domain.model.SessionMode +import app.coilforphoniebox.domain.model.ThemeMode +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Global settings only. Everything box-specific lives on the box row instead (§7.2), + * so adding a second box never means untangling shared preferences. + */ +@Singleton +class SettingsStore @Inject constructor( + private val dataStore: DataStore, +) { + val settings: Flow = dataStore.data.map { prefs -> + AppSettings( + themeMode = prefs[THEME_MODE].toThemeMode(), + dynamicColor = prefs[DYNAMIC_COLOR] ?: false, + sessionMode = prefs[SESSION_MODE].toSessionMode(), + activeBoxId = prefs[ACTIVE_BOX_ID], + onboardingComplete = prefs[ONBOARDING_COMPLETE] ?: false, + ) + } + + suspend fun current(): AppSettings = settings.first() + + suspend fun setThemeMode(mode: ThemeMode) = put(THEME_MODE, mode.name) + + suspend fun setDynamicColor(enabled: Boolean) = put(DYNAMIC_COLOR, enabled) + + suspend fun setSessionMode(mode: SessionMode) = put(SESSION_MODE, mode.name) + + suspend fun setActiveBoxId(boxId: String?) { + dataStore.edit { prefs -> + if (boxId == null) prefs.remove(ACTIVE_BOX_ID) else prefs[ACTIVE_BOX_ID] = boxId + } + } + + suspend fun setOnboardingComplete(complete: Boolean) = put(ONBOARDING_COMPLETE, complete) + + private suspend fun put(key: Preferences.Key, value: T) { + dataStore.edit { prefs -> prefs[key] = value } + } + + /** An unknown stored name falls back to the default rather than throwing. */ + private fun String?.toThemeMode(): ThemeMode = + this?.let { name -> ThemeMode.entries.firstOrNull { it.name == name } } ?: ThemeMode.SYSTEM + + private fun String?.toSessionMode(): SessionMode = + this?.let { name -> SessionMode.entries.firstOrNull { it.name == name } } ?: SessionMode.APP_ONLY + + private companion object { + val THEME_MODE = stringPreferencesKey("theme_mode") + val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color") + val SESSION_MODE = stringPreferencesKey("session_mode") + val ACTIVE_BOX_ID = stringPreferencesKey("active_box_id") + val ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete") + } +} diff --git a/core-data/src/test/kotlin/app/coilforphoniebox/data/db/SearchTextTest.kt b/core-data/src/test/kotlin/app/coilforphoniebox/data/db/SearchTextTest.kt new file mode 100644 index 0000000..c165178 --- /dev/null +++ b/core-data/src/test/kotlin/app/coilforphoniebox/data/db/SearchTextTest.kt @@ -0,0 +1,44 @@ +package app.coilforphoniebox.data.db + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SearchTextTest { + + /** The whole point: German, French and Spanish names must not need exact case or accents. */ + @Test + fun `folding ignores case and accents`() { + assertEquals("bar", SearchText.fold("Bär")) + assertEquals("bar", SearchText.fold("BÄR")) + assertEquals("elephant", SearchText.fold("Éléphant")) + assertEquals("nino", SearchText.fold("Niño")) + } + + @Test + fun `a haystack joins the parts it was given and drops the rest`() { + val haystack = SearchText.haystack("Kapitel 1", null, " ", "Käpt'n Blaubär") + + assertEquals("kapitel 1 kapt'n blaubar", haystack) + } + + @Test + fun `a query matches its row through the folded haystack`() { + val haystack = SearchText.haystack("Käpt'n Blaubär") + val pattern = SearchText.pattern("blaub") + + // What SQLite will do with the pattern, minus the wildcards. + assertTrue(haystack.contains(pattern.trim('%'))) + } + + /** + * A user typing `100%` or `a_b` is searching for that text. Unescaped, the first would + * match every row in the library. + */ + @Test + fun `wildcards in a query are escaped`() { + assertEquals("%100\\%%", SearchText.pattern("100%")) + assertEquals("%a\\_b%", SearchText.pattern("a_b")) + assertEquals("%c\\\\d%", SearchText.pattern("c\\d")) + } +} diff --git a/core-domain/build.gradle.kts b/core-domain/build.gradle.kts new file mode 100644 index 0000000..162b99b --- /dev/null +++ b/core-domain/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + +// Deliberately a plain JVM module: models, repository interfaces and use cases +// have no reason to know about Android. +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +dependencies { + api(libs.kotlinx.coroutines.core) + // @Inject only; the DI container itself stays out of this module. + implementation(libs.javax.inject) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/AppSettings.kt b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/AppSettings.kt new file mode 100644 index 0000000..0b392b1 --- /dev/null +++ b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/AppSettings.kt @@ -0,0 +1,22 @@ +package app.coilforphoniebox.domain.model + +enum class ThemeMode { SYSTEM, LIGHT, DARK } + +/** + * How far the media session is allowed to reach beyond the app (§8.3). + * + * [APP_ONLY] is the default: no persistent service, no battery cost, controls only + * while Coil is open. [AUTOMATIC] keeps a foreground service alive so controls + * appear on their own when the box starts playing. + */ +enum class SessionMode { APP_ONLY, AUTOMATIC } + +/** Global settings. Everything box-specific lives on [Box] instead (§7.2). */ +data class AppSettings( + val themeMode: ThemeMode = ThemeMode.SYSTEM, + /** Material You would displace the brand colour, so it is off by default (§10.9). */ + val dynamicColor: Boolean = false, + val sessionMode: SessionMode = SessionMode.APP_ONLY, + val activeBoxId: String? = null, + val onboardingComplete: Boolean = false, +) diff --git a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/Box.kt b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/Box.kt new file mode 100644 index 0000000..8a5d2de --- /dev/null +++ b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/Box.kt @@ -0,0 +1,36 @@ +package app.coilforphoniebox.domain.model + +/** + * A configured Phoniebox. + * + * Exactly one box is *active* at a time (see implementation plan §7.1); everything + * else in the app hangs off [id], including the library cache and favourites, so a + * box can be removed in one step. + */ +data class Box( + val id: String, + val displayName: String, + val host: String, + val rpcPort: Int = DEFAULT_RPC_PORT, + val pubPort: Int = DEFAULT_PUB_PORT, + val addedAt: Long, + /** Whether the background session may monitor this box (§7.4). */ + val autoSessionEnabled: Boolean = false, + /** Wi-Fi network the automatic mode is limited to; null means any Wi-Fi. */ + val networkSsid: String? = null, + val lastSeenAt: Long? = null, + val sortIndex: Int = 0, +) { + /** + * Cover art comes over plain HTTP from the box's web server, not over ZMQ. + * `get_single_coverart` returns a bare filename that is appended here. + */ + fun coverUrl(coverFile: String): String = + "http://$host/cover-cache/${coverFile.trimStart('/')}" + + companion object { + /** TCP, not the WebSocket ports — JeroMQ has no `ws://` transport. */ + const val DEFAULT_RPC_PORT = 5555 + const val DEFAULT_PUB_PORT = 5558 + } +} diff --git a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/ConnectionState.kt b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/ConnectionState.kt new file mode 100644 index 0000000..7d9ecf7 --- /dev/null +++ b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/ConnectionState.kt @@ -0,0 +1,26 @@ +package app.coilforphoniebox.domain.model + +/** + * `Disconnected → Connecting → Connected → Degraded → Disconnected` (§4.3). + * + * [DEGRADED] means the sockets are up but the box has gone quiet past the watchdog + * interval — ZMQ reconnects TCP silently and never reports it, so quiet is the only + * signal available. + */ +enum class ConnectionState { + DISCONNECTED, + CONNECTING, + CONNECTED, + DEGRADED, + ; + + val isUsable: Boolean get() = this == CONNECTED || this == DEGRADED +} + +/** Outcome of a one-shot reachability probe, used by the add-box flow (§4.4). */ +sealed interface ConnectionTestResult { + /** [version] is `core.version` when the box reported one. */ + data class Reachable(val version: String?) : ConnectionTestResult + + data object Unreachable : ConnectionTestResult +} diff --git a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/Favorite.kt b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/Favorite.kt new file mode 100644 index 0000000..6c2056f --- /dev/null +++ b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/Favorite.kt @@ -0,0 +1,71 @@ +package app.coilforphoniebox.domain.model + +enum class FavoriteType { FOLDER, ALBUM, TRACK } + +/** + * Favourites are scoped per box on purpose (§7.2): a folder path that exists on one + * box need not exist on another, and a favourite that silently fails is worse than + * one that is simply not there. + */ +data class Favorite( + val id: Long = 0L, + val boxId: String, + val label: String, + val type: FavoriteType, + val folder: String? = null, + val albumArtist: String? = null, + val album: String? = null, + /** MPD URL of a single file, for a TRACK favourite. */ + val trackUrl: String? = null, + val coverFile: String? = null, + val sortIndex: Int = 0, + val launchCount: Int = 0, + val shortcutPinned: Boolean = false, +) { + /** + * Returns null for a row that cannot be played — a FOLDER without a path, an + * ALBUM without its two keys, a TRACK without its URL — rather than throwing on + * data that reached the database through an import. + */ + fun toPlayTarget(): PlayTarget? = when (type) { + FavoriteType.FOLDER -> folder?.let { PlayTarget.Folder(it) } + FavoriteType.ALBUM -> + if (albumArtist != null && album != null) PlayTarget.Album(albumArtist, album) else null + + FavoriteType.TRACK -> trackUrl?.let { PlayTarget.Track(it) } + } + + companion object { + /** Every play target is favouritable, so this always produces a row. */ + fun of(boxId: String, label: String, target: PlayTarget, coverFile: String? = null): Favorite = + when (target) { + is PlayTarget.Folder -> Favorite( + boxId = boxId, + label = label, + type = FavoriteType.FOLDER, + folder = target.path, + coverFile = coverFile, + ) + + is PlayTarget.Album -> Favorite( + boxId = boxId, + label = label, + type = FavoriteType.ALBUM, + albumArtist = target.albumArtist, + album = target.album, + coverFile = coverFile, + ) + + // A single track is a favourite of its own kind. The player and the + // library both offer folder *and* track explicitly, because from a + // playing song alone it is not obvious which one a star would save. + is PlayTarget.Track -> Favorite( + boxId = boxId, + label = label, + type = FavoriteType.TRACK, + trackUrl = target.url, + coverFile = coverFile, + ) + } + } +} diff --git a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/Library.kt b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/Library.kt new file mode 100644 index 0000000..9a54539 --- /dev/null +++ b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/Library.kt @@ -0,0 +1,96 @@ +package app.coilforphoniebox.domain.model + +/** + * One directory in the box's music library. + * + * [path] is relative to the library root and is exactly what `play_folder` takes, + * which is why a favourite needs no cache lookup to start playing (§6.5). + */ +data class LibraryFolder( + val boxId: String, + val path: String, + val parentPath: String?, + val displayName: String, + val hasChildren: Boolean = false, + val cachedAt: Long = 0L, +) + +data class LibraryTrack( + val boxId: String, + /** MPD URL, unique per box. */ + val url: String, + val parentPath: String?, + val title: String?, + val artist: String?, + val album: String?, + val trackNo: Int? = null, + val durationSeconds: Double? = null, +) { + /** Falls back to the file name so a track with no tags still shows something. */ + val displayTitle: String + get() = title?.takeIf { it.isNotBlank() } ?: url.substringAfterLast('/') +} + +data class LibraryAlbum( + val boxId: String, + val albumArtist: String, + val album: String, + val coverFile: String? = null, + val cachedAt: Long = 0L, +) + +/** + * What a search over the cached library found. + * + * The three kinds stay apart rather than being merged into one ranked list: a folder is what + * a listener usually wants to start, and burying it among fifty track hits would be worse + * than useless. + */ +data class LibrarySearchResults( + val query: String = "", + val folders: List = emptyList(), + val albums: List = emptyList(), + val tracks: List = emptyList(), +) { + val isEmpty: Boolean get() = folders.isEmpty() && albums.isEmpty() && tracks.isEmpty() +} + +/** Progress of the opt-in library crawl, for the settings screen. */ +data class LibraryIndexState( + val running: Boolean = false, + val foldersScanned: Int = 0, +) + +/** How a crawl ended. Every outcome is something the user is told about. */ +sealed interface LibraryIndexResult { + /** [stoppedAtCap] means the library is larger than the crawl's own limit. */ + data class Finished(val foldersScanned: Int, val stoppedAtCap: Boolean) : LibraryIndexResult + + /** + * The box is playing. A crawl occupies the RPC socket its card reader shares, so it + * waits rather than making cards slow to register mid-story (§6). + */ + data object BoxBusy : LibraryIndexResult + + /** The box stopped answering; whatever was scanned before that is kept. */ + data class Interrupted(val foldersScanned: Int) : LibraryIndexResult +} + +/** + * A single level of the folder tree, cached on first visit. The whole library is + * never loaded at once — `list_all_dirs` stays off limits (§5). + */ +data class FolderContent( + val path: String, + val folders: List = emptyList(), + val tracks: List = emptyList(), + /** Null while this level has never been fetched. */ + val cachedAt: Long? = null, +) { + val isEmpty: Boolean get() = folders.isEmpty() && tracks.isEmpty() + + companion object { + /** Path of the library root; the box takes an empty string for it. */ + const val ROOT = "" + } +} diff --git a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/PlayTarget.kt b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/PlayTarget.kt new file mode 100644 index 0000000..16fccb0 --- /dev/null +++ b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/PlayTarget.kt @@ -0,0 +1,11 @@ +package app.coilforphoniebox.domain.model + +/** Something the box can be asked to start playing. */ +sealed interface PlayTarget { + data class Folder(val path: String) : PlayTarget + + data class Album(val albumArtist: String, val album: String) : PlayTarget + + /** A single file, by MPD URL. */ + data class Track(val url: String) : PlayTarget +} diff --git a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/PlayerStatus.kt b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/PlayerStatus.kt new file mode 100644 index 0000000..8ec9c9e --- /dev/null +++ b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/PlayerStatus.kt @@ -0,0 +1,42 @@ +package app.coilforphoniebox.domain.model + +enum class PlaybackState { PLAY, PAUSE, STOP } + +enum class RepeatMode { OFF, ALL, ONE } + +/** + * The box's `playerstatus` topic, normalised. + * + * Every field is optional on purpose: MPD omits whole fields depending on the + * content — web radio has no `duration` and no `album` — and returns some numbers + * as strings. Nothing here may be treated as a stable contract (§4.2). + */ +data class PlayerStatus( + val state: PlaybackState = PlaybackState.STOP, + val title: String? = null, + val artist: String? = null, + val album: String? = null, + /** MPD URL of the current song, relative to the box's music library root. */ + val file: String? = null, + val elapsedSeconds: Double? = null, + val durationSeconds: Double? = null, + val songId: String? = null, + val playlistPosition: Int? = null, + val playlistLength: Int = 0, + val repeat: RepeatMode = RepeatMode.OFF, + val shuffle: Boolean = false, + /** Elapsed system uptime when this status was received, for interpolation. */ + val receivedAtElapsedMillis: Long = 0L, +) { + val isPlaying: Boolean get() = state == PlaybackState.PLAY + + /** Whether anything is loaded at all — an empty playlist means an idle box. */ + val hasContent: Boolean get() = file != null || title != null + + /** Folder the current song sits in, used to offer "save this as a favourite". */ + val folder: String? get() = file?.substringBeforeLast('/', missingDelimiterValue = "")?.ifEmpty { null } + + companion object { + val Idle = PlayerStatus() + } +} diff --git a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/SleepTimerStatus.kt b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/SleepTimerStatus.kt new file mode 100644 index 0000000..ce0c4e2 --- /dev/null +++ b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/SleepTimerStatus.kt @@ -0,0 +1,37 @@ +package app.coilforphoniebox.domain.model + +/** + * The box's `timers.timer_stop_player` timer: when it fires, playback stops. + * + * This is the *only* timer Coil touches. The box also offers a shutdown timer, an idle + * shutdown timer and a volume fade, and those stay out of reach for the same reason + * `host.shutdown` does — Coil never sends a command that can take the box down (§1, §16). + * + * The box publishes this state when it changes, not once a second, so [remainingSecondsAt] + * counts down locally from the last thing it said. Same approach as [PlayerStatus]'s + * elapsed time, and for the same reason: a display that only moves every few minutes reads + * as broken. + */ +data class SleepTimerStatus( + val running: Boolean = false, + /** Seconds left as the box last reported them. */ + val remainingSeconds: Int = 0, + /** What the timer was set to, when the box says so. */ + val requestedSeconds: Int? = null, + /** Elapsed system uptime when this was received, for the local countdown. */ + val receivedAtElapsedMillis: Long = 0L, +) { + /** + * Seconds left as of [nowElapsedMillis], never below zero. A stopped timer has none + * left rather than a stale number. + */ + fun remainingSecondsAt(nowElapsedMillis: Long): Int { + if (!running) return 0 + val elapsed = ((nowElapsedMillis - receivedAtElapsedMillis) / 1000).coerceAtLeast(0L) + return (remainingSeconds - elapsed).coerceIn(0L, Int.MAX_VALUE.toLong()).toInt() + } + + companion object { + val Off = SleepTimerStatus() + } +} diff --git a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/VolumeStatus.kt b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/VolumeStatus.kt new file mode 100644 index 0000000..a89c96f --- /dev/null +++ b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/VolumeStatus.kt @@ -0,0 +1,18 @@ +package app.coilforphoniebox.domain.model + +/** + * [maxLevel] comes from `get_soft_max_volume` and is what the media session + * reports as `deviceVolumeMax`, so the phone's volume keys land in the right range. + */ +data class VolumeStatus( + val level: Int = 0, + val maxLevel: Int = 100, + val muted: Boolean = false, +) { + val fraction: Float + get() = if (maxLevel <= 0) 0f else (level.toFloat() / maxLevel).coerceIn(0f, 1f) + + companion object { + val Unknown = VolumeStatus() + } +} diff --git a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/BackupRepository.kt b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/BackupRepository.kt new file mode 100644 index 0000000..45243df --- /dev/null +++ b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/BackupRepository.kt @@ -0,0 +1,17 @@ +package app.coilforphoniebox.domain.repository + +/** + * Settings export and import. This is the only mitigation for the Play Store / + * GitHub signing split, where switching channels means uninstalling first and losing + * app data (§13.2). + */ +interface BackupRepository { + /** Serialises boxes, favourites and global settings to JSON. */ + suspend fun export(): String + + /** + * Merges [content] into the current data. Boxes are matched on host and port so + * importing the same file twice does not duplicate them. + */ + suspend fun importFrom(content: String): Result +} diff --git a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/BoxRepository.kt b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/BoxRepository.kt new file mode 100644 index 0000000..2668584 --- /dev/null +++ b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/BoxRepository.kt @@ -0,0 +1,34 @@ +package app.coilforphoniebox.domain.repository + +import app.coilforphoniebox.domain.model.Box +import app.coilforphoniebox.domain.model.ConnectionTestResult +import kotlinx.coroutines.flow.Flow + +interface BoxRepository { + val boxes: Flow> + + /** The one box the app currently controls; null before the first box is added. */ + val activeBox: Flow + + suspend fun box(boxId: String): Box? + + /** Returns the stored box, which carries the generated [Box.id]. */ + suspend fun add( + displayName: String, + host: String, + rpcPort: Int = Box.DEFAULT_RPC_PORT, + pubPort: Int = Box.DEFAULT_PUB_PORT, + ): Box + + suspend fun update(box: Box) + + /** Cascades to the box's library cache and favourites. */ + suspend fun delete(boxId: String) + + suspend fun setActive(boxId: String) + + suspend fun markSeen(boxId: String) + + /** One-shot `core.version` probe, without touching the active connection. */ + suspend fun testConnection(host: String, rpcPort: Int): ConnectionTestResult +} diff --git a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/FavoriteRepository.kt b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/FavoriteRepository.kt new file mode 100644 index 0000000..1c3fe03 --- /dev/null +++ b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/FavoriteRepository.kt @@ -0,0 +1,29 @@ +package app.coilforphoniebox.domain.repository + +import app.coilforphoniebox.domain.model.Favorite +import app.coilforphoniebox.domain.model.PlayTarget +import kotlinx.coroutines.flow.Flow + +interface FavoriteRepository { + fun favorites(boxId: String): Flow> + + /** Ordered by [Favorite.launchCount], for the launcher's dynamic shortcuts. */ + fun mostLaunched(boxId: String, limit: Int): Flow> + + suspend fun favorite(id: Long): Favorite? + + suspend fun matching(boxId: String, target: PlayTarget): Favorite? + + suspend fun add(favorite: Favorite): Long + + suspend fun remove(id: Long) + + suspend fun rename(id: Long, label: String) + + /** Persists the given order as [Favorite.sortIndex] values. */ + suspend fun reorder(ids: List) + + suspend fun recordLaunch(id: Long) + + suspend fun setPinned(id: Long, pinned: Boolean) +} diff --git a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/LibraryRepository.kt b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/LibraryRepository.kt new file mode 100644 index 0000000..7db3476 --- /dev/null +++ b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/LibraryRepository.kt @@ -0,0 +1,71 @@ +package app.coilforphoniebox.domain.repository + +import app.coilforphoniebox.domain.model.FolderContent +import app.coilforphoniebox.domain.model.LibraryAlbum +import app.coilforphoniebox.domain.model.LibraryIndexResult +import app.coilforphoniebox.domain.model.LibraryIndexState +import app.coilforphoniebox.domain.model.LibrarySearchResults +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +/** + * Room is the single source of truth (§6.1): every read is a [Flow] from the + * database, and a refresh writes to the database rather than returning data. The + * library therefore stays browsable while the box is off. + */ +interface LibraryRepository { + fun folderContent(boxId: String, path: String): Flow + + /** Fetches exactly one level via `get_folder_content` and caches it. */ + suspend fun refreshFolder(boxId: String, path: String): Result + + fun albums(boxId: String): Flow> + + /** Most recent successful album refresh, for the freshness hint. */ + fun albumsCachedAt(boxId: String): Flow + + suspend fun refreshAlbums(boxId: String): Result + + /** + * Fetches this album's cover file name if it is not cached yet, and stores it. + * + * Deliberately per album and on demand: asking for every cover during a refresh + * would be one RPC per album on a socket the box also uses for card detection. + */ + suspend fun ensureAlbumCover(boxId: String, albumArtist: String, album: String) + + /** Fires `player.ctrl.update` so the box rescans its own MPD database. */ + suspend fun rescanBoxLibrary(boxId: String): Result + + /** + * Refreshes the album list only when the cache is older than a week *and* the box + * is idle, so a blocking call never delays card detection mid-playback (§6.4). + */ + suspend fun refreshIfStaleAndIdle(boxId: String) + + /** + * Searches the cache — the Phoniebox protocol has no search command, so there is nothing + * to ask the box even if it were free to ask. A blank [query] yields no results rather + * than everything. + * + * What is searchable is therefore what has been fetched: every album once the album tab + * has loaded, and folders and tracks from levels that have been opened — or from all of + * them, after [indexLibrary]. + */ + fun search(boxId: String, query: String): Flow + + /** Progress of a running [indexLibrary], for the screen that started it. */ + val indexState: StateFlow + + /** + * Walks the whole folder tree one level at a time, so that search covers folders nobody + * has opened yet. + * + * Opt-in and never automatic: this is exactly the traffic §6 warns about — the box + * processes RPCs sequentially and shares that socket with its card reader, so a crawl + * makes cards slow to register while it runs. It pauses between levels for that reason, + * refuses to start while the box is playing, and can be cancelled by cancelling the + * calling coroutine, keeping whatever it managed to scan. + */ + suspend fun indexLibrary(boxId: String): LibraryIndexResult +} diff --git a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/PlayerRepository.kt b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/PlayerRepository.kt new file mode 100644 index 0000000..041ae71 --- /dev/null +++ b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/PlayerRepository.kt @@ -0,0 +1,71 @@ +package app.coilforphoniebox.domain.repository + +import app.coilforphoniebox.domain.model.ConnectionState +import app.coilforphoniebox.domain.model.PlayTarget +import app.coilforphoniebox.domain.model.PlayerStatus +import app.coilforphoniebox.domain.model.RepeatMode +import app.coilforphoniebox.domain.model.SleepTimerStatus +import app.coilforphoniebox.domain.model.VolumeStatus +import kotlinx.coroutines.flow.Flow + +/** + * Live state of the active box. Nothing here is persisted: `playerstatus` and + * `volume.level` arrive over PubSub roughly four times a second and are always + * taken fresh from the box (§6.2). + * + * Commands return [Result] so the UI can surface a failure; they do not wait for the + * state to change, because the next PubSub message will correct any optimistic + * assumption anyway. + */ +interface PlayerRepository { + val status: Flow + val volume: Flow + val connectionState: Flow + + /** `core.version` of the active box, for diagnostics and bug reports. */ + val boxVersion: Flow + + /** Absolute HTTP URL of the current song's cover, or null when there is none. */ + val coverUrl: Flow + + /** + * The box's stop-player timer. Its shutdown timers are deliberately not represented + * here at all — Coil never sends a command that switches the box off (§16). + */ + val sleepTimer: Flow + + suspend fun play(): Result + suspend fun pause(): Result + suspend fun toggle(): Result + suspend fun next(): Result + suspend fun previous(): Result + suspend fun seekTo(positionSeconds: Double): Result + suspend fun setShuffle(enabled: Boolean): Result + suspend fun setRepeat(mode: RepeatMode): Result + + suspend fun setVolume(level: Int): Result + suspend fun changeVolume(step: Int): Result + suspend fun toggleMute(): Result + + /** + * Sets the timer that stops playback after [minutes], replacing a running one. + * + * The box ignores `start` while its timer is already alive, so changing the duration + * means cancelling first — which the implementation does, rather than leaving the user + * wondering why the new duration did not take. + */ + suspend fun startSleepTimer(minutes: Int): Result + + suspend fun cancelSleepTimer(): Result + + /** + * Pulls the timer state once, for when the timer UI opens. Later changes arrive on the + * published topic, so this is never called on a schedule. + */ + suspend fun refreshSleepTimer(): Result + + suspend fun play(target: PlayTarget): Result + + /** Start [target] on a specific box, connecting ad hoc if it is not the active one. */ + suspend fun playOn(boxId: String, target: PlayTarget): Result +} diff --git a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/SettingsRepository.kt b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/SettingsRepository.kt new file mode 100644 index 0000000..9461b67 --- /dev/null +++ b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/SettingsRepository.kt @@ -0,0 +1,20 @@ +package app.coilforphoniebox.domain.repository + +import app.coilforphoniebox.domain.model.AppSettings +import app.coilforphoniebox.domain.model.SessionMode +import app.coilforphoniebox.domain.model.ThemeMode +import kotlinx.coroutines.flow.Flow + +interface SettingsRepository { + val settings: Flow + + suspend fun current(): AppSettings + + suspend fun setThemeMode(mode: ThemeMode) + + suspend fun setDynamicColor(enabled: Boolean) + + suspend fun setSessionMode(mode: SessionMode) + + suspend fun setOnboardingComplete(complete: Boolean) +} diff --git a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/usecase/PlayFavoriteUseCase.kt b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/usecase/PlayFavoriteUseCase.kt new file mode 100644 index 0000000..e014ad7 --- /dev/null +++ b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/usecase/PlayFavoriteUseCase.kt @@ -0,0 +1,41 @@ +package app.coilforphoniebox.domain.usecase + +import app.coilforphoniebox.domain.model.Favorite +import app.coilforphoniebox.domain.repository.BoxRepository +import app.coilforphoniebox.domain.repository.FavoriteRepository +import app.coilforphoniebox.domain.repository.PlayerRepository +import javax.inject.Inject + +/** + * Starts a favourite on the box it belongs to. + * + * A home screen shortcut must start *its* box regardless of which one is currently + * active (§7.3), so this switches the active box on success — otherwise the media + * notification and the app would disagree about what is playing. + * + * No cache lookup is involved: the favourite already carries the folder path that + * `play_folder` takes, which makes this exactly one RPC call (§6.5). + */ +class PlayFavoriteUseCase @Inject constructor( + private val favorites: FavoriteRepository, + private val boxes: BoxRepository, + private val player: PlayerRepository, +) { + suspend operator fun invoke(favoriteId: Long): Result { + val favorite = favorites.favorite(favoriteId) + ?: return Result.failure(IllegalArgumentException("Unknown favourite $favoriteId")) + return invoke(favorite) + } + + suspend operator fun invoke(favorite: Favorite): Result { + val target = favorite.toPlayTarget() + ?: return Result.failure(IllegalStateException("Favourite ${favorite.id} has no target")) + + return player.playOn(favorite.boxId, target) + .onSuccess { + favorites.recordLaunch(favorite.id) + boxes.setActive(favorite.boxId) + } + .map { favorite } + } +} diff --git a/core-domain/src/test/kotlin/app/coilforphoniebox/domain/model/BoxTest.kt b/core-domain/src/test/kotlin/app/coilforphoniebox/domain/model/BoxTest.kt new file mode 100644 index 0000000..d10f4c3 --- /dev/null +++ b/core-domain/src/test/kotlin/app/coilforphoniebox/domain/model/BoxTest.kt @@ -0,0 +1,28 @@ +package app.coilforphoniebox.domain.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +class BoxTest { + + private val box = Box( + id = "box-1", + displayName = "Living room", + host = "phoniebox.local", + addedAt = 0L, + ) + + @Test + fun `defaults are the TCP ports, not the WebSocket ones`() { + // JeroMQ has no ws:// transport, so 5556/5557 are never used. + assertEquals(5555, box.rpcPort) + assertEquals(5558, box.pubPort) + } + + /** Cover art comes over HTTP from the box's web server, not over ZMQ. */ + @Test + fun `cover urls point at the box's cover cache`() { + assertEquals("http://phoniebox.local/cover-cache/a1.jpg", box.coverUrl("a1.jpg")) + assertEquals("http://phoniebox.local/cover-cache/a1.jpg", box.coverUrl("/a1.jpg")) + } +} diff --git a/core-domain/src/test/kotlin/app/coilforphoniebox/domain/model/FavoriteTest.kt b/core-domain/src/test/kotlin/app/coilforphoniebox/domain/model/FavoriteTest.kt new file mode 100644 index 0000000..62b6c64 --- /dev/null +++ b/core-domain/src/test/kotlin/app/coilforphoniebox/domain/model/FavoriteTest.kt @@ -0,0 +1,50 @@ +package app.coilforphoniebox.domain.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class FavoriteTest { + + @Test + fun `a folder favourite plays the folder it stored`() { + val favorite = Favorite.of("box-1", "Bibi", PlayTarget.Folder("Audiobooks/Bibi")) + + assertEquals(FavoriteType.FOLDER, favorite.type) + assertEquals(PlayTarget.Folder("Audiobooks/Bibi"), favorite.toPlayTarget()) + } + + @Test + fun `an album favourite keeps both keys`() { + val favorite = Favorite.of("box-1", "Hexerei", PlayTarget.Album("Bibi", "Hexerei")) + + assertEquals(FavoriteType.ALBUM, favorite.type) + assertEquals(PlayTarget.Album("Bibi", "Hexerei"), favorite.toPlayTarget()) + } + + /** + * A single track is favouritable in its own right: from a playing song, "folder" and + * "track" are two different intentions and the user picks which one to save. + */ + @Test + fun `a track favourite plays the single file it stored`() { + val favorite = Favorite.of("box-1", "01.mp3", PlayTarget.Track("Audiobooks/Bibi/01.mp3")) + + assertEquals(FavoriteType.TRACK, favorite.type) + assertEquals(PlayTarget.Track("Audiobooks/Bibi/01.mp3"), favorite.toPlayTarget()) + } + + /** + * Rows like these can only arrive through an imported file. They stay visible and + * removable instead of throwing when the list is drawn. + */ + @Test + fun `a favourite with missing keys has no target rather than crashing`() { + assertNull(Favorite(boxId = "b", label = "x", type = FavoriteType.FOLDER).toPlayTarget()) + assertNull( + Favorite(boxId = "b", label = "x", type = FavoriteType.ALBUM, albumArtist = "only artist") + .toPlayTarget(), + ) + assertNull(Favorite(boxId = "b", label = "x", type = FavoriteType.TRACK).toPlayTarget()) + } +} diff --git a/core-transport/build.gradle.kts b/core-transport/build.gradle.kts new file mode 100644 index 0000000..31b5928 --- /dev/null +++ b/core-transport/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "app.coilforphoniebox.transport" + compileSdk = 36 + + defaultConfig { + minSdk = 26 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + api(project(":core-domain")) + + implementation(libs.androidx.core.ktx) + api(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.android) + + // Pure Java ZeroMQ: no NDK, and no ws:// transport — hence TCP 5555/5558. + api(libs.jeromq) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/core-transport/src/main/kotlin/app/coilforphoniebox/transport/BoxProbe.kt b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/BoxProbe.kt new file mode 100644 index 0000000..8f631b5 --- /dev/null +++ b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/BoxProbe.kt @@ -0,0 +1,28 @@ +package app.coilforphoniebox.transport + +import app.coilforphoniebox.domain.model.ConnectionTestResult +import javax.inject.Inject +import javax.inject.Singleton + +/** + * One-shot reachability check for the add-box and settings flows. + * + * Deliberately independent of [ConnectionManager]: testing an address must not disturb + * the live connection to the box that is currently playing. + */ +@Singleton +class BoxProbe @Inject constructor() { + + /** + * A reply of any shape means the box is there and its jukebox app is answering, which + * is all this needs to establish. The version is not asked for: no RPC returns it — it + * arrives over PubSub if the box publishes it at all. + */ + suspend fun probe(host: String, rpcPort: Int): ConnectionTestResult = + ZmqRpcClient(host, rpcPort).use { client -> + client.call(Commands.ping).fold( + onSuccess = { ConnectionTestResult.Reachable(version = null) }, + onFailure = { ConnectionTestResult.Unreachable }, + ) + } +} diff --git a/core-transport/src/main/kotlin/app/coilforphoniebox/transport/ConnectionManager.kt b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/ConnectionManager.kt new file mode 100644 index 0000000..2203594 --- /dev/null +++ b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/ConnectionManager.kt @@ -0,0 +1,162 @@ +package app.coilforphoniebox.transport + +import android.util.Log +import app.coilforphoniebox.domain.model.Box +import app.coilforphoniebox.domain.model.ConnectionState +import app.coilforphoniebox.domain.model.PlayerStatus +import app.coilforphoniebox.domain.model.SleepTimerStatus +import app.coilforphoniebox.domain.model.VolumeStatus +import app.coilforphoniebox.domain.repository.BoxRepository +import app.coilforphoniebox.transport.di.TransportScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonElement +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Owns the one live [PhonieboxSession] and rebuilds it when the active box changes. + * + * The connection is reference counted rather than tied to the process: the UI holds a + * reference while it is on screen, the media service holds one while it runs. At zero + * the sockets close, which is what makes "only while the app is open" mean what it + * says (§8.3). + */ +@OptIn(ExperimentalCoroutinesApi::class) +@Singleton +class ConnectionManager @Inject constructor( + private val boxes: BoxRepository, + @TransportScope private val scope: CoroutineScope, +) { + private val lock = Mutex() + private val _session = MutableStateFlow(null) + + private var owners = 0 + private var latestBox: Box? = null + + /** Identity of a *connection*, so a `lastSeenAt` write does not rebuild sockets. */ + private val Box.connectionKey: String get() = "$id|$host|$rpcPort|$pubPort" + private var currentKey: String? = null + + val activeBox: StateFlow = + boxes.activeBox.stateIn(scope, SharingStarted.Eagerly, null) + + val connection: StateFlow = _session + .flatMapLatest { it?.connection ?: flowOf(ConnectionState.DISCONNECTED) } + .stateIn(scope, SharingStarted.Eagerly, ConnectionState.DISCONNECTED) + + val status: StateFlow = _session + .flatMapLatest { it?.status ?: flowOf(PlayerStatus.Idle) } + .stateIn(scope, SharingStarted.Eagerly, PlayerStatus.Idle) + + val volume: StateFlow = _session + .flatMapLatest { it?.volume ?: flowOf(VolumeStatus.Unknown) } + .stateIn(scope, SharingStarted.Eagerly, VolumeStatus.Unknown) + + val boxVersion: StateFlow = _session + .flatMapLatest { it?.version ?: flowOf(null) } + .stateIn(scope, SharingStarted.Eagerly, null) + + /** A timer belongs to a box, so switching box drops back to "no timer". */ + val sleepTimer: StateFlow = _session + .flatMapLatest { it?.sleepTimer ?: flowOf(SleepTimerStatus.Off) } + .stateIn(scope, SharingStarted.Eagerly, SleepTimerStatus.Off) + + init { + scope.launch { + boxes.activeBox.collect { box -> + lock.withLock { + latestBox = box + reconcile() + } + } + } + + // A box that answers is a box that was seen; used by the switcher's reachability + // dots and by nothing that would loop back into a rebuild. + scope.launch { + connection.collect { state -> + if (state == ConnectionState.CONNECTED) { + latestBox?.let { boxes.markSeen(it.id) } + } + } + } + } + + /** Called by a screen or the media service that needs the box connected. */ + fun acquire() { + scope.launch { + lock.withLock { + owners++ + reconcile() + } + } + } + + fun release() { + scope.launch { + lock.withLock { + owners = (owners - 1).coerceAtLeast(0) + reconcile() + } + } + } + + private fun reconcile() { + val wanted = if (owners > 0) latestBox else null + val wantedKey = wanted?.connectionKey + if (wantedKey == currentKey) return + + _session.value?.let { + Log.i(TAG, "Closing session for ${it.box.displayName}") + it.close() + } + _session.value = wanted?.let { + Log.i(TAG, "Opening session for ${it.displayName} (${it.host})") + PhonieboxSession(it) + } + currentKey = wantedKey + } + + /** Sends [command] to the active box. */ + suspend fun call(command: PhonieboxCommand): Result = + _session.value?.call(command) ?: Result.failure(NotConnectedException()) + + /** + * Sends [command] to a named box, whether or not it is the active one. + * + * A home screen shortcut has to reach the box it was created for (§7.3), so when + * that is not the active box this opens a socket, sends one command and closes it + * again — no session, no subscription, no lasting state. + */ + suspend fun callOn(boxId: String, command: PhonieboxCommand): Result { + _session.value?.takeIf { it.box.id == boxId }?.let { return it.call(command) } + + val box = boxes.box(boxId) ?: return Result.failure(NotConnectedException()) + return ZmqRpcClient(box.host, box.rpcPort).use { client -> client.call(command) } + } + + /** Current status of the active box without collecting the flow. */ + fun currentStatus(): PlayerStatus = status.value + + fun currentVolume(): VolumeStatus = volume.value + + fun currentSleepTimer(): SleepTimerStatus = sleepTimer.value + + /** Asks the active box for its timer state; no session means nothing to ask. */ + suspend fun refreshSleepTimer(): Result = + _session.value?.refreshSleepTimer() ?: Result.failure(NotConnectedException()) + + private companion object { + const val TAG = "CoilConnection" + } +} diff --git a/core-transport/src/main/kotlin/app/coilforphoniebox/transport/HostDiscovery.kt b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/HostDiscovery.kt new file mode 100644 index 0000000..1df8d80 --- /dev/null +++ b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/HostDiscovery.kt @@ -0,0 +1,121 @@ +package app.coilforphoniebox.transport + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.util.Log +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.launch +import java.net.Inet4Address +import javax.inject.Inject +import javax.inject.Singleton + +/** + * mDNS scan for boxes on the local network. + * + * Convenience only — manual entry stays the primary path (§4.4). Avahi runs on + * Raspberry Pi OS by default, so a Phoniebox usually announces itself as a workstation + * and, if its web server registers one, as an HTTP service. + * + * Nothing here can tell a Phoniebox from any other Raspberry Pi. Candidates are + * confirmed by asking them for `core.version` — see [BoxProbe] — which is both cheap + * and conclusive. + */ +@Singleton +class HostDiscovery @Inject constructor( + @ApplicationContext private val context: Context, +) { + data class Candidate(val serviceName: String, val host: String) + + fun discover(): Flow = callbackFlow { + val nsdManager = context.getSystemService(Context.NSD_SERVICE) as? NsdManager + if (nsdManager == null) { + close() + return@callbackFlow + } + + // NsdManager tolerates exactly one resolve at a time on older releases, so + // requests are funnelled through a single consumer rather than fired at once. + val toResolve = Channel(Channel.UNLIMITED) + val seen = HashSet() + + launch { + for (service in toResolve) { + resolve(nsdManager, service)?.let { candidate -> + if (seen.add(candidate.host)) trySend(candidate) + } + } + } + + val listeners = SERVICE_TYPES.map { type -> + val listener = object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(serviceType: String?) = Unit + + override fun onServiceFound(serviceInfo: NsdServiceInfo) { + toResolve.trySend(serviceInfo) + } + + override fun onServiceLost(serviceInfo: NsdServiceInfo?) = Unit + + override fun onDiscoveryStopped(serviceType: String?) = Unit + + override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) { + Log.w(TAG, "Discovery of $serviceType failed with $errorCode") + } + + override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) = Unit + } + runCatching { + nsdManager.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, listener) + }.onFailure { Log.w(TAG, "Could not start discovery for $type", it) } + type to listener + } + + awaitClose { + listeners.forEach { (_, listener) -> + runCatching { nsdManager.stopServiceDiscovery(listener) } + } + toResolve.close() + } + } + + private suspend fun resolve(nsdManager: NsdManager, service: NsdServiceInfo): Candidate? { + val resolved = Channel(1) + + @Suppress("DEPRECATION") // registerServiceInfoCallback needs API 34; minSdk is 26. + nsdManager.resolveService( + service, + object : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) { + resolved.trySend(null) + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo) { + resolved.trySend(serviceInfo) + } + }, + ) + + val info = resolved.receive() ?: return null + + // IPv4 only: the box's address is typed into a text field and shown back to the + // user, and a link-local IPv6 address with a scope id is neither. + @Suppress("DEPRECATION") // getHostAddresses() needs API 34. + val address = info.host as? Inet4Address ?: return null + return Candidate( + serviceName = info.serviceName?.takeIf { it.isNotBlank() } ?: address.hostAddress.orEmpty(), + host = address.hostAddress ?: return null, + ) + } + + private companion object { + const val TAG = "CoilDiscovery" + + /** Avahi on Raspberry Pi OS registers the first; a web server may add the second. */ + val SERVICE_TYPES = listOf("_workstation._tcp.", "_http._tcp.") + } +} diff --git a/core-transport/src/main/kotlin/app/coilforphoniebox/transport/LibraryParser.kt b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/LibraryParser.kt new file mode 100644 index 0000000..8689769 --- /dev/null +++ b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/LibraryParser.kt @@ -0,0 +1,146 @@ +package app.coilforphoniebox.transport + +import app.coilforphoniebox.domain.model.FolderContent +import app.coilforphoniebox.domain.model.LibraryAlbum +import app.coilforphoniebox.domain.model.LibraryFolder +import app.coilforphoniebox.domain.model.LibraryTrack +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** Parses the RPC results that feed the library cache. */ +object LibraryParser { + + /** + * `get_folder_content` returns one level as + * `[{ "type": "directory", "name": "Bibi", "path": "/abs/path", "relpath": "Audiobooks/Bibi" }, …]` + * where `type` is one of `directory`, `file`, `stream`, `podcast`. + * + * **`relpath` is the one to keep.** `path` is absolute on the box's filesystem, + * while `play_folder` and `play_single` expect a path relative to the music + * library root — which is what `relpath` holds. + */ + fun folderContent( + boxId: String, + parentPath: String, + result: JsonElement?, + cachedAt: Long, + ): FolderContent { + val entries = (result as? JsonArray).orEmpty() + val folders = mutableListOf() + val tracks = mutableListOf() + + for (element in entries) { + val entry = element as? JsonObject ?: continue + val name = entry.string("name") ?: continue + val path = entry.string("relpath") ?: entry.string("path") ?: continue + + when (entry.string("type")) { + "directory" -> folders += LibraryFolder( + boxId = boxId, + path = path, + parentPath = parentPath, + displayName = name, + // Whether a subfolder has children of its own is only knowable by + // descending, and descending eagerly is exactly what this design + // avoids. Folders are always openable, so nothing reads this. + hasChildren = false, + cachedAt = cachedAt, + ) + + // file, stream and podcast are all playable by URL. + else -> tracks += LibraryTrack( + boxId = boxId, + url = path, + parentPath = parentPath, + // Names come from the box and are shown verbatim: content is user + // data and is never rewritten or re-cased (§12.4). + title = name, + artist = null, + album = null, + ) + } + } + + return FolderContent( + path = parentPath, + folders = folders, + tracks = tracks, + cachedAt = cachedAt, + ) + } + + /** + * `list_albums` is MPD's `list album group albumartist`, so each entry carries one + * album artist and either a single album or an array of them. + */ + fun albums(boxId: String, result: JsonElement?, cachedAt: Long): List { + val entries = (result as? JsonArray).orEmpty() + val albums = mutableListOf() + + for (element in entries) { + when (element) { + is JsonObject -> { + val artist = element.string("albumartist") + ?: element.string("artist") + ?: "" + element["album"].asStringList() + .filter { it.isNotBlank() } + .forEach { album -> + albums += LibraryAlbum(boxId, artist, album, cachedAt = cachedAt) + } + } + + // Ungrouped fallback: a plain list of album names. + is JsonPrimitive -> element.content.takeIf { it.isNotBlank() }?.let { album -> + albums += LibraryAlbum(boxId, "", album, cachedAt = cachedAt) + } + + else -> Unit + } + } + + return albums.distinctBy { it.albumArtist to it.album } + } + + /** Outcome of a cover art request. */ + sealed interface CoverArt { + /** A file name in the box's cover cache. */ + data class Available(val fileName: String) : CoverArt + + /** + * The box has queued the extraction on its own worker thread and has nothing to + * hand over yet. Ask again shortly — this is what the *first* request for any song + * returns, so treating it as a file name means no cover ever appears. + */ + data object Pending : CoverArt + + /** The box looked and there is no artwork for this song. */ + data object Missing : CoverArt + } + + /** + * `get_single_coverart` and `get_album_coverart` return a bare file name in the box's + * cover cache. The image itself is fetched over HTTP from the box's web server, never + * over ZMQ (§5). + * + * Two sentinel values matter, both from `coverart_cache_manager.py`: `CACHE_PENDING` + * while extraction is queued, and an empty string for "no artwork". + */ + fun coverArt(result: JsonElement?): CoverArt { + val value = (result as? JsonPrimitive)?.content?.trim() + ?: return CoverArt.Missing + + return when { + value == CACHE_PENDING -> CoverArt.Pending + value.isBlank() || value == "null" || value == "None" -> CoverArt.Missing + else -> CoverArt.Available(value.substringAfterLast('/')) + } + } + + /** `CACHE_PENDING` in `coverart_cache_manager.py`. */ + private const val CACHE_PENDING = "CACHE_PENDING" + + private fun JsonArray?.orEmpty(): List = this ?: emptyList() +} diff --git a/core-transport/src/main/kotlin/app/coilforphoniebox/transport/PayloadParsing.kt b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/PayloadParsing.kt new file mode 100644 index 0000000..d919623 --- /dev/null +++ b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/PayloadParsing.kt @@ -0,0 +1,50 @@ +package app.coilforphoniebox.transport + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Lenient accessors for everything arriving from the box. + * + * MPD hands most of its status over as strings — `"elapsed": "12.345"`, + * `"repeat": "1"` — and omits whole fields depending on the content: web radio has no + * `duration` and no `album`. Nothing here throws on a missing or oddly typed field; + * it returns null and the caller falls back (§4.2). + */ +internal fun JsonElement?.asPrimitiveOrNull(): JsonPrimitive? = this as? JsonPrimitive + +internal fun JsonObject.string(key: String): String? = + this[key].asPrimitiveOrNull()?.let { primitive -> + primitive.content.takeIf { it.isNotBlank() && it != "null" } + } + +internal fun JsonObject.double(key: String): Double? = + this[key].asPrimitiveOrNull()?.let { it.content.toDoubleOrNull() } + +internal fun JsonObject.int(key: String): Int? = + this[key].asPrimitiveOrNull()?.let { it.content.toDoubleOrNull()?.toInt() } + +/** + * Accepts `true`, `"true"`, `1` and `"1"`. MPD uses `"0"`/`"1"`, the volume plugin + * publishes a real JSON boolean, and both reach the same fields. + */ +internal fun JsonObject.boolean(key: String): Boolean? = + this[key].asPrimitiveOrNull()?.content?.lowercase()?.let { value -> + when (value) { + "1", "true", "yes", "on" -> true + "0", "false", "no", "off" -> false + else -> null + } + } + +/** + * A value that may be a single item or a list of them. `list_albums` groups by album + * artist, and an artist with several albums comes back as an array under one key. + */ +internal fun JsonElement?.asStringList(): List = when (this) { + is JsonPrimitive -> listOf(content) + is JsonArray -> mapNotNull { it.asPrimitiveOrNull()?.content } + else -> emptyList() +} diff --git a/core-transport/src/main/kotlin/app/coilforphoniebox/transport/PhonieboxCommand.kt b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/PhonieboxCommand.kt new file mode 100644 index 0000000..83f6761 --- /dev/null +++ b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/PhonieboxCommand.kt @@ -0,0 +1,287 @@ +package app.coilforphoniebox.transport + +import app.coilforphoniebox.domain.model.PlayTarget +import app.coilforphoniebox.domain.model.RepeatMode +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** + * One RPC call. Mirrors the payload the box expects: + * + * ```json + * { "id": "", "package": "player", "plugin": "ctrl", + * "method": "play_folder", "kwargs": { "folder": "Audiobooks/Bibi" } } + * ``` + */ +data class PhonieboxCommand( + val pkg: String, + val plugin: String, + val method: String? = null, + val kwargs: Map = emptyMap(), + val timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS, + /** + * Whether it is safe to send this command again after a timeout. + * + * Absolute commands (`set_volume`, `play_folder`, any getter) are; relative or + * toggling ones (`next`, `change_volume`, `toggle`) are not — a retried `next` + * would skip two tracks when the first request did arrive after all. + */ + val retryable: Boolean = false, +) { + /** Full dotted name, for logs and error messages. */ + val name: String get() = listOfNotNull(pkg, plugin, method).joinToString(".") + + fun toJson(id: String): JsonObject = buildJsonObject { + put("id", id) + put("package", pkg) + put("plugin", plugin) + method?.let { put("method", it) } + if (kwargs.isNotEmpty()) put("kwargs", JsonObject(kwargs)) + } + + companion object { + const val DEFAULT_TIMEOUT_MILLIS = 3_000L + + /** + * Library calls run synchronously on the box and can take a while on a large + * collection. They cannot be handed off with `as_thread` — see [Commands]. + */ + const val LIBRARY_TIMEOUT_MILLIS = 15_000L + } +} + +/** + * The commands Coil sends, and deliberately no others. + * + * Anything beyond playback stays out — `host.shutdown` and friends are never + * reachable from this app, which limits the damage if the box's unauthenticated RPC + * port is ever accidentally exposed (§16). + * + * Argument names are taken from the plugin signatures in + * `src/jukebox/components/player/playermpd/__init__.py` and + * `src/jukebox/components/volume/__init__.py` on `future3/main`, not from the web + * UI's command table, which omits several of them. + * + * **`as_thread` is never sent.** The implementation plan suggests it for slow calls, but + * `jukebox/plugs.py` starts a daemon thread and returns the `Thread` object rather than + * the function's result — so any call that carries it answers with something unusable. + * It is fire-and-forget only, and every call here needs its result. The cost is that a + * library call occupies the box's sequential RPC loop, and with it card detection, for as + * long as it runs; the mitigation is to call these rarely and never on a timer (§6). + */ +object Commands { + + // -------------------------------------------------------------------- ping + + /** + * Reachability check, also used as the watchdog's ping. + * + * There is no `core.version` RPC — the box only *publishes* `core.*` topics, and + * asking for that package answers with an error, which used to make every connection + * test fail. `playerstatus` returns the poller's cached dict without touching MPD, so + * it is both cheap and proof that the jukebox app itself is answering. + */ + val ping = player("playerstatus", retryable = true) + + // ---------------------------------------------------------------- player + + val play = player("play", retryable = true) + val pause = player("pause", retryable = true) + + /** Relative to the current state, so never retried. */ + val toggle = player("toggle") + val next = player("next") + val previous = player("prev") + + fun seek(positionSeconds: Double) = player( + "seek", + kwargs = mapOf("new_time" to JsonPrimitive(positionSeconds)), + retryable = true, + ) + + /** + * `shuffle` takes a named option rather than a flag. The box also accepts + * `toggle`, which is deliberately unused: the app knows the current state from + * `playerstatus`, and an absolute value survives a retry. + */ + fun shuffle(enabled: Boolean) = player( + "shuffle", + kwargs = mapOf("option" to JsonPrimitive(if (enabled) "enable" else "disable")), + retryable = true, + ) + + /** + * `repeat` also takes an option string. The box's own `toggle` cycles + * off → all → one, which is what the UI does — but it does so by asking for the + * target mode outright. + */ + fun repeat(mode: RepeatMode) = player( + "repeat", + kwargs = mapOf( + "option" to JsonPrimitive( + when (mode) { + RepeatMode.OFF -> "disable" + RepeatMode.ALL -> "enable_repeat" + RepeatMode.ONE -> "enable_repeat_single" + }, + ), + ), + retryable = true, + ) + + /** + * Same call as [ping], named for the other thing it is used for: pulling the status + * once after a rebuilt connection instead of waiting for the next publish. + * + * Never polled — the box publishes at 4 Hz on its own, and the last-value cache + * covers a fresh subscription (§4.2.1). + */ + val playerStatus = ping + + // --------------------------------------------------------------- content + + /** One level only. `list_all_dirs` is never used — unbounded memory on the box. */ + fun folderContent(folder: String) = player( + "get_folder_content", + kwargs = mapOf("folder" to JsonPrimitive(folder)), + timeoutMillis = PhonieboxCommand.LIBRARY_TIMEOUT_MILLIS, + retryable = true, + ) + + val listAlbums = player( + "list_albums", + timeoutMillis = PhonieboxCommand.LIBRARY_TIMEOUT_MILLIS, + retryable = true, + ) + + fun singleCoverArt(songUrl: String) = player( + "get_single_coverart", + kwargs = mapOf("song_url" to JsonPrimitive(songUrl)), + retryable = true, + ) + + fun albumCoverArt(albumArtist: String, album: String) = player( + "get_album_coverart", + kwargs = mapOf( + "albumartist" to JsonPrimitive(albumArtist), + "album" to JsonPrimitive(album), + ), + retryable = true, + ) + + /** + * Starts the box's MPD database scan. `update_wait` blocks until the scan is + * finished and is avoided for that reason (§6.4). + */ + val updateLibrary = player("update", retryable = true) + + fun play(target: PlayTarget): PhonieboxCommand = when (target) { + is PlayTarget.Folder -> player( + "play_folder", + kwargs = mapOf("folder" to JsonPrimitive(target.path)), + retryable = true, + ) + + is PlayTarget.Album -> player( + "play_album", + kwargs = mapOf( + "albumartist" to JsonPrimitive(target.albumArtist), + "album" to JsonPrimitive(target.album), + ), + retryable = true, + ) + + is PlayTarget.Track -> player( + "play_single", + kwargs = mapOf("song_url" to JsonPrimitive(target.url)), + retryable = true, + ) + } + + // ---------------------------------------------------------------- volume + + // No `get_volume`: the level arrives on the `volume.level` topic four times a second, + // and asking for it would only occupy the socket the box needs for its card reader. + + /** Upper limit configured on the box; used as `deviceVolumeMax` (§8.1). */ + val softMaxVolume = volume("get_soft_max_volume", retryable = true) + + fun setVolume(level: Int) = volume( + "set_volume", + kwargs = mapOf("volume" to JsonPrimitive(level)), + retryable = true, + ) + + /** Relative, so never retried. */ + fun changeVolume(step: Int) = volume( + "change_volume", + kwargs = mapOf("step" to JsonPrimitive(step)), + ) + + /** + * `mute` sets an absolute state despite the name — it is not a toggle. The caller + * passes the state it wants, which also makes it safe to retry. + */ + fun mute(muted: Boolean) = volume( + "mute", + kwargs = mapOf("mute" to JsonPrimitive(muted)), + retryable = true, + ) + + // ----------------------------------------------------------- sleep timer + + /** + * The box's timer that *stops the player*, and none of its siblings. + * + * `timers` also holds `timer_shutdown`, `timer_idle_shutdown` and `timer_fade_volume`. + * The two shutdown timers are deliberately unreachable from here: they switch the box + * off, which is exactly what Coil promises never to do (§1, §16). Adding them would also + * mean an unauthenticated LAN port could power down the box on a delay. + * + * `wait_seconds` it is — the plugin's own default is `default_timeout_sec`. + */ + fun startSleepTimer(seconds: Int) = timer( + "start", + kwargs = mapOf("wait_seconds" to JsonPrimitive(seconds)), + ) + + val cancelSleepTimer = timer("cancel") + + /** + * Asked once when the timer UI opens. The state *is* published, but only when it + * changes, so a box that has not touched its timer since booting has nothing in the + * last-value cache to hand over on subscribe. + */ + val sleepTimerState = timer("get_state", retryable = true) + + // --------------------------------------------------------------- helpers + + private fun player( + method: String, + kwargs: Map = emptyMap(), + timeoutMillis: Long = PhonieboxCommand.DEFAULT_TIMEOUT_MILLIS, + retryable: Boolean = false, + ) = PhonieboxCommand("player", "ctrl", method, kwargs, timeoutMillis, retryable) + + private fun volume( + method: String, + kwargs: Map = emptyMap(), + retryable: Boolean = false, + ) = PhonieboxCommand("volume", "ctrl", method, kwargs, retryable = retryable) + + /** Hard-wired to `timer_stop_player`: no other timer plugin is addressable from here. */ + private fun timer( + method: String, + kwargs: Map = emptyMap(), + retryable: Boolean = false, + ) = PhonieboxCommand(TIMER_PACKAGE, TIMER_STOP_PLAYER, method, kwargs, retryable = retryable) + + private const val TIMER_PACKAGE = "timers" + private const val TIMER_STOP_PLAYER = "timer_stop_player" + + /** The topic that plugin publishes its state on: `.`. */ + const val TIMER_STOP_PLAYER_TOPIC = "$TIMER_PACKAGE.$TIMER_STOP_PLAYER" +} diff --git a/core-transport/src/main/kotlin/app/coilforphoniebox/transport/PhonieboxSession.kt b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/PhonieboxSession.kt new file mode 100644 index 0000000..b3ddad5 --- /dev/null +++ b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/PhonieboxSession.kt @@ -0,0 +1,240 @@ +package app.coilforphoniebox.transport + +import android.os.SystemClock +import android.util.Log +import app.coilforphoniebox.domain.model.Box +import app.coilforphoniebox.domain.model.ConnectionState +import app.coilforphoniebox.domain.model.PlayerStatus +import app.coilforphoniebox.domain.model.SleepTimerStatus +import app.coilforphoniebox.domain.model.VolumeStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * One live connection to one box: the RPC socket pair and the SUB socket, plus the + * watchdog that keeps them honest. + * + * Exactly one session exists at a time. Switching boxes tears this down and builds a + * new one, which is cheap because the publisher's last-value cache means the new + * session's state arrives without a request (§7.1). + */ +class PhonieboxSession(val box: Box) : AutoCloseable { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val socketLock = Mutex() + + private val _connection = MutableStateFlow(ConnectionState.CONNECTING) + val connection: StateFlow = _connection.asStateFlow() + + private val _status = MutableStateFlow(PlayerStatus.Idle) + val status: StateFlow = _status.asStateFlow() + + private val _volume = MutableStateFlow(VolumeStatus.Unknown) + val volume: StateFlow = _volume.asStateFlow() + + private val _version = MutableStateFlow(null) + + /** `core.version`: never branched on, but included in bug reports (§1). */ + val version: StateFlow = _version.asStateFlow() + + private val _sleepTimer = MutableStateFlow(SleepTimerStatus.Off) + + /** + * The stop-player timer. Starts at "off" rather than "unknown": the box publishes this + * only when it changes, so a box that has not been asked to set one has nothing in the + * last-value cache — and "no timer" is what that means. + */ + val sleepTimer: StateFlow = _sleepTimer.asStateFlow() + + @Volatile + private var rpc: ZmqRpcClient = ZmqRpcClient(box.host, box.rpcPort) + + @Volatile + private var subscriber: ZmqStatusSubscriber = openSubscriber() + + init { + scope.launch { handshake() } + scope.launch { watchdog() } + } + + suspend fun call(command: PhonieboxCommand): Result = + rpc.call(command).onSuccess { + // A successful reply is proof of life, whatever the watchdog thought. + if (_connection.value != ConnectionState.CONNECTED) { + _connection.value = ConnectionState.CONNECTED + } + } + + /** + * Asks for the timer's state. Called when the timer UI opens, never on a schedule: + * the published topic covers every later change. + */ + suspend fun refreshSleepTimer(): Result = + call(Commands.sleepTimerState).map { result -> + StatusParser.sleepTimer(result, SystemClock.elapsedRealtime()) + ?.let { _sleepTimer.value = it } + Unit + } + + /** Pulls the status over RPC. Only for after a rebuilt connection — never polled. */ + suspend fun refreshStatus(): Result = + call(Commands.playerStatus).map { result -> + (result as? JsonObject) + ?.let { StatusParser.playerStatus(it, SystemClock.elapsedRealtime()) } + ?.let { _status.value = it } + Unit + } + + private fun openSubscriber() = ZmqStatusSubscriber( + host = box.host, + port = box.pubPort, + topics = ZmqStatusSubscriber.DEFAULT_TOPICS, + onMessage = ::onPublished, + ) + + /** Called on the subscriber's own thread; every write here is to a StateFlow. */ + private fun onPublished(topic: String, payload: String) { + when { + topic == ZmqStatusSubscriber.TOPIC_PLAYER_STATUS -> { + val parsed = StatusParser.playerStatus(payload, SystemClock.elapsedRealtime()) + if (parsed != null) { + _status.value = parsed + } else { + // A silent skip here looks exactly like a frozen player, so say so. + Log.w(TAG, "Unparseable playerstatus payload: ${payload.take(200)}") + } + } + + topic == ZmqStatusSubscriber.TOPIC_SLEEP_TIMER -> + StatusParser.sleepTimer(payload, SystemClock.elapsedRealtime()) + ?.let { _sleepTimer.value = it } + + topic.startsWith(ZmqStatusSubscriber.TOPIC_VOLUME) -> + StatusParser.volume(payload, _volume.value)?.let { _volume.value = it } + + topic == ZmqStatusSubscriber.TOPIC_VERSION -> + StatusParser.versionFromTopic(payload)?.let { _version.value = it } + + // What the daemon actually publishes about itself. Used only when no explicit + // version arrived, and only when it is a plain string worth showing. + topic == ZmqStatusSubscriber.TOPIC_GIT_STATE && _version.value == null -> + StatusParser.plainString(payload)?.let { _version.value = it } + } + + if (_connection.value != ConnectionState.CONNECTED) { + _connection.value = ConnectionState.CONNECTED + } + } + + /** + * One ping to establish reachability without waiting for the publisher, and one + * request for the soft volume maximum, which is never published and which the media + * session needs as `deviceVolumeMax` (§8.1). + * + * The ping is `playerstatus`, so its reply doubles as the initial state — the + * last-value cache would deliver that anyway, this just makes it immediate. + */ + private suspend fun handshake() { + call(Commands.ping) + .onSuccess { payload -> + (payload as? JsonObject) + ?.let { StatusParser.playerStatus(it, SystemClock.elapsedRealtime()) } + ?.let { _status.value = it } + } + .onFailure { + // Nothing published yet either means the box really is not there. + if (subscriber.lastMessageAtElapsed == 0L) { + _connection.value = ConnectionState.DISCONNECTED + } + } + + call(Commands.softMaxVolume).onSuccess { payload -> + StatusParser.softMaxVolume(payload)?.let { max -> + _volume.value = _volume.value.copy(maxLevel = max) + } + } + } + + /** + * ZMQ reconnects a dropped TCP connection by itself but never says so, and the box + * publishes unconditionally at 4 Hz — so silence, and only silence, means + * something is wrong. This is mandatory rather than optional (§4.3). + */ + private suspend fun watchdog() { + var backoffMillis = INITIAL_BACKOFF_MILLIS + + while (scope.isActive) { + delay(CHECK_INTERVAL_MILLIS) + + val lastMessage = subscriber.lastMessageAtElapsed + val silentFor = SystemClock.elapsedRealtime() - lastMessage + + if (lastMessage != 0L && silentFor < SILENCE_LIMIT_MILLIS) { + if (_connection.value != ConnectionState.CONNECTED) { + _connection.value = ConnectionState.CONNECTED + } + backoffMillis = INITIAL_BACKOFF_MILLIS + continue + } + + if (_connection.value == ConnectionState.CONNECTED) { + _connection.value = ConnectionState.DEGRADED + } + + if (rpc.call(Commands.ping).isSuccess) { + // Sockets are fine, the publisher just went quiet. Ask once. + _connection.value = ConnectionState.CONNECTED + backoffMillis = INITIAL_BACKOFF_MILLIS + refreshStatus() + continue + } + + _connection.value = ConnectionState.DISCONNECTED + delay(backoffMillis) + backoffMillis = (backoffMillis * 2).coerceAtMost(MAX_BACKOFF_MILLIS) + rebuild() + } + } + + private suspend fun rebuild() { + socketLock.withLock { + if (!scope.isActive) return + Log.i(TAG, "Rebuilding sockets for ${box.host}") + rpc.close() + subscriber.close() + _connection.value = ConnectionState.CONNECTING + rpc = ZmqRpcClient(box.host, box.rpcPort) + subscriber = openSubscriber() + } + handshake() + } + + override fun close() { + scope.cancel() + rpc.close() + subscriber.close() + _connection.value = ConnectionState.DISCONNECTED + } + + private companion object { + const val TAG = "CoilSession" + const val CHECK_INTERVAL_MILLIS = 5_000L + + /** 20 s of nothing at all, against an expected four messages a second. */ + const val SILENCE_LIMIT_MILLIS = 20_000L + const val INITIAL_BACKOFF_MILLIS = 1_000L + const val MAX_BACKOFF_MILLIS = 30_000L + } +} diff --git a/core-transport/src/main/kotlin/app/coilforphoniebox/transport/StatusParser.kt b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/StatusParser.kt new file mode 100644 index 0000000..b035553 --- /dev/null +++ b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/StatusParser.kt @@ -0,0 +1,154 @@ +package app.coilforphoniebox.transport + +import app.coilforphoniebox.domain.model.PlaybackState +import app.coilforphoniebox.domain.model.PlayerStatus +import app.coilforphoniebox.domain.model.RepeatMode +import app.coilforphoniebox.domain.model.SleepTimerStatus +import app.coilforphoniebox.domain.model.VolumeStatus +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Turns published payloads into domain models. + * + * `playerstatus` is MPD's own `status` merged with `currentsong`, with `volume` + * removed — volume travels on its own topic. Field names come from MPD and are not a + * contract Coil can rely on, so every one of them is centralised here: if upstream + * renames something, this file is the only place that needs to change (§15). + */ +object StatusParser { + + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + fun parseObject(raw: String): JsonObject? = + runCatching { json.parseToJsonElement(raw) as? JsonObject }.getOrNull() + + /** Returns null when the payload is not usable at all. */ + fun playerStatus(raw: String, receivedAtElapsedMillis: Long): PlayerStatus? = + parseObject(raw)?.let { playerStatus(it, receivedAtElapsedMillis) } + + /** + * Also used for the `player.ctrl.playerstatus` RPC result, which returns the very + * same structure — handy right after a rebuilt connection, where waiting for the + * publisher's last-value cache would be a guess. + */ + fun playerStatus(obj: JsonObject, receivedAtElapsedMillis: Long): PlayerStatus { + return PlayerStatus( + state = playbackState(obj.string("state")), + title = obj.string("title"), + // `albumartist` is the better label for an audiobook; `artist` is the + // per-track value and can differ track by track within one folder. + artist = obj.string("albumartist") ?: obj.string("artist"), + album = obj.string("album"), + file = obj.string("file"), + elapsedSeconds = obj.double("elapsed") ?: elapsedFromTimeField(obj), + durationSeconds = obj.double("duration") ?: totalFromTimeField(obj), + songId = obj.string("songid") ?: obj.string("id"), + // `song` is the queue position from `status`, `pos` the one from + // `currentsong`. They agree; both are read so neither being absent hurts. + playlistPosition = obj.int("song") ?: obj.int("pos"), + playlistLength = obj.int("playlistlength") ?: 0, + repeat = repeatMode(obj), + shuffle = obj.boolean("random") ?: false, + receivedAtElapsedMillis = receivedAtElapsedMillis, + ) + } + + /** + * `volume.level` publishes `{"volume": 42, "mute": false}`. [previous] carries the + * soft maximum forward, which only ever arrives over RPC. + */ + fun volume(raw: String, previous: VolumeStatus): VolumeStatus? { + val obj = parseObject(raw) + ?: return (raw.trim().toIntOrNull())?.let { previous.copy(level = it) } + + val level = obj.int("volume") ?: obj.int("level") ?: return null + return previous.copy( + level = level, + muted = obj.boolean("mute") ?: obj.boolean("muted") ?: previous.muted, + ) + } + + /** + * `timers.timer_stop_player` publishes, and `get_state` returns, + * `{"enabled": true, "remaining_seconds": 1187, "wait_seconds": 1800.0, + * "type": "GenericTimerClass"}`. + * + * Read leniently like every other payload here: `enabled` alone is enough to know + * whether a timer is running, and a missing or stringified `remaining_seconds` gives a + * running timer with nothing to count down rather than no timer at all. + */ + fun sleepTimer(payload: JsonElement?, receivedAtElapsedMillis: Long): SleepTimerStatus? { + val obj = payload as? JsonObject ?: return null + val enabled = obj.boolean("enabled") ?: return null + if (!enabled) return SleepTimerStatus.Off + + return SleepTimerStatus( + running = true, + remainingSeconds = (obj.int("remaining_seconds") ?: 0).coerceAtLeast(0), + requestedSeconds = obj.int("wait_seconds")?.takeIf { it > 0 }, + receivedAtElapsedMillis = receivedAtElapsedMillis, + ) + } + + fun sleepTimer(raw: String, receivedAtElapsedMillis: Long): SleepTimerStatus? = + sleepTimer(parseObject(raw), receivedAtElapsedMillis) + + /** `core.version` arrives as a bare string on the topic and as a string over RPC. */ + fun version(payload: JsonElement?): String? = when (payload) { + is JsonPrimitive -> payload.content.takeIf { it.isNotBlank() && it != "null" } + is JsonObject -> payload.string("version") ?: payload.string("value") + else -> null + } + + /** + * A payload that is a bare JSON string, and nothing else. Structured payloads return + * null rather than a stringified object, because this ends up on screen. + */ + fun plainString(raw: String): String? = + (runCatching { json.parseToJsonElement(raw) }.getOrNull() as? JsonPrimitive) + ?.takeIf { it.isString } + ?.content + ?.trim() + ?.takeIf { it.isNotBlank() && it != "null" } + + fun versionFromTopic(raw: String): String? = + runCatching { version(json.parseToJsonElement(raw)) }.getOrNull() + ?: raw.trim().trim('"').takeIf { it.isNotBlank() } + + /** `get_soft_max_volume` returns a bare number. */ + fun softMaxVolume(payload: JsonElement?): Int? = when (payload) { + is JsonPrimitive -> payload.content.toDoubleOrNull()?.toInt() + is JsonObject -> payload.int("volume") ?: payload.int("max") + else -> null + }?.takeIf { it in 1..100 } + + private fun playbackState(value: String?): PlaybackState = when (value?.lowercase()) { + "play" -> PlaybackState.PLAY + "pause" -> PlaybackState.PAUSE + else -> PlaybackState.STOP + } + + /** + * MPD reports `single` as `"0"`, `"1"` or `"oneshot"`; `repeat` plus `single` + * together are what the box's own `repeat` option strings map onto. + */ + private fun repeatMode(obj: JsonObject): RepeatMode { + val repeat = obj.boolean("repeat") ?: false + val single = obj.boolean("single") ?: (obj.string("single") == "oneshot") + return when { + single -> RepeatMode.ONE + repeat -> RepeatMode.ALL + else -> RepeatMode.OFF + } + } + + /** Older MPD protocol: `"time": ":"`. */ + private fun elapsedFromTimeField(obj: JsonObject): Double? = + obj.string("time")?.substringBefore(':')?.toDoubleOrNull() + + private fun totalFromTimeField(obj: JsonObject): Double? = + obj.string("time")?.substringAfter(':', missingDelimiterValue = "")?.toDoubleOrNull() +} diff --git a/core-transport/src/main/kotlin/app/coilforphoniebox/transport/TransportException.kt b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/TransportException.kt new file mode 100644 index 0000000..f4fd9fd --- /dev/null +++ b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/TransportException.kt @@ -0,0 +1,23 @@ +package app.coilforphoniebox.transport + +/** Base type for every failure the transport layer reports upwards. */ +sealed class TransportException(message: String, cause: Throwable? = null) : + Exception(message, cause) + +/** + * No reply within the command's timeout. Expected in normal operation: the box may + * be switched off, which is a state rather than an error. + */ +class RpcTimeoutException(val commandName: String) : + TransportException("No reply to $commandName") + +/** The box answered with an `error` object instead of a `result`. */ +class RpcErrorException(val commandName: String, val detail: String) : + TransportException("$commandName failed: $detail") + +/** No box configured, or the socket pair has been torn down. */ +class NotConnectedException : TransportException("Not connected to a box") + +/** The reply arrived but did not look like what the command promised. */ +class UnexpectedPayloadException(val commandName: String) : + TransportException("Unexpected payload for $commandName") diff --git a/core-transport/src/main/kotlin/app/coilforphoniebox/transport/ZmqRpcClient.kt b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/ZmqRpcClient.kt new file mode 100644 index 0000000..2f578ac --- /dev/null +++ b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/ZmqRpcClient.kt @@ -0,0 +1,177 @@ +package app.coilforphoniebox.transport + +import android.util.Log +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.zeromq.SocketType +import org.zeromq.ZContext +import org.zeromq.ZMQ +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * RPC against the box's `zmq.REP` socket on TCP 5555. + * + * Uses `DEALER` rather than `REQ`: a REQ socket allows exactly one outstanding + * request and the web UI carries a code comment saying concurrent requests break it. + * A DEALER can have several in flight, and correlation happens through the `id` + * field in the payload anyway. + * + * **The detail that decides whether any of this works:** with REQ/REP, ZMQ inserts an + * empty delimiter frame that a DEALER has to prepend itself. Without the `sendMore("")` + * below, the REP socket cannot read the envelope and never replies at all. + * + * JeroMQ sockets are not thread-safe, so exactly one thread ever touches the socket. + * Callers hand work over through [outgoing] and wait on a [CompletableDeferred]. + */ +class ZmqRpcClient( + private val host: String, + private val port: Int, +) : AutoCloseable { + + private class Request(val id: String, val payload: String) + + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + private val outgoing = Channel(Channel.UNLIMITED) + private val pending = ConcurrentHashMap>() + + @Volatile + private var running = true + + private val thread = Thread({ socketLoop() }, "coil-rpc-$host").apply { + isDaemon = true + start() + } + + /** + * Sends [command] and waits for the matching reply. + * + * Retries once for commands marked [PhonieboxCommand.retryable]. Relative ones — + * `next`, `change_volume`, `toggle` — are never retried: if the first request did + * arrive and only the reply was lost, a retry would apply the change twice. + */ + suspend fun call(command: PhonieboxCommand): Result { + val attempts = if (command.retryable) 2 else 1 + var failure: Result = Result.failure(NotConnectedException()) + repeat(attempts) { attempt -> + if (attempt > 0) Log.d(TAG, "Retrying ${command.name}") + val result = attempt(command) + if (result.isSuccess) return result + failure = result + } + return failure + } + + private suspend fun attempt(command: PhonieboxCommand): Result { + if (!running) return Result.failure(NotConnectedException()) + + val id = UUID.randomUUID().toString() + val reply = CompletableDeferred() + pending[id] = reply + + return try { + outgoing.trySend(Request(id, command.toJson(id).toString())) + val response = withTimeoutOrNull(command.timeoutMillis) { reply.await() } + ?: return Result.failure(RpcTimeoutException(command.name)) + + response["error"]?.let { error -> + return Result.failure(RpcErrorException(command.name, error.toString())) + } + Result.success(response["result"] ?: JsonNull) + } catch (e: TransportException) { + // The socket thread gave up while this call was waiting. + Result.failure(e) + } finally { + pending.remove(id) + } + } + + private fun socketLoop() { + try { + ZContext().use { context -> + val socket = context.createSocket(SocketType.DEALER).apply { + linger = 0 + // Surfaces dead connections faster under Doze, where a silently + // broken TCP connection would otherwise look healthy for minutes. + tcpKeepAlive = 1 + tcpKeepAliveIdle = KEEPALIVE_IDLE_SECONDS + tcpKeepAliveInterval = KEEPALIVE_INTERVAL_SECONDS + receiveTimeOut = POLL_MILLIS + connect("tcp://$host:$port") + } + + while (running) { + while (true) { + val request = outgoing.tryReceive().getOrNull() ?: break + socket.sendMore("") // the delimiter frame REP expects + socket.send(request.payload) + } + receiveOne(socket) + } + } + } catch (e: Throwable) { + // The context is gone; every caller has to fail rather than hang. + Log.w(TAG, "RPC loop for $host ended", e) + } finally { + running = false + failAllPending() + } + } + + private fun receiveOne(socket: ZMQ.Socket) { + // Null on the receive timeout, which is the idle case. + val first = socket.recvStr() ?: return + val body = if (first.isEmpty() && socket.hasReceiveMore()) socket.recvStr() else first + if (body.isNullOrBlank()) return + + val obj = runCatching { json.parseToJsonElement(body).jsonObject }.getOrNull() + if (obj == null) { + Log.w(TAG, "Dropping unparseable reply from $host") + return + } + + // Replies without an id still have to be drained — the box always answers, + // and an unread reply would sit in the receive queue forever. + val id = obj["id"]?.let { runCatching { it.jsonPrimitive.content }.getOrNull() } ?: return + pending.remove(id)?.complete(obj) + } + + /** + * Completes waiting callers with a failure rather than cancelling them: + * cancelling the deferred would cancel the *caller's* coroutine, which is a + * surprising way for "the box went away" to arrive at a button press. + */ + private fun failAllPending() { + pending.keys.toList().forEach { id -> + pending.remove(id)?.completeExceptionally(NotConnectedException()) + } + } + + override fun close() { + // The loop notices within one poll interval and tears the context down + // itself; interrupting it mid-receive would be a worse way to stop. + running = false + outgoing.close() + } + + private companion object { + const val TAG = "CoilRpc" + + /** + * How long the socket thread blocks waiting for a reply before checking the + * outgoing queue again. It bounds the delay on a command, so it stays short; + * 20 wakeups a second is nothing next to the 4 Hz status stream. + */ + const val POLL_MILLIS = 50 + + const val KEEPALIVE_IDLE_SECONDS = 30L + const val KEEPALIVE_INTERVAL_SECONDS = 10L + } +} diff --git a/core-transport/src/main/kotlin/app/coilforphoniebox/transport/ZmqStatusSubscriber.kt b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/ZmqStatusSubscriber.kt new file mode 100644 index 0000000..68df23e --- /dev/null +++ b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/ZmqStatusSubscriber.kt @@ -0,0 +1,124 @@ +package app.coilforphoniebox.transport + +import android.os.SystemClock +import android.util.Log +import org.zeromq.SocketType +import org.zeromq.ZContext + +/** + * `SUB` socket on TCP 5558. + * + * No polling anywhere in the app: the box polls MPD itself every 250 ms and publishes + * the result, so the client gets four updates a second without asking. It also keeps + * a last-value cache, which means subscribing alone yields the complete current state + * — there is no initial `playerstatus` request on connect (§4.2.1, §4.2.2). + * + * That publish happens unconditionally, including while the box is stopped: roughly + * 345,000 messages a day for a long-running service. The volume is irrelevant, the + * JSON parsing is not, so identical payloads are dropped here by raw string compare + * before anything is parsed (§4.2.3). + */ +class ZmqStatusSubscriber( + private val host: String, + private val port: Int, + private val topics: List, + private val onMessage: (topic: String, payload: String) -> Unit, +) : AutoCloseable { + + private val lastRawPerTopic = HashMap() + + /** + * When the last message arrived, on the elapsed-realtime clock. The watchdog + * reads this: ZMQ reconnects TCP silently and never reports it upwards, so + * silence is the only signal that something is wrong (§4.3). + */ + @Volatile + var lastMessageAtElapsed: Long = 0L + private set + + @Volatile + private var running = true + + init { + Thread({ socketLoop() }, "coil-sub-$host").apply { + isDaemon = true + start() + } + } + + private fun socketLoop() { + try { + ZContext().use { context -> + val socket = context.createSocket(SocketType.SUB).apply { + linger = 0 + tcpKeepAlive = 1 + receiveTimeOut = POLL_MILLIS + topics.forEach { subscribe(it.toByteArray()) } + connect("tcp://$host:$port") + } + + while (running) { + val topic = socket.recvStr() ?: continue + if (!socket.hasReceiveMore()) continue + val payload = socket.recvStr() ?: continue + + // Updated even for a duplicate payload: an unchanged status still + // proves the link is alive, which is exactly what matters here. + lastMessageAtElapsed = SystemClock.elapsedRealtime() + + if (lastRawPerTopic[topic] == payload) continue + lastRawPerTopic[topic] = payload + + onMessage(topic, payload) + } + } + } catch (e: Throwable) { + Log.w(TAG, "Subscriber for $host ended", e) + } finally { + running = false + } + } + + override fun close() { + running = false + } + + companion object { + private const val TAG = "CoilSub" + + /** Long enough to stay cheap at rest, short enough to close promptly. */ + private const val POLL_MILLIS = 250 + + const val TOPIC_PLAYER_STATUS = "playerstatus" + + /** Prefix subscription: catches `volume.level` and any sibling topic. */ + const val TOPIC_VOLUME = "volume" + + /** + * Prefix subscription for the box's own identity topics. + * + * Deliberately a prefix rather than `core.version`: the daemon publishes + * `core.started_at`, `core.git_state` and `core.plugins.*`, and *not* a version — + * so subscribing to the family is the only way to pick up whatever it does send. + */ + const val TOPIC_CORE_PREFIX = "core." + const val TOPIC_VERSION = "core.version" + const val TOPIC_GIT_STATE = "core.git_state" + + /** + * The stop-player timer's state topic, and only it. + * + * Subscribing to the `timers.` family would also deliver the shutdown timers' + * state. Coil has no business showing a countdown to something it refuses to + * trigger, so the subscription names the one timer it supports (§16). + */ + const val TOPIC_SLEEP_TIMER = Commands.TIMER_STOP_PLAYER_TOPIC + + val DEFAULT_TOPICS = listOf( + TOPIC_PLAYER_STATUS, + TOPIC_VOLUME, + TOPIC_CORE_PREFIX, + TOPIC_SLEEP_TIMER, + ) + } +} diff --git a/core-transport/src/main/kotlin/app/coilforphoniebox/transport/di/TransportModule.kt b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/di/TransportModule.kt new file mode 100644 index 0000000..64214d9 --- /dev/null +++ b/core-transport/src/main/kotlin/app/coilforphoniebox/transport/di/TransportModule.kt @@ -0,0 +1,27 @@ +package app.coilforphoniebox.transport.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 javax.inject.Qualifier +import javax.inject.Singleton + +/** Scope that lives as long as the process and owns the transport's coroutines. */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class TransportScope + +@Module +@InstallIn(SingletonComponent::class) +object TransportModule { + + @Provides + @Singleton + @TransportScope + fun provideTransportScope(): CoroutineScope = + CoroutineScope(SupervisorJob() + Dispatchers.IO) +} diff --git a/core-transport/src/test/kotlin/app/coilforphoniebox/transport/CommandsTest.kt b/core-transport/src/test/kotlin/app/coilforphoniebox/transport/CommandsTest.kt new file mode 100644 index 0000000..c8df5ae --- /dev/null +++ b/core-transport/src/test/kotlin/app/coilforphoniebox/transport/CommandsTest.kt @@ -0,0 +1,191 @@ +package app.coilforphoniebox.transport + +import app.coilforphoniebox.domain.model.PlayTarget +import app.coilforphoniebox.domain.model.RepeatMode +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Argument names and values here have to match the box's plugin signatures exactly — a + * wrong kwarg name fails silently, because the box answers either way. + */ +class CommandsTest { + + @Test + fun `payload carries id, package, plugin, method and kwargs`() { + val json = Commands.play(PlayTarget.Folder("Audiobooks/Bibi")).toJson("abc-123") + + assertEquals("abc-123", json["id"]?.jsonPrimitive?.content) + assertEquals("player", json["package"]?.jsonPrimitive?.content) + assertEquals("ctrl", json["plugin"]?.jsonPrimitive?.content) + assertEquals("play_folder", json["method"]?.jsonPrimitive?.content) + assertEquals( + "Audiobooks/Bibi", + json["kwargs"]?.jsonObject?.get("folder")?.jsonPrimitive?.content, + ) + } + + @Test + fun `kwargs are omitted when there are none`() { + val json = Commands.toggle.toJson("id") + + assertNull(json["kwargs"]) + assertNull(json["as_thread"]) + } + + /** + * `jukebox/plugs.py` starts a daemon thread for an `as_thread` call and returns the + * Thread object instead of the function's result, so a call that carries it answers + * with something unusable. Every command here needs its result. + */ + @Test + fun `as_thread is never sent, because it discards the result`() { + val commands = listOf( + Commands.ping, + Commands.listAlbums, + Commands.folderContent("Audiobooks"), + Commands.singleCoverArt("A/01.mp3"), + Commands.albumCoverArt("X", "A"), + Commands.updateLibrary, + Commands.play(PlayTarget.Folder("x")), + ) + commands.forEach { command -> + assertNull("${command.name} must not set as_thread", command.toJson("id")["as_thread"]) + } + } + + /** + * There is no `core` RPC package — `core.*` are published topics only — so asking for + * `core.version` answers with an error and every connection test failed. + */ + @Test + fun `the reachability ping is playerstatus, not core version`() { + assertEquals("player.ctrl.playerstatus", Commands.ping.name) + assertEquals("player", Commands.ping.pkg) + assertTrue(Commands.ping.retryable) + } + + @Test + fun `library calls get a longer timeout than the rest`() { + assertEquals(PhonieboxCommand.LIBRARY_TIMEOUT_MILLIS, Commands.listAlbums.timeoutMillis) + assertEquals( + PhonieboxCommand.LIBRARY_TIMEOUT_MILLIS, + Commands.folderContent("Audiobooks").timeoutMillis, + ) + assertEquals(PhonieboxCommand.DEFAULT_TIMEOUT_MILLIS, Commands.play.timeoutMillis) + } + + @Test + fun `shuffle takes an option string rather than a flag`() { + assertEquals("enable", Commands.shuffle(true).kwargs["option"]?.jsonPrimitive?.content) + assertEquals("disable", Commands.shuffle(false).kwargs["option"]?.jsonPrimitive?.content) + } + + @Test + fun `repeat maps each mode onto the box's own option names`() { + fun option(mode: RepeatMode) = Commands.repeat(mode).kwargs["option"]?.jsonPrimitive?.content + + assertEquals("disable", option(RepeatMode.OFF)) + assertEquals("enable_repeat", option(RepeatMode.ALL)) + assertEquals("enable_repeat_single", option(RepeatMode.ONE)) + } + + @Test + fun `mute is absolute, not a toggle`() { + assertEquals("true", Commands.mute(true).kwargs["mute"]?.jsonPrimitive?.content) + assertEquals("false", Commands.mute(false).kwargs["mute"]?.jsonPrimitive?.content) + } + + @Test + fun `seek uses new_time`() { + assertEquals("12.5", Commands.seek(12.5).kwargs["new_time"]?.jsonPrimitive?.content) + } + + @Test + fun `album playback needs both keys`() { + val command = Commands.play(PlayTarget.Album("Bibi Blocksberg", "Hexerei")) + + assertEquals("play_album", command.method) + assertEquals("Bibi Blocksberg", command.kwargs["albumartist"]?.jsonPrimitive?.content) + assertEquals("Hexerei", command.kwargs["album"]?.jsonPrimitive?.content) + } + + /** + * The distinction that keeps a lost reply from skipping two tracks: absolute commands + * may be retried, relative ones may not. + */ + @Test + fun `only absolute commands are retryable`() { + assertTrue(Commands.play.retryable) + assertTrue(Commands.setVolume(30).retryable) + assertTrue(Commands.play(PlayTarget.Folder("x")).retryable) + assertTrue(Commands.ping.retryable) + + assertFalse(Commands.next.retryable) + assertFalse(Commands.previous.retryable) + assertFalse(Commands.toggle.retryable) + assertFalse(Commands.changeVolume(5).retryable) + } + + @Test + fun `command name is the dotted plugin path`() { + assertEquals("player.ctrl.play_folder", Commands.play(PlayTarget.Folder("x")).name) + assertEquals("volume.ctrl.set_volume", Commands.setVolume(30).name) + } + + /** The UI offers minutes; the plugin signature is `wait_seconds`. */ + @Test + fun `the sleep timer addresses timer_stop_player in seconds`() { + val json = Commands.startSleepTimer(1_800).toJson("id") + + assertEquals("timers", json["package"]?.jsonPrimitive?.content) + assertEquals("timer_stop_player", json["plugin"]?.jsonPrimitive?.content) + assertEquals("start", json["method"]?.jsonPrimitive?.content) + assertEquals("1800", json["kwargs"]?.jsonObject?.get("wait_seconds")?.jsonPrimitive?.content) + + assertEquals("timers.timer_stop_player.cancel", Commands.cancelSleepTimer.name) + assertEquals("timers.timer_stop_player.get_state", Commands.sleepTimerState.name) + } + + /** + * The promise this app makes: no command it can send switches the box off. The box's + * `timer_shutdown` and `timer_idle_shutdown` plugins would do exactly that, so nothing + * in the catalogue may name them — this fails if someone adds one. + */ + @Test + fun `no command addresses a shutdown timer`() { + val everyName = listOf( + Commands.startSleepTimer(60), + Commands.cancelSleepTimer, + Commands.sleepTimerState, + Commands.play, + Commands.pause, + Commands.toggle, + Commands.next, + Commands.previous, + Commands.ping, + Commands.updateLibrary, + Commands.listAlbums, + Commands.softMaxVolume, + Commands.setVolume(10), + Commands.changeVolume(1), + Commands.mute(true), + Commands.seek(1.0), + Commands.shuffle(true), + Commands.repeat(RepeatMode.ALL), + Commands.folderContent("x"), + Commands.singleCoverArt("x"), + Commands.albumCoverArt("x", "y"), + Commands.play(PlayTarget.Folder("x")), + ).map { it.name } + + assertTrue(everyName.none { it.contains("shutdown") }) + assertTrue(everyName.none { it.contains("host.") }) + assertTrue(everyName.none { it.contains("reboot") }) + } +} diff --git a/core-transport/src/test/kotlin/app/coilforphoniebox/transport/LibraryParserTest.kt b/core-transport/src/test/kotlin/app/coilforphoniebox/transport/LibraryParserTest.kt new file mode 100644 index 0000000..84337e1 --- /dev/null +++ b/core-transport/src/test/kotlin/app/coilforphoniebox/transport/LibraryParserTest.kt @@ -0,0 +1,122 @@ +package app.coilforphoniebox.transport + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class LibraryParserTest { + + private fun parse(raw: String) = Json.parseToJsonElement(raw) + + /** + * The box returns both an absolute `path` and a `relpath`. Only `relpath` is usable: + * `play_folder` and `play_single` expect a path relative to the music library root. + */ + @Test + fun `folder content keeps the relative path, not the absolute one`() { + val raw = """ + [ + {"type":"directory","name":"Bibi","path":"/home/pi/audiofolders/Audiobooks/Bibi","relpath":"Audiobooks/Bibi"}, + {"type":"file","name":"01.mp3","path":"/home/pi/audiofolders/Audiobooks/01.mp3","relpath":"Audiobooks/01.mp3"} + ] + """.trimIndent() + + val content = LibraryParser.folderContent("box-1", "Audiobooks", parse(raw), cachedAt = 5L) + + assertEquals(1, content.folders.size) + assertEquals("Audiobooks/Bibi", content.folders.first().path) + assertEquals("Bibi", content.folders.first().displayName) + assertEquals("Audiobooks", content.folders.first().parentPath) + + assertEquals(1, content.tracks.size) + assertEquals("Audiobooks/01.mp3", content.tracks.first().url) + assertEquals(5L, content.cachedAt) + } + + @Test + fun `streams and podcasts are treated as playable entries`() { + val raw = """ + [ + {"type":"stream","name":"Radio","path":"http://a","relpath":"http://a"}, + {"type":"podcast","name":"Show","path":"http://b","relpath":"http://b"} + ] + """.trimIndent() + + val content = LibraryParser.folderContent("box-1", "", parse(raw), 0L) + + assertEquals(2, content.tracks.size) + assertTrue(content.folders.isEmpty()) + } + + @Test + fun `entries missing a name or a path are skipped rather than fatal`() { + val raw = """[{"type":"directory"},{"type":"directory","name":"Ok","relpath":"Ok"}]""" + + val content = LibraryParser.folderContent("box-1", "", parse(raw), 0L) + + assertEquals(1, content.folders.size) + } + + /** + * `list_albums` groups by album artist, so an artist with several albums comes back + * with an array under one key. + */ + @Test + fun `albums are read from grouped entries with one or many albums`() { + val raw = """ + [ + {"albumartist":"Bibi Blocksberg","album":["Hexerei","Der Reiterhof"]}, + {"albumartist":"Benjamin","album":"Zoo"} + ] + """.trimIndent() + + val albums = LibraryParser.albums("box-1", parse(raw), cachedAt = 9L) + + assertEquals(3, albums.size) + assertTrue(albums.any { it.albumArtist == "Bibi Blocksberg" && it.album == "Hexerei" }) + assertTrue(albums.any { it.albumArtist == "Bibi Blocksberg" && it.album == "Der Reiterhof" }) + assertTrue(albums.any { it.albumArtist == "Benjamin" && it.album == "Zoo" }) + assertTrue(albums.all { it.cachedAt == 9L }) + } + + @Test + fun `albums without an artist still come through`() { + val albums = LibraryParser.albums("box-1", parse("""[{"album":"Untitled"}]"""), 0L) + + assertEquals(1, albums.size) + assertEquals("", albums.first().albumArtist) + } + + @Test + fun `cover art returns a bare file name`() { + assertEquals( + LibraryParser.CoverArt.Available("abc123.jpg"), + LibraryParser.coverArt(parse("\"abc123.jpg\"")), + ) + assertEquals( + LibraryParser.CoverArt.Available("abc123.jpg"), + LibraryParser.coverArt(parse("\"/cache/abc123.jpg\"")), + ) + } + + /** + * The box queues extraction on a worker thread and answers the *first* request for any + * song with this sentinel. Treating it as a file name is why no cover ever appeared. + */ + @Test + fun `CACHE_PENDING is a retry signal, not a file name`() { + assertEquals( + LibraryParser.CoverArt.Pending, + LibraryParser.coverArt(parse("\"CACHE_PENDING\"")), + ) + } + + @Test + fun `an empty answer means there is no artwork`() { + assertEquals(LibraryParser.CoverArt.Missing, LibraryParser.coverArt(parse("\"\""))) + assertEquals(LibraryParser.CoverArt.Missing, LibraryParser.coverArt(parse("null"))) + assertEquals(LibraryParser.CoverArt.Missing, LibraryParser.coverArt(null)) + } +} diff --git a/core-transport/src/test/kotlin/app/coilforphoniebox/transport/StatusParserTest.kt b/core-transport/src/test/kotlin/app/coilforphoniebox/transport/StatusParserTest.kt new file mode 100644 index 0000000..12d2100 --- /dev/null +++ b/core-transport/src/test/kotlin/app/coilforphoniebox/transport/StatusParserTest.kt @@ -0,0 +1,162 @@ +package app.coilforphoniebox.transport + +import app.coilforphoniebox.domain.model.PlaybackState +import app.coilforphoniebox.domain.model.RepeatMode +import app.coilforphoniebox.domain.model.VolumeStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The box publishes MPD's own status, which means numbers arrive as strings and whole + * fields go missing depending on the content. These are the shapes seen in practice. + */ +class StatusParserTest { + + @Test + fun `parses a typical playing status with stringified numbers`() { + val raw = """ + { + "state": "play", "elapsed": "12.345", "duration": "204.6", + "song": "3", "songid": "17", "playlistlength": "12", + "repeat": "0", "random": "0", "single": "0", + "file": "Audiobooks/Bibi/01.mp3", "title": "Chapter 1", + "artist": "Bibi Blocksberg", "albumartist": "Bibi Blocksberg", + "album": "Hexerei" + } + """.trimIndent() + + val status = StatusParser.playerStatus(raw, receivedAtElapsedMillis = 42L)!! + + assertEquals(PlaybackState.PLAY, status.state) + assertEquals(12.345, status.elapsedSeconds!!, 0.001) + assertEquals(204.6, status.durationSeconds!!, 0.001) + assertEquals(3, status.playlistPosition) + assertEquals("17", status.songId) + assertEquals(12, status.playlistLength) + assertEquals(RepeatMode.OFF, status.repeat) + assertFalse(status.shuffle) + assertEquals("Chapter 1", status.title) + assertEquals("Audiobooks/Bibi", status.folder) + assertEquals(42L, status.receivedAtElapsedMillis) + } + + @Test + fun `web radio has neither duration nor album`() { + val raw = """{"state":"play","elapsed":"91.0","file":"http://stream.example/live","title":"Some show"}""" + + val status = StatusParser.playerStatus(raw, 0L)!! + + assertNull(status.durationSeconds) + assertNull(status.album) + assertTrue(status.hasContent) + } + + @Test + fun `an idle box reports stop and stays parseable`() { + val status = StatusParser.playerStatus("""{"state":"stop","playlistlength":"0"}""", 0L)!! + + assertEquals(PlaybackState.STOP, status.state) + assertEquals(0, status.playlistLength) + assertFalse(status.hasContent) + } + + @Test + fun `repeat and single together decide the repeat mode`() { + fun mode(repeat: String, single: String) = StatusParser + .playerStatus("""{"state":"play","repeat":"$repeat","single":"$single","file":"a.mp3"}""", 0L)!! + .repeat + + assertEquals(RepeatMode.OFF, mode("0", "0")) + assertEquals(RepeatMode.ALL, mode("1", "0")) + assertEquals(RepeatMode.ONE, mode("1", "1")) + // MPD also has a one-shot single mode, which is closer to "one" than to "off". + assertEquals(RepeatMode.ONE, mode("1", "oneshot")) + } + + @Test + fun `falls back to the old time field when duration is absent`() { + val status = StatusParser.playerStatus("""{"state":"play","time":"30:180","file":"a.mp3"}""", 0L)!! + + assertEquals(30.0, status.elapsedSeconds!!, 0.001) + assertEquals(180.0, status.durationSeconds!!, 0.001) + } + + @Test + fun `prefers the album artist over the per-track artist`() { + val raw = """{"state":"play","file":"a.mp3","artist":"Narrator","albumartist":"Series"}""" + + assertEquals("Series", StatusParser.playerStatus(raw, 0L)!!.artist) + } + + @Test + fun `rubbish payloads return null rather than throwing`() { + assertNull(StatusParser.playerStatus("not json at all", 0L)) + assertNull(StatusParser.playerStatus("", 0L)) + } + + @Test + fun `volume arrives as an object and keeps the soft maximum`() { + val previous = VolumeStatus(level = 10, maxLevel = 80, muted = false) + + val volume = StatusParser.volume("""{"volume": 42, "mute": true}""", previous)!! + + assertEquals(42, volume.level) + assertTrue(volume.muted) + assertEquals(80, volume.maxLevel) + } + + @Test + fun `volume also tolerates a bare number`() { + val volume = StatusParser.volume("37", VolumeStatus(level = 10, maxLevel = 90))!! + + assertEquals(37, volume.level) + assertEquals(90, volume.maxLevel) + } + + @Test + fun `soft maximum outside the plausible range is ignored`() { + assertEquals(75, StatusParser.softMaxVolume(jsonPrimitive("75"))) + assertNull(StatusParser.softMaxVolume(jsonPrimitive("0"))) + assertNull(StatusParser.softMaxVolume(jsonPrimitive("140"))) + } + + /** `wait_seconds` comes back as a float, and the box's own numbers may be strings. */ + @Test + fun `a running sleep timer is read with its remaining seconds`() { + val timer = StatusParser.sleepTimer( + """{"enabled": true, "remaining_seconds": 1187, "wait_seconds": 1800.0, + "type": "GenericTimerClass"}""", + receivedAtElapsedMillis = 10_000L, + )!! + + assertTrue(timer.running) + assertEquals(1187, timer.remainingSeconds) + assertEquals(1800, timer.requestedSeconds) + // Counted down locally, because the box only publishes on change. + assertEquals(1177, timer.remainingSecondsAt(20_000L)) + assertEquals(0, timer.remainingSecondsAt(10_000L + 1_187_000L + 5_000L)) + } + + @Test + fun `a disabled timer has nothing left, and an unusable payload is not a timer`() { + val off = StatusParser.sleepTimer("""{"enabled": false, "remaining_seconds": 42}""", 0L)!! + + assertFalse(off.running) + assertEquals(0, off.remainingSecondsAt(60_000L)) + + assertNull(StatusParser.sleepTimer("""{"type": "GenericTimerClass"}""", 0L)) + assertNull(StatusParser.sleepTimer("not json", 0L)) + } + + @Test + fun `version is read from a topic payload with or without quotes`() { + assertEquals("v3.6.0", StatusParser.versionFromTopic("\"v3.6.0\"")) + assertEquals("v3.6.0", StatusParser.versionFromTopic("v3.6.0")) + } + + private fun jsonPrimitive(value: String) = + kotlinx.serialization.json.Json.parseToJsonElement(value) +} diff --git a/docs/pages/assets/screenshots/01_player.png b/docs/pages/assets/screenshots/01_player.png new file mode 100644 index 0000000..8d7a56a Binary files /dev/null and b/docs/pages/assets/screenshots/01_player.png differ diff --git a/docs/pages/assets/screenshots/02_library.png b/docs/pages/assets/screenshots/02_library.png new file mode 100644 index 0000000..9dedeb3 Binary files /dev/null and b/docs/pages/assets/screenshots/02_library.png differ diff --git a/docs/pages/assets/screenshots/03_favourites.png b/docs/pages/assets/screenshots/03_favourites.png new file mode 100644 index 0000000..10bca17 Binary files /dev/null and b/docs/pages/assets/screenshots/03_favourites.png differ diff --git a/docs/pages/assets/screenshots/04_search.png b/docs/pages/assets/screenshots/04_search.png new file mode 100644 index 0000000..256c5bf Binary files /dev/null and b/docs/pages/assets/screenshots/04_search.png differ diff --git a/docs/pages/assets/screenshots/05_sleep_timer.png b/docs/pages/assets/screenshots/05_sleep_timer.png new file mode 100644 index 0000000..3c3cd65 Binary files /dev/null and b/docs/pages/assets/screenshots/05_sleep_timer.png differ diff --git a/docs/pages/index.md b/docs/pages/index.md index 76f620a..9ae4830 100644 --- a/docs/pages/index.md +++ b/docs/pages/index.md @@ -9,5 +9,21 @@ A native Android remote for [Phoniebox](https://github.com/MiczFlor/RPi-Jukebox- Open it, see what is playing, pick something else, hand the phone back. + +
+ The player screen, showing cover art, the track title and transport controls + Browsing the library by folder, with a mini player at the bottom + Saved favourites as a grid of covers + Searching the cached library, with folder, album and track results + The player with a sleep timer running, counting down to when playback stops +
+ - [Source on GitHub](https://github.com/EarMaster/coil) - [Privacy Policy](/privacy/) diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 776b0df..859e09d 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -37,7 +37,10 @@ Both bind to `tcp://*`, so both are reachable across the local network. - `id` is optional but required to correlate responses - `method` is optional when the plugin itself is callable -- `as_thread` runs the call on its own thread — use for potentially slow calls +- `as_thread` runs the call on its own thread **and returns the `Thread` object instead of the + function's result** (`jukebox/plugs.py`). It is fire-and-forget only: any call whose answer you + need must omit it, even a slow one. Verified against a real box — sending it is what made every + library call come back empty - `tsp` (timestamp in nanoseconds) makes the server return its processing time ### Response @@ -51,6 +54,13 @@ When `tsp` was sent, the response additionally carries `total_processing_time` i **A response is always sent**, even without an `id`. The client must drain every reply or its receive queue fills up. +### There is no `core` RPC package + +`core.*` names are **published topics only**. Calling `core.version` over RPC answers with an +error, which is a convincing way to make a working box look unreachable. For a reachability check +use `player.ctrl.playerstatus`: it returns the status poller's cached dict without touching MPD, so +it is cheap and proves the jukebox app itself is answering. + ### Socket type The server is a **plain `zmq.REP` socket** with a blocking `recv()` loop. @@ -75,7 +85,8 @@ The same REP socket is bound to `inproc`, `tcp` and `ws` simultaneously. Interna |---|---| | `playerstatus` | Core status: essentially the MPD status plus song data | | `volume.level` | Current volume | -| `core.version` | Version identifier, usable as a connection check | +| `core.git_state` | Identifies the installed source state; the closest thing to a version | +| `core.version` | Documented, but **not published** by `daemon.py` — subscribe to the `core.` prefix rather than this exact topic | | `core.started_at` | Start timestamp | | `rfid.card_id` | Most recently read card | | `host.temperature.cpu` | CPU temperature | @@ -117,11 +128,35 @@ Full reference: `src/webapp/src/commands/index.js` in the Phoniebox repository. Do **not** use `list_all_dirs` — the repository explicitly warns about memory consumption on large libraries. Use `get_folder_content` lazily, one level at a time. +`get_folder_content(folder)` returns entries of the form +`{ type: directory|file|stream|podcast, name, path, relpath }`. **Use `relpath`**: `path` is absolute +on the box's filesystem, while `play_folder` and `play_single` expect a path relative to the music +library root. + +`shuffle(option)` and `repeat(option)` take named strings, not MPD flags — `enable`/`disable` for +shuffle, `disable`/`enable_repeat`/`enable_repeat_single` for repeat. + `update` triggers the MPD database scan. `update_wait` blocks until completion and should be avoided because of the shared socket. +### Timers — `timers.*` + +Four plugins: `timer_stop_player`, `timer_shutdown`, `timer_idle_shutdown`, `timer_fade_volume`. +Each takes `start(wait_seconds)`, `cancel()` and `get_state()`, and publishes its state on the topic +`timers.` — **on change only, not once a second**. `get_state` returns +`{enabled, remaining_seconds, wait_seconds, type}`. + +Coil uses **`timer_stop_player` and no other**: the two shutdown plugins would switch the box off, +which is out of scope by design (§16). See AGENTS.md "The sleep timer, and the timers Coil refuses". + +`GenericTimerClass.start` **ignores the call when the timer is already running** ("Ignoring start +command" in the box's log, then it returns). Changing a running timer's duration means `cancel` +first, otherwise the old duration silently stands. + ### Volume — `volume.ctrl.*` -`get_volume`, `set_volume(volume)`, `change_volume(step)`, `mute`, `get_soft_max_volume` +`get_volume`, `set_volume(volume)`, `change_volume(step)`, `mute(mute)`, `get_soft_max_volume` + +Despite the name, `mute` is **not** a toggle: it takes the state you want as a `mute` boolean. `get_soft_max_volume` returns the upper limit — use it as `deviceVolumeMax` in the media session. diff --git a/fastlane/metadata/android/README.md b/fastlane/metadata/android/README.md new file mode 100644 index 0000000..79ca75b --- /dev/null +++ b/fastlane/metadata/android/README.md @@ -0,0 +1,90 @@ +# Play Store listing metadata + +Google Play *store listing* text, one directory per locale, in the layout `fastlane supply` +and Triple-T's gradle-play-publisher both read natively. Keeping it here means listing copy is +reviewed in PRs like any other translated string instead of being retyped into the Play Console. + +Locales are Coil's five launch locales, matching the app's translated resources +(`app/src/main/res/values-*`) and `localeFilters` in `app/build.gradle.kts`: `en-US`, `de-DE`, +`es-ES`, `fr-FR`, `nl-NL`. + +| File | Play limit | Status | +|---|---|---| +| `title.txt` | 30 characters | present | +| `short_description.txt` | 80 characters | present | +| `full_description.txt` | 4000 characters | not written yet | +| `changelogs/{versionCode}.txt` | 500 characters | written per release by `/release` | + +## Release notes are named after the versionCode + +`changelogs/7.txt` is the What's New text for **versionCode 7** — not version 0.7.0. That is how +fastlane and the Play API key release notes, and it is the easy thing to get wrong: a file named +after the versionName, or one left at the previous versionCode, is silently never picked up and +the release ships with empty release notes. + +All five locales are required for every release. There is no English fallback for store text, so a +missing locale means a blank What's New in that language rather than an English one. `google-play.yml` +enforces this and fails the deploy rather than shipping a gap. + +## Screenshots + +`en-US/images/` holds the listing screenshots, generated from the app itself rather than taken +by hand: + +```bash +./gradlew :app:recordRoborazziDebug --tests '*StoreAssetTest' +``` + +That renders `phoneScreenshots`, `sevenInchScreenshots` and `tenInchScreenshots` from +`StoreAssetTest` (see AGENTS.md, "Store assets"), strips the alpha channel Play rejects, and +copies the phone set to the website. `check_store_metadata.sh` validates them the same way it +validates the text — format, alpha, dimensions, aspect ratio and count — so a screenshot that +Play would refuse fails here first. + +Only English is generated: the other four translations are unreviewed drafts, and Play falls +back to the default language's images for a locale that has none. + +## What does *not* live here + +**The feature graphic.** Would go in `/images/featureGraphic.png` (1024×500); not +written yet. + +## What CI does with these files + +`google-play.yml` reads `changelogs/{versionCode}.txt` for every locale, validates it, and copies +it into the flat `whatsnew-` naming `r0adkll/upload-google-play` expects. + +That action does **not** upload listing text — title, short description and full description are +still copied into the Play Console by hand. These files are the canonical source; the Console is +the copy. Closing that gap means adding a `fastlane supply` step or moving to +gradle-play-publisher, either of which would read this tree as-is. + +## Checking character limits + +```sh +tools/check_store_metadata.sh # listing text +tools/check_store_metadata.sh 7 # also require release notes for versionCode 7 +``` + +Play enforces every limit per locale, in characters. Do not hand-count with `wc -m`: it only +decodes multi-byte characters when the shell locale is UTF-8 and counts bytes otherwise, so in +Git Bash on Windows `für` measures 4 and every accented locale looks longer than it is. The script +counts bytes minus UTF-8 continuation bytes, which is exact regardless of locale. + +## Translation status + +The four non-English locales are **drafts**, not reviewed by fluent speakers — same caveat as the +header comments in `app/src/main/res/values-*/strings.xml`. Per `CONTRIBUTING`, a locale ships only +after a human has read it. Unlike in-app strings, there is no English fallback here: an unreviewed +listing is what users see in the store, so review matters more, not less. + +Rendering notes for the current text: + +- "coil" stays lowercase and "Phoniebox" stays untranslated in every locale — both are proper + names, Phoniebox being the upstream project's. +- The wry sense of English "proper" (*a real one, as it should be*) is carried by `richtige` (de), + `en toda regla` (es), `véritable` (fr) and `volwaardige` (nl). A literal "correct/appropriate" + rendering would lose the tone. +- de/nl take a definite article before the device noun (`für die`, `voor de`) where dropping it + reads clipped; fr/es treat Phoniebox as a bare product name, which also sidesteps guessing its + grammatical gender in French. diff --git a/fastlane/metadata/android/de-DE/changelogs/2.txt b/fastlane/metadata/android/de-DE/changelogs/2.txt new file mode 100644 index 0000000..682758b --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/2.txt @@ -0,0 +1 @@ +Erste Version. Sehen, was läuft, die Bibliothek durchstöbern und durchsuchen und alles direkt abspielen. Ordner, Alben oder einzelne Titel als Favoriten speichern und auf den Startbildschirm legen. Ein Sleep-Timer beendet die Wiedergabe, ohne die Box auszuschalten. Die Steuerung erscheint auf dem Sperrbildschirm, und mehrere Boxen sind möglich. diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt new file mode 100644 index 0000000..7254eda --- /dev/null +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -0,0 +1,14 @@ +Phoniebox macht aus einem Raspberry Pi eine RFID-Jukebox: Karte auf die Box legen, die Wiedergabe startet. Mitgeliefert wird eine Web-Oberfläche, die am Rechner gut funktioniert und am Handy eher unbequem ist. + +coil ist eine richtige Android-App für dieselbe Aufgabe. Aufmachen, sehen was läuft, etwas anderes auswählen, Handy zurückgeben. Sobald die Box spielt, erscheint die Steuerung auf dem Sperrbildschirm — die App muss dafür nicht geöffnet werden. + +- Player mit Cover, Spulen, Wiedergabesteuerung und Lautstärke +- Bibliothek nach Ordnern oder Alben durchstöbern und alles direkt abspielen +- Steuerung auf dem Sperrbildschirm und in der Benachrichtigung, erscheint von selbst, sobald die Box spielt, und lässt sich komplett abschalten +- Favoriten mit Verknüpfungen auf dem Startbildschirm: Ein Tippen startet Hörbuch oder Album, ohne die App zu öffnen +- Mehrere Boxen — eine pro Zimmer, Wechsel über die obere Leiste, jede mit eigenen Favoriten +- Die Lautstärketasten am Handy regeln die Box, solange die Wiedergabe aktiv ist + +coil behält alles auf deinem Gerät und in deinem Netzwerk. Kein Konto, keine Telemetrie, keine Cloud. + +coil ist ein unabhängiger Client eines Drittanbieters. Die App steht in keiner Verbindung zum Phoniebox-Projekt und wird von ihm nicht offiziell unterstützt. Phoniebox ist das Werk von MiczFlor und weiteren Mitwirkenden. diff --git a/fastlane/metadata/android/de-DE/short_description.txt b/fastlane/metadata/android/de-DE/short_description.txt new file mode 100644 index 0000000..f11b457 --- /dev/null +++ b/fastlane/metadata/android/de-DE/short_description.txt @@ -0,0 +1 @@ +Eine richtige Android-Fernbedienung für die Phoniebox. diff --git a/fastlane/metadata/android/de-DE/title.txt b/fastlane/metadata/android/de-DE/title.txt new file mode 100644 index 0000000..186002b --- /dev/null +++ b/fastlane/metadata/android/de-DE/title.txt @@ -0,0 +1 @@ +coil für Phoniebox diff --git a/fastlane/metadata/android/en-US/changelogs/2.txt b/fastlane/metadata/android/en-US/changelogs/2.txt new file mode 100644 index 0000000..49a72a5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/2.txt @@ -0,0 +1 @@ +First release. See what is playing, browse and search the library, and start anything you find. Save folders, albums or single tracks as favourites and put them on your home screen. A sleep timer stops the music without switching the box off. Controls appear on your lock screen, and more than one box is supported. diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..55b8b73 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,14 @@ +Phoniebox turns a Raspberry Pi into an RFID jukebox: put a card on the box, audio plays. It comes with a web UI that works well on a desktop and awkwardly on a phone. + +coil is a proper Android app for the same job. Open it, see what is playing, pick something else, hand the phone back. When the box starts playing, playback controls appear on your lock screen — no app launch needed. + +- Player with cover art, scrubbing, transport controls and volume +- Library browsing by folder or album, play anything you find +- Lock screen and notification controls that appear on their own when the box starts playing, and can be switched off entirely +- Favourites with home screen shortcuts, so one tap starts an audiobook or album without opening the app +- Multiple boxes — one per room, switched from the top bar, each with its own favourites +- Hardware volume buttons control the box while the media session is active + +coil keeps everything on your device and your network. No account, no telemetry, no cloud. + +coil is an independent third-party client. It is not affiliated with or endorsed by the Phoniebox project. Phoniebox is the work of MiczFlor and contributors. diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/01_player.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/01_player.png new file mode 100644 index 0000000..8d7a56a Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/01_player.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/02_library.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/02_library.png new file mode 100644 index 0000000..9dedeb3 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/02_library.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/03_favourites.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/03_favourites.png new file mode 100644 index 0000000..10bca17 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/03_favourites.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/04_search.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/04_search.png new file mode 100644 index 0000000..256c5bf Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/04_search.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/05_sleep_timer.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/05_sleep_timer.png new file mode 100644 index 0000000..3c3cd65 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/05_sleep_timer.png differ diff --git a/fastlane/metadata/android/en-US/images/sevenInchScreenshots/01_player.png b/fastlane/metadata/android/en-US/images/sevenInchScreenshots/01_player.png new file mode 100644 index 0000000..a40cf10 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/sevenInchScreenshots/01_player.png differ diff --git a/fastlane/metadata/android/en-US/images/sevenInchScreenshots/02_library.png b/fastlane/metadata/android/en-US/images/sevenInchScreenshots/02_library.png new file mode 100644 index 0000000..302a4b4 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/sevenInchScreenshots/02_library.png differ diff --git a/fastlane/metadata/android/en-US/images/sevenInchScreenshots/03_favourites.png b/fastlane/metadata/android/en-US/images/sevenInchScreenshots/03_favourites.png new file mode 100644 index 0000000..1ae51c4 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/sevenInchScreenshots/03_favourites.png differ diff --git a/fastlane/metadata/android/en-US/images/sevenInchScreenshots/04_search.png b/fastlane/metadata/android/en-US/images/sevenInchScreenshots/04_search.png new file mode 100644 index 0000000..5d05520 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/sevenInchScreenshots/04_search.png differ diff --git a/fastlane/metadata/android/en-US/images/sevenInchScreenshots/05_sleep_timer.png b/fastlane/metadata/android/en-US/images/sevenInchScreenshots/05_sleep_timer.png new file mode 100644 index 0000000..b161f97 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/sevenInchScreenshots/05_sleep_timer.png differ diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/01_player.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/01_player.png new file mode 100644 index 0000000..6e1ec9a Binary files /dev/null and b/fastlane/metadata/android/en-US/images/tenInchScreenshots/01_player.png differ diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/02_library.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/02_library.png new file mode 100644 index 0000000..586adea Binary files /dev/null and b/fastlane/metadata/android/en-US/images/tenInchScreenshots/02_library.png differ diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/03_favourites.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/03_favourites.png new file mode 100644 index 0000000..ac428ea Binary files /dev/null and b/fastlane/metadata/android/en-US/images/tenInchScreenshots/03_favourites.png differ diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/04_search.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/04_search.png new file mode 100644 index 0000000..a58d95a Binary files /dev/null and b/fastlane/metadata/android/en-US/images/tenInchScreenshots/04_search.png differ diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/05_sleep_timer.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/05_sleep_timer.png new file mode 100644 index 0000000..3886cb4 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/tenInchScreenshots/05_sleep_timer.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..ef161d4 --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +A proper Android remote for Phoniebox. diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..a81bf93 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +coil for Phoniebox diff --git a/fastlane/metadata/android/es-ES/changelogs/2.txt b/fastlane/metadata/android/es-ES/changelogs/2.txt new file mode 100644 index 0000000..4784e8e --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/2.txt @@ -0,0 +1 @@ +Primera versión. Ver qué está sonando, explorar y buscar en la biblioteca y reproducir lo que encuentres. Guardar carpetas, álbumes o pistas concretas como favoritos y ponerlos en la pantalla de inicio. Un temporizador detiene la reproducción sin apagar la caja. Los controles aparecen en la pantalla de bloqueo y se admiten varias cajas. diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt new file mode 100644 index 0000000..db0aac7 --- /dev/null +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -0,0 +1,14 @@ +Phoniebox convierte una Raspberry Pi en una jukebox RFID: pones una tarjeta sobre la caja y empieza la reproducción. Incluye una interfaz web que funciona bien en el ordenador y con torpeza en el móvil. + +coil es una aplicación Android en toda regla para lo mismo. La abres, ves qué está sonando, eliges otra cosa y devuelves el móvil. Cuando la caja empieza a reproducir, los controles aparecen en la pantalla de bloqueo, sin necesidad de abrir la aplicación. + +- Reproductor con portada, barra de progreso, controles de reproducción y volumen +- Biblioteca por carpeta o álbum, con reproducción directa de lo que encuentres +- Controles en la pantalla de bloqueo y en la notificación, que aparecen por su cuenta cuando la caja empieza a reproducir y se pueden desactivar por completo +- Favoritos con accesos directos en la pantalla de inicio: un toque inicia un audiolibro o un álbum sin abrir la aplicación +- Varias cajas: una por habitación, se cambia desde la barra superior y cada una tiene sus propios favoritos +- Las teclas de volumen del móvil controlan la caja mientras la reproducción está activa + +coil mantiene todo en tu dispositivo y en tu red. Sin cuenta, sin telemetría, sin nube. + +coil es un cliente independiente de terceros. No está afiliado al proyecto Phoniebox ni cuenta con su respaldo. Phoniebox es obra de MiczFlor y de quienes contribuyen al proyecto. diff --git a/fastlane/metadata/android/es-ES/short_description.txt b/fastlane/metadata/android/es-ES/short_description.txt new file mode 100644 index 0000000..e1c1876 --- /dev/null +++ b/fastlane/metadata/android/es-ES/short_description.txt @@ -0,0 +1 @@ +Un mando a distancia Android en toda regla para Phoniebox. diff --git a/fastlane/metadata/android/es-ES/title.txt b/fastlane/metadata/android/es-ES/title.txt new file mode 100644 index 0000000..647fd32 --- /dev/null +++ b/fastlane/metadata/android/es-ES/title.txt @@ -0,0 +1 @@ +coil para Phoniebox diff --git a/fastlane/metadata/android/fr-FR/changelogs/2.txt b/fastlane/metadata/android/fr-FR/changelogs/2.txt new file mode 100644 index 0000000..e524327 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/2.txt @@ -0,0 +1 @@ +Première version. Voir ce qui est en cours, parcourir et rechercher la bibliothèque, et lancer ce que vous trouvez. Enregistrer des dossiers, des albums ou des titres comme favoris et les poser sur l'écran d'accueil. Une minuterie arrête la lecture sans éteindre la box. Les commandes apparaissent sur l'écran de verrouillage, et plusieurs box sont possibles. diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt new file mode 100644 index 0000000..e6e02c1 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -0,0 +1,14 @@ +Phoniebox transforme un Raspberry Pi en juke-box RFID : on pose une carte sur la box et la lecture démarre. Elle est livrée avec une interface web, à l'aise sur un ordinateur et malcommode sur un téléphone. + +coil est une véritable application Android pour le même usage. Vous l'ouvrez, vous voyez ce qui est en cours, vous choisissez autre chose, vous rendez le téléphone. Dès que la box se met à jouer, les commandes de lecture apparaissent sur votre écran de verrouillage, sans avoir à lancer l'application. + +- Lecteur avec pochette, barre de progression, commandes de lecture et volume +- Bibliothèque à parcourir par dossier ou par album, avec lecture immédiate +- Commandes sur l'écran de verrouillage et dans la notification, qui apparaissent d'elles-mêmes dès que la box joue et peuvent être désactivées entièrement +- Favoris avec raccourcis sur l'écran d'accueil : un appui lance un livre audio ou un album sans ouvrir l'application +- Plusieurs box : une par pièce, changement depuis la barre du haut, chacune avec ses propres favoris +- Les touches de volume du téléphone règlent la box tant que la lecture est active + +coil garde tout sur votre appareil et sur votre réseau. Aucun compte, aucune télémétrie, aucun cloud. + +coil est un client indépendant, développé par un tiers. Il n'est ni affilié au projet Phoniebox ni approuvé par lui. Phoniebox est l'œuvre de MiczFlor et de ses contributeurs. diff --git a/fastlane/metadata/android/fr-FR/short_description.txt b/fastlane/metadata/android/fr-FR/short_description.txt new file mode 100644 index 0000000..778d898 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/short_description.txt @@ -0,0 +1 @@ +Une véritable télécommande Android pour Phoniebox. diff --git a/fastlane/metadata/android/fr-FR/title.txt b/fastlane/metadata/android/fr-FR/title.txt new file mode 100644 index 0000000..fe18c1e --- /dev/null +++ b/fastlane/metadata/android/fr-FR/title.txt @@ -0,0 +1 @@ +coil pour Phoniebox diff --git a/fastlane/metadata/android/nl-NL/changelogs/2.txt b/fastlane/metadata/android/nl-NL/changelogs/2.txt new file mode 100644 index 0000000..4288101 --- /dev/null +++ b/fastlane/metadata/android/nl-NL/changelogs/2.txt @@ -0,0 +1 @@ +Eerste versie. Zien wat er speelt, de bibliotheek doorbladeren en doorzoeken en alles direct afspelen. Mappen, albums of losse nummers als favoriet opslaan en op je startscherm zetten. Een slaaptimer stopt de weergave zonder de box uit te zetten. De bediening verschijnt op je vergrendelscherm en meerdere boxen zijn mogelijk. diff --git a/fastlane/metadata/android/nl-NL/full_description.txt b/fastlane/metadata/android/nl-NL/full_description.txt new file mode 100644 index 0000000..3522497 --- /dev/null +++ b/fastlane/metadata/android/nl-NL/full_description.txt @@ -0,0 +1,14 @@ +Phoniebox maakt van een Raspberry Pi een RFID-jukebox: kaartje op de box leggen, de weergave start. Er zit een webinterface bij die op een computer prima werkt en op een telefoon onhandig is. + +coil is een volwaardige Android-app voor hetzelfde werk. Openen, zien wat er speelt, iets anders kiezen, telefoon weer teruggeven. Zodra de box begint te spelen, verschijnt de bediening op je vergrendelscherm — de app hoeft er niet voor open. + +- Speler met hoes, voortgangsbalk, bedieningstoetsen en volume +- Bibliotheek doorbladeren per map of album en alles direct afspelen +- Bediening op het vergrendelscherm en in de melding, verschijnt van zichzelf zodra de box speelt en kan helemaal uit +- Favorieten met snelkoppelingen op het startscherm: één tik start een luisterboek of album zonder de app te openen +- Meerdere boxen — één per kamer, wisselen via de balk bovenaan, elk met eigen favorieten +- De volumetoetsen van de telefoon regelen de box zolang de weergave actief is + +coil houdt alles op je toestel en in je eigen netwerk. Geen account, geen telemetrie, geen cloud. + +coil is een onafhankelijke client van derden. De app is niet verbonden met het Phoniebox-project en wordt daar niet door onderschreven. Phoniebox is het werk van MiczFlor en andere bijdragers. diff --git a/fastlane/metadata/android/nl-NL/short_description.txt b/fastlane/metadata/android/nl-NL/short_description.txt new file mode 100644 index 0000000..219abff --- /dev/null +++ b/fastlane/metadata/android/nl-NL/short_description.txt @@ -0,0 +1 @@ +Een volwaardige Android-afstandsbediening voor de Phoniebox. diff --git a/fastlane/metadata/android/nl-NL/title.txt b/fastlane/metadata/android/nl-NL/title.txt new file mode 100644 index 0000000..c2824bb --- /dev/null +++ b/fastlane/metadata/android/nl-NL/title.txt @@ -0,0 +1 @@ +coil voor Phoniebox diff --git a/feature-media/build.gradle.kts b/feature-media/build.gradle.kts new file mode 100644 index 0000000..2e1ec5c --- /dev/null +++ b/feature-media/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "app.coilforphoniebox.media" + compileSdk = 36 + + defaultConfig { + minSdk = 26 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation(project(":core-domain")) + implementation(project(":core-data")) + implementation(project(":core-transport")) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.service) + implementation(libs.kotlinx.coroutines.android) + + api(libs.media3.session) + api(libs.media3.common) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/feature-media/src/main/AndroidManifest.xml b/feature-media/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7b75a65 --- /dev/null +++ b/feature-media/src/main/AndroidManifest.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-media/src/main/kotlin/app/coilforphoniebox/media/AutoSessionStarter.kt b/feature-media/src/main/kotlin/app/coilforphoniebox/media/AutoSessionStarter.kt new file mode 100644 index 0000000..85c8d5d --- /dev/null +++ b/feature-media/src/main/kotlin/app/coilforphoniebox/media/AutoSessionStarter.kt @@ -0,0 +1,72 @@ +package app.coilforphoniebox.media + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.util.Log +import androidx.core.content.ContextCompat +import androidx.media3.common.util.UnstableApi +import app.coilforphoniebox.domain.model.SessionMode +import app.coilforphoniebox.domain.repository.BoxRepository +import app.coilforphoniebox.domain.repository.SettingsRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Starts the automatic session when its conditions hold (§8.3). + * + * Three of them, all necessary: the global mode is automatic, the active box allows it, + * and the device is on Wi-Fi. A box in a guest room can be excluded permanently through + * its own flag (§7.4), and monitoring is limited to the active box — one persistent + * connection per configured box would multiply the battery cost of a feature that is + * already a compromise. + * + * Whether the start succeeds is not entirely up to the app: Android restricts starting a + * foreground service from the background, so a start from the boot receiver can be + * refused outright. That is caught and logged rather than crashing, and the app starts + * the service again the next time it is opened. This is the honest limitation the + * settings screen warns about. + */ +@androidx.annotation.OptIn(UnstableApi::class) +@Singleton +class AutoSessionStarter @Inject constructor( + @ApplicationContext private val context: Context, + private val settings: SettingsRepository, + private val boxes: BoxRepository, +) { + + suspend fun startIfEnabled(): Boolean { + if (settings.current().sessionMode != SessionMode.AUTOMATIC) return false + + val box = boxes.activeBox.first() ?: return false + if (!box.autoSessionEnabled) return false + if (!isOnWifi()) return false + + return start() + } + + private fun start(): Boolean = runCatching { + ContextCompat.startForegroundService(context, PhonieboxMediaService.automaticIntent(context)) + true + }.getOrElse { error -> + Log.w(TAG, "Could not start the automatic session", error) + false + } + + /** + * Any Wi-Fi network counts. Matching a stored SSID would need the location permission + * on Android 10 and later, which is a steep ask for a convenience feature — so the + * SSID on the box row narrows the *offer* rather than gating the check. + */ + private fun isOnWifi(): Boolean { + val connectivity = context.getSystemService(ConnectivityManager::class.java) ?: return false + val capabilities = connectivity.activeNetwork?.let { connectivity.getNetworkCapabilities(it) } + return capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true + } + + private companion object { + const val TAG = "CoilAutoSession" + } +} diff --git a/feature-media/src/main/kotlin/app/coilforphoniebox/media/BootReceiver.kt b/feature-media/src/main/kotlin/app/coilforphoniebox/media/BootReceiver.kt new file mode 100644 index 0000000..b677e4e --- /dev/null +++ b/feature-media/src/main/kotlin/app/coilforphoniebox/media/BootReceiver.kt @@ -0,0 +1,38 @@ +package app.coilforphoniebox.media + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.media3.common.util.UnstableApi +import app.coilforphoniebox.transport.di.TransportScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Brings the automatic session back after a reboot. + * + * Whether Android permits a foreground service to start from here depends on the version + * and the manufacturer; [AutoSessionStarter] treats a refusal as a normal outcome. + */ +@androidx.annotation.OptIn(UnstableApi::class) +@AndroidEntryPoint +class BootReceiver : BroadcastReceiver() { + + @Inject lateinit var starter: AutoSessionStarter + + @Inject @TransportScope lateinit var scope: CoroutineScope + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED && + intent.action != Intent.ACTION_MY_PACKAGE_REPLACED + ) { + return + } + + // The receiver returns immediately; the settings lookup is a suspend call, so it + // continues on the process-wide transport scope. + scope.launch { starter.startIfEnabled() } + } +} diff --git a/feature-media/src/main/kotlin/app/coilforphoniebox/media/MediaNotificationTexts.kt b/feature-media/src/main/kotlin/app/coilforphoniebox/media/MediaNotificationTexts.kt new file mode 100644 index 0000000..7868f34 --- /dev/null +++ b/feature-media/src/main/kotlin/app/coilforphoniebox/media/MediaNotificationTexts.kt @@ -0,0 +1,29 @@ +package app.coilforphoniebox.media + +import androidx.annotation.DrawableRes + +/** + * Text and icons the media notification needs, supplied by the app module. + * + * The indirection exists so that every translatable string in the project stays in one + * `res/values/strings.xml`: translations arrive as pull requests against that one file + * per locale, and a second string file in a library module would be exactly the kind of + * drift §12.5 is trying to avoid. + * + * Values are read on each call rather than cached, so a locale change is picked up + * without restarting the service. + */ +interface MediaNotificationTexts { + val playbackChannelName: String + val playbackChannelDescription: String + val statusChannelName: String + val statusChannelDescription: String + + val readyTitle: String + + /** "Connected to " — takes the box's display name. */ + fun connectedTo(boxName: String): String + + @get:DrawableRes + val smallIcon: Int +} diff --git a/feature-media/src/main/kotlin/app/coilforphoniebox/media/MediaSessionBinder.kt b/feature-media/src/main/kotlin/app/coilforphoniebox/media/MediaSessionBinder.kt new file mode 100644 index 0000000..c9a9083 --- /dev/null +++ b/feature-media/src/main/kotlin/app/coilforphoniebox/media/MediaSessionBinder.kt @@ -0,0 +1,55 @@ +package app.coilforphoniebox.media + +import android.content.ComponentName +import android.content.Context +import android.content.ServiceConnection +import android.os.IBinder +import android.util.Log +import androidx.media3.common.util.UnstableApi +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Keeps the media service alive while the UI is on screen, without making it a foreground + * service. + * + * This is what "only while Coil is open" means in practice (§8.3): a bound service exists + * as long as the screen does and posts no notification of its own, and media3 turns it + * into a foreground service with a media notification the moment the box starts playing. + */ +@androidx.annotation.OptIn(UnstableApi::class) +@Singleton +class MediaSessionBinder @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) = Unit + + override fun onServiceDisconnected(name: ComponentName?) = Unit + } + + private var bound = false + + fun bind() { + if (bound) return + bound = runCatching { + context.bindService( + PhonieboxMediaService.serviceIntent(context), + connection, + Context.BIND_AUTO_CREATE, + ) + }.onFailure { Log.w(TAG, "Could not bind the media service", it) } + .getOrDefault(false) + } + + fun unbind() { + if (!bound) return + runCatching { context.unbindService(connection) } + bound = false + } + + private companion object { + const val TAG = "CoilMediaBinder" + } +} diff --git a/feature-media/src/main/kotlin/app/coilforphoniebox/media/PhonieboxMediaService.kt b/feature-media/src/main/kotlin/app/coilforphoniebox/media/PhonieboxMediaService.kt new file mode 100644 index 0000000..cbf2ea8 --- /dev/null +++ b/feature-media/src/main/kotlin/app/coilforphoniebox/media/PhonieboxMediaService.kt @@ -0,0 +1,297 @@ +package app.coilforphoniebox.media + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService +import app.coilforphoniebox.domain.model.PlaybackState +import app.coilforphoniebox.domain.model.SessionMode +import app.coilforphoniebox.domain.repository.BoxRepository +import app.coilforphoniebox.domain.repository.PlayerRepository +import app.coilforphoniebox.domain.repository.SettingsRepository +import app.coilforphoniebox.transport.ConnectionManager +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Holds the media session, and with it the connection to the box. + * + * In the default "only while Coil is open" mode the app binds to this service, so it + * lives exactly as long as the UI and posts a notification only once the box actually + * plays — media3 handles that transition. In automatic mode the service is started + * outright and keeps a low-priority notification up while it waits, because a foreground + * service has to be visible (§8.3). + */ +@androidx.annotation.OptIn(UnstableApi::class) +@AndroidEntryPoint +class PhonieboxMediaService : MediaSessionService() { + + @Inject lateinit var playerRepository: PlayerRepository + + @Inject lateinit var boxRepository: BoxRepository + + @Inject lateinit var settingsRepository: SettingsRepository + + @Inject lateinit var connectionManager: ConnectionManager + + @Inject lateinit var texts: MediaNotificationTexts + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var session: MediaSession? = null + private var phonieboxPlayer: PhonieboxPlayer? = null + + /** Set when the service was started for automatic mode rather than merely bound. */ + private var automatic = false + private var networkCallback: ConnectivityManager.NetworkCallback? = null + + /** + * How many clients are bound. In the default mode the service should exist while the + * UI does and while something plays, and not a moment longer — a connection nobody is + * watching is exactly the battery cost that mode exists to avoid. + */ + private var boundClients = 0 + + override fun onCreate() { + super.onCreate() + createChannels() + + // The session owns the connection for as long as it lives. + connectionManager.acquire() + + val player = PhonieboxPlayer(scope, playerRepository, boxRepository) + phonieboxPlayer = player + + // Shares its notification id with the quiet status notification below, so the + // media notification simply replaces it once playback starts. + setMediaNotificationProvider( + DefaultMediaNotificationProvider.Builder(this) + .setChannelId(CHANNEL_PLAYBACK) + .setNotificationId(NOTIFICATION_ID) + .build() + .apply { setSmallIcon(texts.smallIcon) }, + ) + + session = MediaSession.Builder(this, player) + .apply { launchIntent()?.let { setSessionActivity(it) } } + .build() + + observeIdleState() + observeSessionModeSetting() + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val result = super.onStartCommand(intent, flags, startId) + + if (intent?.action == ACTION_START_AUTOMATIC) { + automatic = true + // Started with startForegroundService, so something visible has to go up now. + startForeground(NOTIFICATION_ID, statusNotification()) + watchNetwork() + } + + return result + } + + override fun onBind(intent: Intent?): IBinder? { + boundClients++ + return super.onBind(intent) + } + + override fun onRebind(intent: Intent?) { + boundClients++ + super.onRebind(intent) + } + + override fun onUnbind(intent: Intent?): Boolean { + boundClients = (boundClients - 1).coerceAtLeast(0) + stopIfNoLongerNeeded() + return super.onUnbind(intent) + } + + /** + * While automatic mode waits for the box to start playing there is nothing for media3 + * to show, but the service still has to be visible. Once playback begins, media3's own + * notification takes over the same id, and this quiet one goes with it. + * + * The same signal ends the service in the default mode: nothing playing, nobody bound, + * nothing to do. + */ + private fun observeIdleState() { + scope.launch { + playerRepository.status + .map { it.state } + .distinctUntilChanged() + .collect { state -> + if (automatic && state != PlaybackState.PLAY) { + notificationManager().notify(NOTIFICATION_ID, statusNotification()) + } + if (state != PlaybackState.PLAY) stopIfNoLongerNeeded() + } + } + } + + private fun stopIfNoLongerNeeded() { + if (automatic || boundClients > 0) return + if (connectionManager.currentStatus().state == PlaybackState.PLAY) return + Log.i(TAG, "Nothing playing and nothing bound; stopping") + stopSelf() + } + + /** Turning the automatic mode off in settings should stop the service, not wait for a reboot. */ + private fun observeSessionModeSetting() { + scope.launch { + settingsRepository.settings + .map { it.sessionMode } + .distinctUntilChanged() + .collect { mode -> + if (automatic && mode == SessionMode.APP_ONLY) { + Log.i(TAG, "Automatic mode switched off; stopping") + stopSelf() + } + } + } + } + + private fun statusNotification(): Notification { + val box = connectionManager.activeBox.value + return NotificationCompat.Builder(this, CHANNEL_STATUS) + .setSmallIcon(texts.smallIcon) + .setContentTitle(texts.readyTitle) + .setContentText(box?.displayName?.let { texts.connectedTo(it) }) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setSilent(true) + .setOngoing(true) + .setShowWhen(false) + .apply { launchIntent()?.let { setContentIntent(it) } } + .build() + } + + /** + * Stops the service when the device leaves Wi-Fi, which is the point of tying the + * automatic mode to the network: no box in reach, no reason to run. + */ + private fun watchNetwork() { + if (networkCallback != null) return + val connectivity = getSystemService(ConnectivityManager::class.java) ?: return + + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onLost(network: Network) { + Log.i(TAG, "Wi-Fi lost; stopping the automatic session") + stopSelf() + } + } + networkCallback = callback + + runCatching { + connectivity.registerNetworkCallback( + NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .build(), + callback, + ) + }.onFailure { Log.w(TAG, "Could not watch the network", it) } + } + + override fun onTaskRemoved(rootIntent: Intent?) { + // Swiping the app away ends the session unless the user asked for automatic mode + // or the box is actually playing right now. + val playing = connectionManager.currentStatus().state == PlaybackState.PLAY + if (!automatic && !playing) stopSelf() + super.onTaskRemoved(rootIntent) + } + + override fun onDestroy() { + networkCallback?.let { callback -> + runCatching { + getSystemService(ConnectivityManager::class.java)?.unregisterNetworkCallback(callback) + } + } + networkCallback = null + + session?.release() + session = null + phonieboxPlayer?.release() + phonieboxPlayer = null + + connectionManager.release() + scope.cancel() + super.onDestroy() + } + + private fun launchIntent(): PendingIntent? = + packageManager.getLaunchIntentForPackage(packageName)?.let { intent -> + PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun notificationManager(): NotificationManager = + getSystemService(NotificationManager::class.java) + + private fun createChannels() { + val manager = notificationManager() + + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_PLAYBACK, + texts.playbackChannelName, + NotificationManager.IMPORTANCE_LOW, + ).apply { description = texts.playbackChannelDescription }, + ) + + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_STATUS, + texts.statusChannelName, + NotificationManager.IMPORTANCE_MIN, + ).apply { description = texts.statusChannelDescription }, + ) + } + + companion object { + private const val TAG = "CoilMediaService" + + const val CHANNEL_PLAYBACK = "playback" + const val CHANNEL_STATUS = "connection" + + /** Shared with media3's own notification, so one replaces the other. */ + const val NOTIFICATION_ID = 1001 + + const val ACTION_START_AUTOMATIC = "app.coilforphoniebox.media.START_AUTOMATIC" + + fun automaticIntent(context: Context): Intent = + Intent(context, PhonieboxMediaService::class.java).setAction(ACTION_START_AUTOMATIC) + + /** + * Plain start or bind intent. Used with `bindService` by the UI and with + * `startService` by the shortcut path, where there is no screen to stay bound. + */ + fun serviceIntent(context: Context): Intent = + Intent(context, PhonieboxMediaService::class.java) + } +} diff --git a/feature-media/src/main/kotlin/app/coilforphoniebox/media/PhonieboxPlayer.kt b/feature-media/src/main/kotlin/app/coilforphoniebox/media/PhonieboxPlayer.kt new file mode 100644 index 0000000..655934d --- /dev/null +++ b/feature-media/src/main/kotlin/app/coilforphoniebox/media/PhonieboxPlayer.kt @@ -0,0 +1,236 @@ +package app.coilforphoniebox.media + +import android.net.Uri +import android.os.Looper +import androidx.media3.common.DeviceInfo +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.common.SimpleBasePlayer +import androidx.media3.common.util.UnstableApi +import app.coilforphoniebox.domain.model.Box +import app.coilforphoniebox.domain.model.PlaybackState +import app.coilforphoniebox.domain.model.PlayerStatus +import app.coilforphoniebox.domain.model.RepeatMode +import app.coilforphoniebox.domain.model.VolumeStatus +import app.coilforphoniebox.domain.repository.BoxRepository +import app.coilforphoniebox.domain.repository.PlayerRepository +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +/** + * A media3 player whose playback happens somewhere else entirely. + * + * `SimpleBasePlayer` exists precisely for this case (the Cast scenario): [getState] is + * assembled from the most recent `playerstatus`, and every command fires an RPC and + * returns immediately. The UI is therefore optimistic and gets corrected by the next + * published status a quarter of a second later (§8.1). + * + * The useful side effect of `COMMAND_SET_DEVICE_VOLUME` is that the phone's hardware + * volume buttons change the Phoniebox while the session is active. + */ +@androidx.annotation.OptIn(UnstableApi::class) +class PhonieboxPlayer( + private val scope: CoroutineScope, + private val player: PlayerRepository, + boxes: BoxRepository, +) : SimpleBasePlayer(Looper.getMainLooper()) { + + private data class Snapshot( + val status: PlayerStatus = PlayerStatus.Idle, + val volume: VolumeStatus = VolumeStatus.Unknown, + val coverUrl: String? = null, + val activeBox: Box? = null, + val boxCount: Int = 0, + ) + + @Volatile + private var snapshot = Snapshot() + + init { + scope.launch { + combine( + player.status, + player.volume, + player.coverUrl, + boxes.activeBox, + boxes.boxes.map { it.size }.distinctUntilChanged(), + ) { status, volume, coverUrl, box, boxCount -> + Snapshot(status, volume, coverUrl, box, boxCount) + }.collect { next -> + snapshot = next + // Must happen on the player's own thread, which this scope runs on. + invalidateState() + } + } + } + + override fun getState(): State { + val current = snapshot + val status = current.status + + val builder = State.Builder() + .setAvailableCommands(AVAILABLE_COMMANDS) + .setPlaybackState(if (status.hasContent) Player.STATE_READY else Player.STATE_IDLE) + .setPlayWhenReady( + status.state == PlaybackState.PLAY, + Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE, + ) + .setShuffleModeEnabled(status.shuffle) + .setRepeatMode( + when (status.repeat) { + RepeatMode.OFF -> Player.REPEAT_MODE_OFF + RepeatMode.ALL -> Player.REPEAT_MODE_ALL + RepeatMode.ONE -> Player.REPEAT_MODE_ONE + }, + ) + .setDeviceInfo( + DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_REMOTE) + .setMinVolume(0) + .setMaxVolume(current.volume.maxLevel.coerceAtLeast(1)) + .build(), + ) + .setDeviceVolume(current.volume.level.coerceAtLeast(0)) + .setIsDeviceMuted(current.volume.muted) + + if (status.hasContent) { + builder.setPlaylist(listOf(mediaItemFor(current))) + builder.setCurrentMediaItemIndex(0) + + val positionMs = ((status.elapsedSeconds ?: 0.0) * 1000).toLong().coerceAtLeast(0L) + builder.setContentPositionMs( + // Published four times a second, which is smooth enough on its own — + // extrapolating is robustness against a brief gap, not the mechanism (§8.1). + if (status.state == PlaybackState.PLAY) { + PositionSupplier.getExtrapolating(positionMs, 1f) + } else { + PositionSupplier.getConstant(positionMs) + }, + ) + } + + return builder.build() + } + + private fun mediaItemFor(current: Snapshot): MediaItemData { + val status = current.status + val durationUs = status.durationSeconds + ?.takeIf { it > 0 } + ?.let { (it * 1_000_000).toLong() } + ?: androidx.media3.common.C.TIME_UNSET + + val metadata = MediaMetadata.Builder() + .setTitle(status.title) + .setArtist(status.artist) + .setAlbumTitle(status.album) + // With more than one box, the lock screen has to make clear which device is + // playing; with one it would just be noise (§8.1). + .setSubtitle(current.activeBox?.displayName?.takeIf { current.boxCount > 1 }) + .setArtworkUri(current.coverUrl?.let { Uri.parse(it) }) + .setIsBrowsable(false) + .setIsPlayable(true) + .build() + + return MediaItemData.Builder(status.songId ?: status.file ?: CURRENT_ITEM_UID) + .setMediaItem( + MediaItem.Builder() + .setMediaId(status.file ?: CURRENT_ITEM_UID) + .setMediaMetadata(metadata) + .build(), + ) + .setDurationUs(durationUs) + .setIsSeekable(durationUs != androidx.media3.common.C.TIME_UNSET) + .setIsDynamic(false) + .build() + } + + override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> = fireAndForget { + if (playWhenReady) player.play() else player.pause() + } + + override fun handlePrepare(): ListenableFuture<*> = Futures.immediateVoidFuture() + + override fun handleStop(): ListenableFuture<*> = fireAndForget { + // Coil never sends anything beyond playback control; pausing is as far as it goes. + player.pause() + } + + override fun handleSeek( + mediaItemIndex: Int, + positionMs: Long, + seekCommand: Int, + ): ListenableFuture<*> = fireAndForget { + when (seekCommand) { + Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> player.next() + Player.COMMAND_SEEK_TO_PREVIOUS, Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> + player.previous() + + else -> player.seekTo(positionMs / 1000.0) + } + } + + override fun handleSetDeviceVolume(deviceVolume: Int, flags: Int): ListenableFuture<*> = + fireAndForget { player.setVolume(deviceVolume) } + + override fun handleIncreaseDeviceVolume(flags: Int): ListenableFuture<*> = + fireAndForget { player.changeVolume(VOLUME_STEP) } + + override fun handleDecreaseDeviceVolume(flags: Int): ListenableFuture<*> = + fireAndForget { player.changeVolume(-VOLUME_STEP) } + + override fun handleSetDeviceMuted(muted: Boolean, flags: Int): ListenableFuture<*> = + fireAndForget { player.toggleMute() } + + override fun handleSetShuffleModeEnabled(shuffleModeEnabled: Boolean): ListenableFuture<*> = + fireAndForget { player.setShuffle(shuffleModeEnabled) } + + override fun handleSetRepeatMode(repeatMode: Int): ListenableFuture<*> = fireAndForget { + player.setRepeat( + when (repeatMode) { + Player.REPEAT_MODE_ONE -> RepeatMode.ONE + Player.REPEAT_MODE_ALL -> RepeatMode.ALL + else -> RepeatMode.OFF + }, + ) + } + + /** + * Every command returns immediately rather than waiting for the box: a lock screen + * button that blocks for a network round trip feels broken even when it works. + */ + private fun fireAndForget(block: suspend () -> Unit): ListenableFuture<*> { + scope.launch { block() } + return Futures.immediateVoidFuture() + } + + private companion object { + const val CURRENT_ITEM_UID = "coil-current" + const val VOLUME_STEP = 5 + + val AVAILABLE_COMMANDS: Player.Commands = Player.Commands.Builder() + .addAll( + Player.COMMAND_PLAY_PAUSE, + Player.COMMAND_PREPARE, + Player.COMMAND_STOP, + Player.COMMAND_SEEK_TO_NEXT, + Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, + Player.COMMAND_SEEK_TO_PREVIOUS, + Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM, + Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, + Player.COMMAND_GET_TIMELINE, + Player.COMMAND_GET_METADATA, + Player.COMMAND_SET_SHUFFLE_MODE, + Player.COMMAND_SET_REPEAT_MODE, + // The reason the phone's volume keys reach the box at all. + Player.COMMAND_GET_DEVICE_VOLUME, + Player.COMMAND_SET_DEVICE_VOLUME_WITH_FLAGS, + Player.COMMAND_ADJUST_DEVICE_VOLUME_WITH_FLAGS, + ) + .build() + } +} diff --git a/feature-shortcuts/build.gradle.kts b/feature-shortcuts/build.gradle.kts new file mode 100644 index 0000000..35f4b8e --- /dev/null +++ b/feature-shortcuts/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "app.coilforphoniebox.shortcuts" + compileSdk = 36 + + defaultConfig { + minSdk = 26 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation(project(":core-domain")) + implementation(project(":core-data")) + implementation(project(":core-transport")) + implementation(project(":feature-media")) + + implementation(libs.androidx.core.ktx) + implementation(libs.kotlinx.coroutines.android) + + // Pinned shortcuts use cover art as their icon, loaded through the app's shared + // image loader so it comes out of the same disk cache the UI already filled. + implementation(libs.coil.core) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + testImplementation(libs.junit) +} diff --git a/feature-shortcuts/src/main/kotlin/app/coilforphoniebox/shortcuts/PlayDeepLink.kt b/feature-shortcuts/src/main/kotlin/app/coilforphoniebox/shortcuts/PlayDeepLink.kt new file mode 100644 index 0000000..1217514 --- /dev/null +++ b/feature-shortcuts/src/main/kotlin/app/coilforphoniebox/shortcuts/PlayDeepLink.kt @@ -0,0 +1,94 @@ +package app.coilforphoniebox.shortcuts + +import android.content.Intent +import android.net.Uri +import app.coilforphoniebox.domain.model.Favorite +import app.coilforphoniebox.domain.model.FavoriteType +import app.coilforphoniebox.domain.model.PlayTarget + +/** + * `coil://play?box=&type=folder&path=…`, `…&type=album&albumartist=…&album=…` + * or `…&type=track&url=…` + * + * The box id travels in the link because a home screen shortcut has to start the box it + * was created for, whatever is currently active (§7.3). + */ +object PlayDeepLink { + + const val SCHEME = "coil" + const val HOST = "play" + + private const val PARAM_BOX = "box" + private const val PARAM_TYPE = "type" + private const val PARAM_PATH = "path" + private const val PARAM_ALBUM_ARTIST = "albumartist" + private const val PARAM_ALBUM = "album" + private const val PARAM_URL = "url" + private const val PARAM_FAVORITE = "favorite" + + private const val TYPE_FOLDER = "folder" + private const val TYPE_ALBUM = "album" + private const val TYPE_TRACK = "track" + + data class Request( + val boxId: String, + val target: PlayTarget, + /** Present for shortcuts, so the launch can be counted. */ + val favoriteId: Long?, + ) + + fun uriFor(favorite: Favorite): Uri? { + val builder = Uri.Builder() + .scheme(SCHEME) + .authority(HOST) + .appendQueryParameter(PARAM_BOX, favorite.boxId) + .appendQueryParameter(PARAM_FAVORITE, favorite.id.toString()) + + when (favorite.type) { + FavoriteType.FOLDER -> builder + .appendQueryParameter(PARAM_TYPE, TYPE_FOLDER) + .appendQueryParameter(PARAM_PATH, favorite.folder ?: return null) + + FavoriteType.ALBUM -> builder + .appendQueryParameter(PARAM_TYPE, TYPE_ALBUM) + .appendQueryParameter(PARAM_ALBUM_ARTIST, favorite.albumArtist ?: return null) + .appendQueryParameter(PARAM_ALBUM, favorite.album ?: return null) + + FavoriteType.TRACK -> builder + .appendQueryParameter(PARAM_TYPE, TYPE_TRACK) + .appendQueryParameter(PARAM_URL, favorite.trackUrl ?: return null) + } + return builder.build() + } + + /** + * An implicit VIEW intent limited to this package: a library module cannot name the + * activity class in the app module, and restricting the package keeps the link from + * being answerable by anything else. + */ + fun intentFor(favorite: Favorite, packageName: String): Intent? = + uriFor(favorite)?.let { uri -> + Intent(Intent.ACTION_VIEW, uri).setPackage(packageName) + } + + fun parse(uri: Uri?): Request? { + if (uri == null || uri.scheme != SCHEME || uri.host != HOST) return null + val boxId = uri.getQueryParameter(PARAM_BOX)?.takeIf { it.isNotBlank() } ?: return null + val favoriteId = uri.getQueryParameter(PARAM_FAVORITE)?.toLongOrNull() + + val target = when (uri.getQueryParameter(PARAM_TYPE)) { + TYPE_FOLDER -> uri.getQueryParameter(PARAM_PATH)?.let { PlayTarget.Folder(it) } + TYPE_ALBUM -> { + val artist = uri.getQueryParameter(PARAM_ALBUM_ARTIST) + val album = uri.getQueryParameter(PARAM_ALBUM) + if (artist != null && album != null) PlayTarget.Album(artist, album) else null + } + + TYPE_TRACK -> uri.getQueryParameter(PARAM_URL)?.let { PlayTarget.Track(it) } + + else -> null + } ?: return null + + return Request(boxId = boxId, target = target, favoriteId = favoriteId) + } +} diff --git a/feature-shortcuts/src/main/kotlin/app/coilforphoniebox/shortcuts/ShortcutPublisher.kt b/feature-shortcuts/src/main/kotlin/app/coilforphoniebox/shortcuts/ShortcutPublisher.kt new file mode 100644 index 0000000..f5cad7c --- /dev/null +++ b/feature-shortcuts/src/main/kotlin/app/coilforphoniebox/shortcuts/ShortcutPublisher.kt @@ -0,0 +1,118 @@ +package app.coilforphoniebox.shortcuts + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.util.Log +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import app.coilforphoniebox.domain.model.Favorite +import coil.imageLoader +import coil.request.ImageRequest +import coil.request.SuccessResult +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Favourites as launcher shortcuts (§9). + * + * Dynamic shortcuts come from the active box only — a long-press menu mixing boxes would + * be confusing. Pinned shortcuts may come from any box, because the user placed them + * deliberately (§7.3). + */ +@Singleton +class ShortcutPublisher @Inject constructor( + @ApplicationContext private val context: Context, +) { + + /** + * Replaces the dynamic shortcut set. Depending on the launcher only about four are + * ever visible, so the list is capped rather than published in full. + */ + fun publishDynamic(favorites: List) { + val shortcuts = favorites + .take(ShortcutManagerCompat.getMaxShortcutCountPerActivity(context).coerceAtMost(MAX_DYNAMIC)) + .mapNotNull { favorite -> buildShortcut(favorite, icon = null) } + + runCatching { ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts) } + .onFailure { Log.w(TAG, "Could not publish dynamic shortcuts", it) } + } + + /** + * Asks the launcher to pin [favorite], using its cover art as an adaptive icon. + * + * Returns false when the launcher does not support pinning at all, which is the one + * case worth telling the user about — the rest is up to the launcher's own dialog. + */ + suspend fun requestPin(favorite: Favorite, coverUrl: String?): Boolean { + if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context)) return false + + val icon = coverUrl?.let { loadCover(it) } + val shortcut = buildShortcut(favorite, icon) ?: return false + + return runCatching { ShortcutManagerCompat.requestPinShortcut(context, shortcut, null) } + .onFailure { Log.w(TAG, "Could not request pinned shortcut", it) } + .getOrDefault(false) + } + + /** + * Tells the launcher that a shortcut was actually used. + * + * Coil ranks its own dynamic set by [Favorite.launchCount], but the launcher keeps its + * own activation history and uses it to decide what to surface — including for pinned + * shortcuts, which Coil does not order at all. + */ + fun reportUsed(favoriteId: Long) { + runCatching { ShortcutManagerCompat.reportShortcutUsed(context, shortcutId(favoriteId)) } + } + + fun removeDynamic() { + runCatching { ShortcutManagerCompat.removeAllDynamicShortcuts(context) } + } + + private fun buildShortcut(favorite: Favorite, icon: IconCompat?): ShortcutInfoCompat? { + val intent = PlayDeepLink.intentFor(favorite, context.packageName) ?: return null + + return ShortcutInfoCompat.Builder(context, shortcutId(favorite.id)) + // Labels are the favourite's own name: content from the box, shown as it is + // and never translated (§12.4). + .setShortLabel(favorite.label) + .setLongLabel(favorite.label) + .setIcon(icon ?: IconCompat.createWithResource(context, R.drawable.ic_shortcut_coil)) + .setIntent(intent) + .build() + } + + /** + * Uses the app's shared image loader, so a cover already on screen comes straight + * from its disk cache rather than over the network again. + */ + private suspend fun loadCover(url: String): IconCompat? { + val request = ImageRequest.Builder(context) + .data(url) + .size(ICON_SIZE_PX) + .allowHardware(false) + .build() + + val bitmap = (context.imageLoader.execute(request) as? SuccessResult) + ?.drawable + ?.let { (it as? BitmapDrawable)?.bitmap } + ?: return null + + return IconCompat.createWithAdaptiveBitmap(bitmap.ensureSoftware()) + } + + /** Adaptive icons need a software bitmap; a hardware one cannot be read back. */ + private fun Bitmap.ensureSoftware(): Bitmap = + if (config == Bitmap.Config.HARDWARE) copy(Bitmap.Config.ARGB_8888, false) ?: this else this + + private fun shortcutId(favoriteId: Long) = "favorite-$favoriteId" + + private companion object { + const val TAG = "CoilShortcuts" + const val MAX_DYNAMIC = 4 + const val ICON_SIZE_PX = 256 + } +} diff --git a/feature-shortcuts/src/main/kotlin/app/coilforphoniebox/shortcuts/ShortcutSynchronizer.kt b/feature-shortcuts/src/main/kotlin/app/coilforphoniebox/shortcuts/ShortcutSynchronizer.kt new file mode 100644 index 0000000..a833a62 --- /dev/null +++ b/feature-shortcuts/src/main/kotlin/app/coilforphoniebox/shortcuts/ShortcutSynchronizer.kt @@ -0,0 +1,49 @@ +package app.coilforphoniebox.shortcuts + +import app.coilforphoniebox.domain.repository.BoxRepository +import app.coilforphoniebox.domain.repository.FavoriteRepository +import app.coilforphoniebox.transport.di.TransportScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Keeps the launcher's dynamic shortcuts in step with the favourites of the active box. + * + * Started once from the application object: shortcuts are part of the launcher's state + * rather than the app's, so they should be right even if the user never opens Coil after + * switching boxes. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@Singleton +class ShortcutSynchronizer @Inject constructor( + private val boxes: BoxRepository, + private val favorites: FavoriteRepository, + private val publisher: ShortcutPublisher, + @TransportScope private val scope: CoroutineScope, +) { + fun start() { + scope.launch { + boxes.activeBox + .map { it?.id } + .distinctUntilChanged() + .flatMapLatest { boxId -> + if (boxId == null) flowOf(emptyList()) + else favorites.mostLaunched(boxId, limit = DYNAMIC_LIMIT) + } + .distinctUntilChanged() + .collect { publisher.publishDynamic(it) } + } + } + + private companion object { + /** Launchers show about four; asking for more just gets them dropped. */ + const val DYNAMIC_LIMIT = 4 + } +} diff --git a/feature-shortcuts/src/main/res/drawable/ic_shortcut_coil.xml b/feature-shortcuts/src/main/res/drawable/ic_shortcut_coil.xml new file mode 100644 index 0000000..feb11b6 --- /dev/null +++ b/feature-shortcuts/src/main/res/drawable/ic_shortcut_coil.xml @@ -0,0 +1,28 @@ + + + + + + + + + + diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..8215fea --- /dev/null +++ b/gradle.properties @@ -0,0 +1,20 @@ +# Sized to coexist with an open IDE: the Gradle daemon, the Kotlin daemon below and lint's +# workers all want memory at once, and asking for more than the machine has free makes the +# daemon disappear mid-build rather than fail cleanly. +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=true + +android.useAndroidX=true +android.nonTransitiveRClass=true + +kotlin.code.style=official +ksp.incremental=true + +# The Kotlin compile daemon gets no heap setting of its own and failed to start behind a +# larger Gradle daemon, silently falling back to slower in-process compilation. +kotlin.daemon.jvmargs=-Xmx1g + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..1d64e45 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,97 @@ +[versions] +# Versions are pinned exactly — no dynamic ranges — so builds stay reproducible +# and the F-Droid option stays open (see docs/implementation-plan.md §13.3). +# AGP 8.13 is the newest 8.x: it supports compileSdk 36 (which Play requires from +# 31 August 2026) while its minimum Gradle is still 8.13, so the wrapper stays put. +# Moving to AGP 9.x would force Gradle 9.5 and a DSL migration — a separate exercise. +agp = "8.13.2" +kotlin = "2.0.21" +ksp = "2.0.21-1.0.28" + +activityCompose = "1.9.3" +coilImage = "2.7.0" +composeBom = "2024.10.01" +coreKtx = "1.15.0" +coroutines = "1.9.0" +datastore = "1.1.1" +hilt = "2.52" +hiltNavigationCompose = "1.2.0" +jeromq = "0.6.0" +junit = "4.13.2" +lifecycle = "2.8.7" +media3 = "1.5.1" +navigationCompose = "2.8.4" +# Screenshot testing runs on the JVM: Robolectric renders the real Compose UI with the +# native graphics backend, Roborazzi writes and compares the PNGs (see AGENTS.md). +# +# Roborazzi stops at 1.60.0 deliberately: 1.61.0 and later ship Kotlin 2.3 metadata, which +# this project's Kotlin 2.0.21 refuses to read ("expected version is 2.0.0"). Upgrading it +# means upgrading Kotlin and the Compose compiler with it, which is a separate exercise — +# the same reasoning that keeps AGP on 8.x. +robolectric = "4.16.1" +roborazzi = "1.60.0" +room = "2.6.1" +serialization = "1.7.3" + +[libraries] +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } + +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" } + +# "Coil" here is the image loading library, not this app. Imported as +# coil.* / coil.compose.* — always qualify it in review to keep the two apart. +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilImage" } +coil-core = { module = "io.coil-kt:coil", version.ref = "coilImage" } +# Serves a canned image for every request, so a screenshot never waits on the box's HTTP +# cover cache — or on a network that is not there. +coil-test = { module = "io.coil-kt:coil-test", version.ref = "coilImage" } + +compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } +compose-material3 = { module = "androidx.compose.material3:material3" } +compose-ui = { module = "androidx.compose.ui:ui" } +compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } + +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } +hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } + +javax-inject = { module = "javax.inject:javax.inject", version = "1" } +jeromq = { module = "org.zeromq:jeromq", version.ref = "jeromq" } +junit = { module = "junit:junit", version.ref = "junit" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } + +media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" } +media3-common = { module = "androidx.media3:media3-common", version.ref = "media3" } + +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" } +roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9bbc975 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..faf9300 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +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 + if ! command -v java >/dev/null 2>&1 + then + 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 +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@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 + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@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="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +: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 %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 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! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..e250e46 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,31 @@ +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 = "coil" + +// Module layout per docs/implementation-plan.md §3. +include(":app") +include(":core-transport") +include(":core-domain") +include(":core-data") +include(":feature-media") +include(":feature-shortcuts") diff --git a/spike/src/main/kotlin/Main.kt b/spike/src/main/kotlin/Main.kt index d67a8b5..b1de38c 100644 --- a/spike/src/main/kotlin/Main.kt +++ b/spike/src/main/kotlin/Main.kt @@ -114,7 +114,9 @@ suspend fun listenPubSub(ctx: ZContext, host: String, seconds: Int) { val socket = ctx.createSocket(SocketType.SUB).apply { linger = 0 receiveTimeOut = 500 - listOf("playerstatus", "volume.level", "core.version", "rfid.card_id") + // "core." is a prefix subscription: the daemon publishes core.git_state and + // core.started_at, but not core.version, whatever the docs suggest. + listOf("playerstatus", "volume.level", "core.", "rfid.card_id") .forEach { subscribe(it.toByteArray()) } connect("tcp://$host:$PUB_PORT") } @@ -164,8 +166,10 @@ fun main(args: Array) = runBlocking { RpcClient(ctx, host).use { rpc -> println("\n[1] Single DEALER request") - val version = rpc.call("core", "version") - if (version != null) ok("core.version = $version") + // player.ctrl.playerstatus, not core.version: there is no `core` RPC package, + // so asking for one answers with an error and a healthy box looks dead. + val status = rpc.call("player", "ctrl", "playerstatus") + if (status != null) ok("playerstatus answered") else { fail("No reply. Check the delimiter framing (see probe_phoniebox.py).") return@runBlocking @@ -177,10 +181,14 @@ fun main(args: Array) = runBlocking { if (good == 5) ok("All 5 replies correlated correctly.") else fail("Only $good/5 replies - serialise requests.") - println("\n[3] Potentially slow call with as_thread") - val albums = rpc.call("player", "ctrl", "list_albums", asThread = true) - if (albums != null) ok("list_albums returned ${albums.jsonArray.size} entries.") - else fail("list_albums gave no reply.") + println("\n[3] Slow call, deliberately without as_thread") + // as_thread hands the call to a daemon thread and returns the Thread object + // instead of the result, so a library call carrying it comes back unusable. + // It is fire-and-forget only — never use it for a call you read. + val albums = rpc.call("player", "ctrl", "list_albums") + val albumCount = albums?.let { runCatching { it.jsonArray.size }.getOrNull() } + if (albumCount != null) ok("list_albums returned $albumCount entries.") + else fail("list_albums gave no usable array: $albums") println("\n[4] Playback test: toggle, wait 3s, toggle back") rpc.call("player", "ctrl", "toggle") diff --git a/tools/check_store_metadata.sh b/tools/check_store_metadata.sh new file mode 100755 index 0000000..aab5b18 --- /dev/null +++ b/tools/check_store_metadata.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +# +# Validate Play Store metadata under fastlane/metadata/android against Google Play's +# per-locale character limits. +# +# Usage: +# tools/check_store_metadata.sh # listing text only (title, descriptions) +# tools/check_store_metadata.sh # also require changelogs/.txt everywhere +# +# Checked in executable (mode 100755) and invoked by path everywhere — CI, /release, by hand. +# Keep the mode: a plain `git add` of a new tool here will not set it, and a workflow step that +# runs it by path fails with exit 126 rather than with anything that names the real problem. +# +# is the app's versionCode, not the versionName — fastlane names release-note files +# after the versionCode (changelogs/7.txt), which is also what the Play API keys them by. +# +# Every limit Play enforces is per locale and counted in characters, so this checks each +# locale's own file. A translation that grew past the limit is the normal failure mode; the +# English source being short proves nothing about the others. +# +# Exits non-zero and prints every problem it found (not just the first), so one run tells you +# the full list of files to fix. + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +META="$ROOT/fastlane/metadata/android" + +# Coil's launch locales (§12.1) — must stay in step with app/build.gradle.kts localeFilters +# and app/src/main/res/values-*. +LOCALES=(en-US de-DE es-ES fr-FR nl-NL) + +VERSION_CODE="${1:-}" + +# Play's limits, in characters. +LIMIT_TITLE=30 +LIMIT_SHORT=80 +LIMIT_FULL=4000 +LIMIT_CHANGELOG=500 + +# Play's limits for listing images, in pixels and bytes. The ratio rule is the one that +# actually bites: a screenshot from a modern 20:9 phone is 2.2:1 and gets refused. +IMAGE_MIN_PX=320 +IMAGE_MAX_PX=3840 +IMAGE_MAX_RATIO=2 +IMAGE_MAX_BYTES=$((8 * 1024 * 1024)) + +# Play requires at least two screenshots in a set for the listing to publish. A folder with one +# image in it is therefore a folder that stops a release, which is worth catching here. +IMAGE_MIN_PER_SET=2 + +# The image folder names fastlane supply uses. Anything else under images/ is flagged rather +# than ignored: a typo like "phonescreenshots" would simply never be uploaded. +IMAGE_SETS=(phoneScreenshots sevenInchScreenshots tenInchScreenshots tvScreenshots wearScreenshots) + +failures=0 +warnings=0 + +fail() { printf ' FAIL %s\n' "$1"; failures=$((failures + 1)); } +warn() { printf ' warn %s\n' "$1"; warnings=$((warnings + 1)); } + +# Character count of a file's content, ignoring trailing newlines. +# +# Deliberately NOT `wc -m`: that only decodes multi-byte characters when the locale is a UTF-8 +# one, and silently counts bytes otherwise — in Git Bash on Windows `wc -m` reports 4 for +# "für". Instead: characters = bytes - UTF-8 continuation bytes (0x80-0xBF), which is exact for +# valid UTF-8 and needs no locale at all. Internal newlines count, as they do in Play. +count_chars() { + local content bytes cont + content="$(cat "$1")" + bytes=$(printf '%s' "$content" | LC_ALL=C wc -c | tr -d '[:space:]') + cont=$(printf '%s' "$content" | LC_ALL=C tr -dc '\200-\277' | LC_ALL=C wc -c | tr -d '[:space:]') + echo $((bytes - cont)) +} + +# check_file