diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index 7d681d0535..7856119e19 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1,5 +1,5 @@
# These are supported funding model platforms
github: ["rukamori"]
-ko_fi: rukamori
-custom: ["https://koiiverse.cloud/donate"]
+buy_me_a_coffee: morieattonkatsu
+custom: ["https://miuka.moe/donate"]
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
deleted file mode 100644
index 1d5a95c8da..0000000000
--- a/.github/copilot-instructions.md
+++ /dev/null
@@ -1,175 +0,0 @@
-You are a Principal Android Engineer performing a rigorous Pull Request review.
-
-Your objective is to ensure all merged code is production-grade, highly performant, stable, and aligned with modern Android best practices. You must catch architectural violations, memory leaks, recomposition issues, and concurrency bugs before they reach the main branch.
-
-Do not accept toy examples, TODOs, placeholders, pseudo-code, speculative code, or incomplete implementations in the PR.
-
-Evaluate the PR for idiomatic, human-quality Kotlin and Android code. Code must be clean, maintainable, and strictly follow the project's established patterns.
-
-When reviewing the PR:
-
-* Focus on the actual root cause of changes; ensure the author isn't just patching symptoms.
-* Reject unnecessary rewrites of unrelated code or unrelated formatting churn.
-* Question renamed public APIs, files, classes, functions, resources, or parameters unless the PR explicitly justifies them.
-* Push back on new architecture, libraries, dependencies, or patterns unless strictly necessary and agreed upon.
-* Ensure the author hasn't invented missing project APIs, utilities, or resources if existing ones could be reused.
-* Look for obsolete, unused, duplicated, janky, or conflicting code that should have been removed during this change.
-* Verify imports, visibility modifiers, nullability, state handling, and coroutine behavior are flawless.
-
----
-
-## ARCHITECTURE — STRICT UDF REVIEW
-
-Evaluate against the required architecture flow:
-UI Stateless → ViewModel State Holder → UseCase Domain → Repository Data
-
-Review Rules:
-
-* Enforce strict unidirectional data flow. Reject any PR that breaks this.
-* Verify screen state is a sealed interface with exactly four variants: `Loading`, `Success`, `Empty`, and `Error`.
-* Ensure `Success` contains only immutable UI data.
-* Ensure `Error` contains a structured error model or user-facing message resource reference.
-* The UI layer must remain entirely stateless.
-* Verify Composables collect ViewModel state using `collectAsStateWithLifecycle()`.
-* Ensure the ViewModel exclusively owns screen state, one-off events, and user actions.
-* Verify UseCases contain strictly business logic and Repositories handle strictly data access.
-* Ensure Domain/UI models are immutable.
-* **CRITICAL:** Reject any PR that exposes raw database entities, API responses, DTOs, cache models, or persistence models to composables.
-* Ensure mapping between data models and UI/domain models happens completely outside composables.
-
----
-
-## JETPACK COMPOSE — REQUIRED PATTERNS
-
-* Verify all mutable state is hoisted out of composables.
-* Ensure composables are pure, stateless, and stable.
-* Check that `remember` is used for non-primitive constants, objects, modifiers, expensive calculations, and structural lambdas in hot paths.
-* Ensure `derivedStateOf` is only used for rapidly changing inputs (scroll, gesture, animation, layout state).
-* Check that UI models are annotated with `@Immutable` or `@Stable` when they satisfy Compose stability rules.
-* Verify Lazy layouts provide a stable `key` and `contentType`.
-* Reject raw mutable collections passed into composables. Require immutable collections or stable UI wrappers.
-* Verify the recomposition scope is as small as possible.
-* Ensure stable parameters are used for frequently recomposed composables.
-* Check that `Modifier` parameters are used correctly and placed first after required parameters.
-
----
-
-## JETPACK COMPOSE — FORBIDDEN ANTI-PATTERNS
-
-Flag a blocking review failure if you see:
-
-* Object, modifier, collection, or lambda allocations inside recomposition hot paths without `remember`.
-* Business logic inside composition.
-* State mutation inside composition.
-* Local `mutableStateOf` used to bypass ViewModel state.
-* `runBlocking` in app execution paths.
-* Blocking I/O on the Main thread.
-* Raw hardcoded user-facing strings in composables.
-* Direct database, network, repository, or use case calls from composables.
-* Unstable list rendering without keys.
-* Unnecessary `LaunchedEffect`, `SideEffect`, or `DisposableEffect`.
-
----
-
-## CONCURRENCY REVIEW
-
-* Verify ViewModel coroutines are scoped to `viewModelScope`.
-* Ensure I/O work runs on `Dispatchers.IO` and CPU-heavy work on `Dispatchers.Default`.
-* **CRITICAL:** Ensure the Main thread is never blocked.
-* Check that coroutine cancellation is preserved and exceptions are not silently swallowed.
-* Verify failures are converted into structured sealed state errors.
-* Look for race conditions or duplicate jobs launched for the same user action.
-* Ensure stale work is canceled or replaced when new user intent supersedes it.
-* Verify `StateFlow` is used for observable screen state.
-* Ensure `SharedFlow` or `Channel` is used *only* for one-off events when absolutely necessary.
-
----
-
-## STRINGS & ASSETS REVIEW
-
-* Reject hardcoded user-facing text. All text must use `stringResource()` in composables.
-* Question unnecessary new string resources if existing ones can be reused.
-* Reject extra string literals or new strings added solely for `contentDescription` on decorative icons/images.
-* Ensure decorative icons/images use `contentDescription = null`.
-* For meaningful icons/images, verify the author reused existing string resources where available.
-* Reject duplicated strings on the same screen surface.
-* Ensure titles, labels, and descriptions are short, clear, and useful.
-* Remind the author to remove unused string resources if they refactored UI.
-* Verify image assets define explicit display dimensions and are decoded at display size, not full resolution.
-
----
-
-## CODE HYGIENE & MAINTAINABILITY
-
-* Flag dead code, unused imports, redundant abstractions, and unnecessary wrappers/mappers.
-* Reject needless cleverness or over-engineering. Code should be readable.
-* Reject mixed responsibilities across UI, ViewModel, UseCase, or Repository layers.
-* Verify the PR cleanly removes unused, obsolete, duplicated, or conflicting code left over from refactoring.
-* Ensure errors propagate correctly through sealed state wrappers rather than crashing or swallowing silently.
-* Enforce clear, consistent, and domain-accurate naming.
-* Suggest explicit types where they improve readability or API stability.
-* Ensure functions remain small, cohesive, and testable.
-
----
-
-## OUTPUT RULES
-
-Do not dump full source files in your review.
-
-When providing review feedback:
-
-* Be direct, professional, and concise.
-* Point out exactly what file, function, or line is problematic.
-* Provide only small, focused diff snippets to demonstrate the required fix.
-* Do not include unchanged file headers, unchanged imports, or surrounding boilerplate in your snippet suggestions.
-* Do not use ellipses inside code blocks unless absolutely necessary for context.
-* If a file looks perfect, do not comment on it just to praise it; focus on actionable feedback.
-
----
-
-## ONE-SHOT REVIEW EXECUTION
-
-Perform exactly one internal review pass of the provided diff before responding.
-
-Rules:
-
-* Analyze the diff once for correctness, architecture boundaries, coroutine safety, recomposition stability, memory usage, imports, nullability, and unused code.
-* Consolidate all your findings into a single, structured review response.
-* Do not output intermediate review notes, self-critiques, or revision history.
-* The final response must present only the consolidated, final PR review.
-
----
-
-## RESPONSE FORMAT
-
-### 1. PR Summary
-
-Provide a one-to-two sentence summary of what this PR actually accomplishes from an engineering perspective.
-
-### 2. Blocking Issues (Changes Requested)
-
-List critical problems that *must* be fixed before merging (e.g., memory leaks, architecture violations, thread blocking, unhoisted state in Compose).
-
-* Format as clear bullet points.
-* Omit this section entirely if there are no blocking issues.
-
-### 3. Non-Blocking Feedback (Nitpicks & Polish)
-
-List minor suggestions, code hygiene improvements, naming tweaks, or optimal refactors that are recommended but not strictly required for merging.
-
-* Omit this section entirely if there are no minor issues.
-
-### 4. Suggested Code Fixes
-
-Provide minimal, focused code snippets showing exactly how to fix the issues raised in Section 2 or 3.
-
-* Format with the filename as a header.
-* Include ONLY the changed block of code.
-
-### 5. Final Verdict
-
-End with exactly one of the following statuses in bold:
-
-* **APPROVE** (If no blocking issues exist)
-* **REQUEST CHANGES** (If any blocking issues exist)
-* **COMMENT** (If you just need clarification from the author before deciding)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a30c37c198..392370bdd6 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -27,7 +27,7 @@ jobs:
submodules: true
- name: Set up Python 3
- uses: actions/setup-python@v6
+ uses: actions/setup-python@v7
with:
python-version: '3.x'
@@ -96,17 +96,17 @@ jobs:
fetch-depth: 0
- name: Set up Python 3.11
- uses: actions/setup-python@v6
+ uses: actions/setup-python@v7
with:
- python-version: '3.11'
+ python-version: '3.15'
- name: Generate translation contributor credits
run: python scripts/generate_translation_contributors.py
- name: Set up JDK 21
- uses: actions/setup-java@v5
+ uses: actions/setup-java@v6
with:
- java-version: '21'
+ java-version: '27'
distribution: 'temurin'
- name: Set Up Gradle
@@ -273,7 +273,7 @@ jobs:
path: app/build/outputs/apk/download
- name: Set up Python 3
- uses: actions/setup-python@v6
+ uses: actions/setup-python@v7
with:
python-version: '3.x'
@@ -316,17 +316,17 @@ jobs:
fetch-depth: 0
- name: Set up Python 3.11
- uses: actions/setup-python@v6
+ uses: actions/setup-python@v7
with:
- python-version: '3.11'
+ python-version: '3.15'
- name: Generate translation contributor credits
run: python scripts/generate_translation_contributors.py
- name: Set up JDK 21
- uses: actions/setup-java@v5
+ uses: actions/setup-java@v6
with:
- java-version: '21'
+ java-version: '27'
distribution: 'temurin'
- name: Set Up Gradle
diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml
index 56438068af..8f7ff86bdb 100644
--- a/.github/workflows/build_pull_request.yml
+++ b/.github/workflows/build_pull_request.yml
@@ -16,17 +16,17 @@ jobs:
fetch-depth: 0
- name: Set up Python 3.11
- uses: actions/setup-python@v6
+ uses: actions/setup-python@v7
with:
- python-version: '3.11'
+ python-version: '3.15'
- name: Generate translation contributor credits
run: python scripts/generate_translation_contributors.py
- name: Set up JDK 21
- uses: actions/setup-java@v5
+ uses: actions/setup-java@v6
with:
- java-version: 21
+ java-version: 27
distribution: "temurin"
- name: Set Up Gradle
diff --git a/.github/workflows/duplicate-check.yml b/.github/workflows/duplicate-check.yml
index 49d3d2f7dc..ef46fd1e82 100644
--- a/.github/workflows/duplicate-check.yml
+++ b/.github/workflows/duplicate-check.yml
@@ -24,9 +24,9 @@ jobs:
uses: actions/checkout@v7
- name: Set up Python 3.10
- uses: actions/setup-python@v6
+ uses: actions/setup-python@v7
with:
- python-version: "3.10"
+ python-version: "3.15"
- name: Install dependencies
run: python -m pip install --disable-pip-version-check scikit-learn requests
diff --git a/.github/workflows/header.yml b/.github/workflows/header.yml
index 94533ecd1b..99b2e6a3af 100644
--- a/.github/workflows/header.yml
+++ b/.github/workflows/header.yml
@@ -9,9 +9,9 @@ jobs:
steps:
- uses: actions/checkout@v7
- - uses: actions/setup-go@v6
+ - uses: actions/setup-go@v7
with:
- go-version: '1.26'
+ go-version: '1.27'
- name: Install addlicense
run: go install github.com/google/addlicense@latest
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 984fce1a7a..9dddde64f5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -61,9 +61,9 @@ jobs:
fetch-depth: 0
- name: Set up Python 3.11
- uses: actions/setup-python@v6
+ uses: actions/setup-python@v7
with:
- python-version: '3.11'
+ python-version: '3.15'
- name: Generate translation contributor credits
run: python scripts/generate_translation_contributors.py
@@ -71,7 +71,7 @@ jobs:
- name: Set up JDK 21
uses: actions/setup-java@v6
with:
- java-version: '21'
+ java-version: '27'
distribution: 'temurin'
- name: Set Up Gradle
@@ -194,17 +194,17 @@ jobs:
fetch-depth: 0
- name: Set up Python 3.11
- uses: actions/setup-python@v6
+ uses: actions/setup-python@v7
with:
- python-version: '3.11'
+ python-version: '3.15'
- name: Generate translation contributor credits
run: python scripts/generate_translation_contributors.py
- name: Set up JDK 21
- uses: actions/setup-java@v5
+ uses: actions/setup-java@v6
with:
- java-version: '21'
+ java-version: '27'
distribution: 'temurin'
- name: Set Up Gradle
diff --git a/ArchiveTuneKoiverseServer.txt b/ArchiveTuneKoiverseServer.txt
index c00346da88..252b3226e3 100644
--- a/ArchiveTuneKoiverseServer.txt
+++ b/ArchiveTuneKoiverseServer.txt
@@ -1 +1 @@
-https://archivetune-api.koiiverse.cloud
\ No newline at end of file
+https://archivetune-api.koiiverse.cloud
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index d0fa3054aa..4594a356b5 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
-me@koiiverse.cloud.
+me@miuka.moe
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 467187fb15..8859955940 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -36,7 +36,7 @@ To ensure build stability and environment parity, the following hardware and sof
### **Development Environment**
* **IDE:** [Android Studio](https://developer.android.com/studio) **Ladybug (2024.2.1) or newer**.
-* **Java Runtime:** **JDK 17** (Amazon Corretto or Azul Zulu recommended for deterministic builds).
+* **Java Runtime:** **JDK 21** (Amazon Corretto or Azul Zulu recommended for deterministic builds).
* **Android SDK:** API Level 34+ (Upside Down Cake).
* **Version Control:** Git 2.40+.
diff --git a/PRIVACY.md b/PRIVACY.md
index 4c4fafe022..93e628724b 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -128,7 +128,7 @@ This appendix maps the main statements above to concrete implementation surfaces
| YouTube signed-in state | The Innertube layer exposes visitor data, data sync ID, cookie, PO token values, proxy state, and login-for-browse behavior as part of the current playback auth state. | `innertube/src/main/kotlin/moe/archivetuneapp/archivetune/innertube/YouTube.kt` |
| Manual backup export | The backup view model writes app settings plus database files into a ZIP archive chosen by the user. | `app/src/main/kotlin/moe/archivetuneapp/archivetune/viewmodels/BackupRestoreViewModel.kt` |
| External network integrations | Build configuration defines keys for Last.fm, Together, and canvas services. The updater fetches release information and caches related metadata in app preferences. | `app/build.gradle.kts`, `app/src/main/kotlin/moe/archivetuneapp/archivetune/utils/Updater.kt` |
-| Canvas service requests | The canvas module sends song and artist names, album IDs, or album URLs to `https://artwork-archivetune.koiiverse.cloud/` and can attach a bearer token. | `canvas/src/main/kotlin/moe/archivetuneapp/archivetune/canvas/ArchiveTuneCanvas.kt` |
+| Canvas service requests | The canvas module sends song and artist names, album IDs, or album URLs to `https://artwork-archivetune.miuka.moe/` and can attach a bearer token. | `canvas/src/main/kotlin/moe/archivetuneapp/archivetune/canvas/ArchiveTuneCanvas.kt` |
| Public feature claims | The repository README and store metadata describe privacy, YouTube integration, lyrics, music recognition, Last.fm, ListenBrainz, Discord Rich Presence, and other network-backed features that must stay aligned with this notice. | `README.md`, `fastlane/metadata/android/en-US/full_description.txt` |
| Current dependency posture | The current Android dependency declarations show Compose, Room, Hilt, Ktor, Media3, Coil, Timber, and related libraries. They do not currently show Firebase, Crashlytics, Sentry, mobile ad SDKs, or mobile analytics SDKs in the Android app dependency definitions reviewed for this notice. | `app/build.gradle.kts`, `gradle/libs.versions.toml` |
diff --git a/README.md b/README.md
index edc8e207b7..7fd6e544ce 100644
--- a/README.md
+++ b/README.md
@@ -20,9 +20,9 @@
- Official Website •
+ Official Website •
Features •
- Privacy •
+ Privacy •
Download •
Screenshots •
Support
@@ -33,19 +33,26 @@
-
-
-
-
+
+
+
+
-
+
@@ -56,7 +63,7 @@
We do not provide support, maintenance, troubleshooting, or assistance for any forked version of this project.
If you choose to use a fork, you do so at your own risk.
-**ArchiveTune** isn’t just another generic YouTube Music wrapper. It’s a fully custom-built player made from the ground up, because we think your music should stay private, look clean, and work exactly the way you expect. If you care about sound quality and want something that actually feels good to use, this is it.
+**ArchiveTune** isn’t just another generic YouTube Music wrapper. It’s a fully custom-built player made with kotlin and jetpack compose, because we think your music should stay private, look clean, and work exactly the way you expect.
---
diff --git a/README_JA.md b/README_JA.md
index 148fae76fb..2b3722ce4c 100644
--- a/README_JA.md
+++ b/README_JA.md
@@ -20,9 +20,9 @@
- 公式サイト •
+ 公式サイト •
機能 •
- プライバシー •
+ プライバシー •
ダウンロード •
スクリーンショット •
サポート
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 371d29e63a..c94b7e1658 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -358,9 +358,9 @@ dependencies {
implementation(libs.shimmer)
// Glance Widget support
- implementation("androidx.glance:glance:1.1.1")
- implementation("androidx.glance:glance-appwidget:1.1.1")
- implementation("androidx.glance:glance-material3:1.1.1")
+ implementation("androidx.glance:glance:1.2.0")
+ implementation("androidx.glance:glance-appwidget:1.2.0")
+ implementation("androidx.glance:glance-material3:1.2.0")
implementation(libs.media3)
implementation("androidx.media3:media3-exoplayer-hls:${libs.versions.media3.get()}")
@@ -372,7 +372,6 @@ dependencies {
add("gmsImplementation", libs.media3.cast)
add("gmsImplementation", libs.mediarouter)
add("gmsImplementation", "com.google.android.gms:play-services-auth:22.0.0")
- implementation(libs.squigglyslider)
implementation(libs.room.runtime)
@@ -401,7 +400,7 @@ dependencies {
implementation(project(":shazamkit"))
implementation(project(":spotifycore"))
implementation(project(":morideobfuscator"))
- implementation("com.materialkolor:material-kolor:5.0.0-alpha07")
+ implementation("com.materialkolor:material-kolor:5.0.1")
implementation(libs.webkit)
implementation(libs.ktor.client.core)
@@ -420,14 +419,14 @@ dependencies {
testImplementation(libs.turbine)
implementation(libs.translator)
implementation("androidx.lifecycle:lifecycle-process:2.11.0")
- implementation("androidx.compose.material3.adaptive:adaptive:1.3.0-rc01")
+ implementation("androidx.compose.material3.adaptive:adaptive:1.4.0-alpha03")
implementation(libs.accompanist.lyrics.ui)
implementation(libs.accompanist.lyrics.core)
}
androidComponents {
onVariants(selector().all()) { variant ->
- if ("automotive" in variant.name) return@onVariants
+ if (variant.productFlavors.any { (_, flavor) -> flavor == "automotive" }) return@onVariants
val capitalizedVariantName =
variant.name.replaceFirstChar { character ->
if (character.isLowerCase()) character.titlecase() else character.toString()
diff --git a/app/schemas/moe.rukamori.archivetune.db.InternalDatabase/37.json b/app/schemas/moe.rukamori.archivetune.db.InternalDatabase/37.json
new file mode 100644
index 0000000000..7121d575b1
--- /dev/null
+++ b/app/schemas/moe.rukamori.archivetune.db.InternalDatabase/37.json
@@ -0,0 +1,1398 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 37,
+ "identityHash": "7f6e70307f77644c5b1042868d55474d",
+ "entities": [
+ {
+ "tableName": "song",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `titleOverride` INTEGER NOT NULL DEFAULT 0, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `albumArtist` TEXT, `trackNumber` INTEGER, `discNumber` INTEGER, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isMusicVideo` INTEGER NOT NULL DEFAULT 0, `isPodcast` INTEGER NOT NULL DEFAULT 0, `isLocal` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "titleOverride",
+ "columnName": "titleOverride",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "albumId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "albumName",
+ "columnName": "albumName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "albumArtist",
+ "columnName": "albumArtist",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "trackNumber",
+ "columnName": "trackNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "discNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "explicit",
+ "columnName": "explicit",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "date",
+ "columnName": "date",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "dateModified",
+ "columnName": "dateModified",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "liked",
+ "columnName": "liked",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "likedDate",
+ "columnName": "likedDate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "totalPlayTime",
+ "columnName": "totalPlayTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "inLibrary",
+ "columnName": "inLibrary",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "dateDownload",
+ "columnName": "dateDownload",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "isMusicVideo",
+ "columnName": "isMusicVideo",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isPodcast",
+ "columnName": "isPodcast",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isLocal",
+ "columnName": "isLocal",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_song_albumId",
+ "unique": false,
+ "columnNames": [
+ "albumId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_albumId` ON `${TABLE_NAME}` (`albumId`)"
+ }
+ ]
+ },
+ {
+ "tableName": "artist",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `channelId` TEXT, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `blockedAt` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "channelId",
+ "columnName": "channelId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastUpdateTime",
+ "columnName": "lastUpdateTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bookmarkedAt",
+ "columnName": "bookmarkedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "blockedAt",
+ "columnName": "blockedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "isLocal",
+ "columnName": "isLocal",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "album",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `playlistId` TEXT, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `themeColor` INTEGER, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `explicit` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlistId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "themeColor",
+ "columnName": "themeColor",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "songCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "explicit",
+ "columnName": "explicit",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "lastUpdateTime",
+ "columnName": "lastUpdateTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bookmarkedAt",
+ "columnName": "bookmarkedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "likedDate",
+ "columnName": "likedDate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "inLibrary",
+ "columnName": "inLibrary",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "isLocal",
+ "columnName": "isLocal",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "playlist",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, `createdAt` INTEGER, `lastUpdateTime` INTEGER, `isEditable` INTEGER NOT NULL DEFAULT true, `bookmarkedAt` INTEGER, `remoteSongCount` INTEGER, `playEndpointParams` TEXT, `thumbnailUrl` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `customOrder` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT 0, `isAutoSync` INTEGER NOT NULL DEFAULT 0, `songSortType` TEXT DEFAULT NULL, `songSortDescending` INTEGER DEFAULT NULL, `isHidden` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "browseId",
+ "columnName": "browseId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastUpdateTime",
+ "columnName": "lastUpdateTime",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "isEditable",
+ "columnName": "isEditable",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "true"
+ },
+ {
+ "fieldPath": "bookmarkedAt",
+ "columnName": "bookmarkedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "remoteSongCount",
+ "columnName": "remoteSongCount",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "playEndpointParams",
+ "columnName": "playEndpointParams",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "shuffleEndpointParams",
+ "columnName": "shuffleEndpointParams",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "radioEndpointParams",
+ "columnName": "radioEndpointParams",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "customOrder",
+ "columnName": "customOrder",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "isLocal",
+ "columnName": "isLocal",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isAutoSync",
+ "columnName": "isAutoSync",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "songSortType",
+ "columnName": "songSortType",
+ "affinity": "TEXT",
+ "defaultValue": "NULL"
+ },
+ {
+ "fieldPath": "songSortDescending",
+ "columnName": "songSortDescending",
+ "affinity": "INTEGER",
+ "defaultValue": "NULL"
+ },
+ {
+ "fieldPath": "isHidden",
+ "columnName": "isHidden",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "podcast",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`browseId` TEXT NOT NULL, `playlistId` TEXT, `title` TEXT NOT NULL, `authorName` TEXT, `authorId` TEXT, `thumbnailUrl` TEXT, `localSavedAt` INTEGER, `remoteSavedAt` INTEGER, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`browseId`))",
+ "fields": [
+ {
+ "fieldPath": "browseId",
+ "columnName": "browseId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlistId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "authorName",
+ "columnName": "authorName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "authorId",
+ "columnName": "authorId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "localSavedAt",
+ "columnName": "localSavedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "remoteSavedAt",
+ "columnName": "remoteSavedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastUpdateTime",
+ "columnName": "lastUpdateTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "browseId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_podcast_playlistId",
+ "unique": true,
+ "columnNames": [
+ "playlistId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_podcast_playlistId` ON `${TABLE_NAME}` (`playlistId`)"
+ }
+ ]
+ },
+ {
+ "tableName": "song_artist_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "songId",
+ "artistId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_song_artist_map_songId",
+ "unique": false,
+ "columnNames": [
+ "songId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)"
+ },
+ {
+ "name": "index_song_artist_map_artistId",
+ "unique": false,
+ "columnNames": [
+ "artistId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "songId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "artist",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "artistId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "song_album_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "albumId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "index",
+ "columnName": "index",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "songId",
+ "albumId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_song_album_map_songId",
+ "unique": false,
+ "columnNames": [
+ "songId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)"
+ },
+ {
+ "name": "index_song_album_map_albumId",
+ "unique": false,
+ "columnNames": [
+ "albumId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_albumId` ON `${TABLE_NAME}` (`albumId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "songId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "album",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "albumId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "album_artist_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "albumId",
+ "columnName": "albumId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "order",
+ "columnName": "order",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "albumId",
+ "artistId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_album_artist_map_albumId",
+ "unique": false,
+ "columnNames": [
+ "albumId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)"
+ },
+ {
+ "name": "index_album_artist_map_artistId",
+ "unique": false,
+ "columnNames": [
+ "artistId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "album",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "albumId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "artist",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "artistId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "playlist_song_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, `setVideoId` TEXT, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "setVideoId",
+ "columnName": "setVideoId",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_playlist_song_map_playlistId",
+ "unique": false,
+ "columnNames": [
+ "playlistId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)"
+ },
+ {
+ "name": "index_playlist_song_map_songId",
+ "unique": false,
+ "columnNames": [
+ "songId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "playlist",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "playlistId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "songId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "search_history",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "query",
+ "columnName": "query",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_search_history_query",
+ "unique": true,
+ "columnNames": [
+ "query"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)"
+ }
+ ]
+ },
+ {
+ "tableName": "format",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, `perceptualLoudnessDb` REAL, `playbackUrl` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itag",
+ "columnName": "itag",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mimeType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "codecs",
+ "columnName": "codecs",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sampleRate",
+ "columnName": "sampleRate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "contentLength",
+ "columnName": "contentLength",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "loudnessDb",
+ "columnName": "loudnessDb",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "perceptualLoudnessDb",
+ "columnName": "perceptualLoudnessDb",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "playbackUrl",
+ "columnName": "playbackUrl",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "lyrics",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, `source` TEXT NOT NULL DEFAULT 'REMOTE', `updatedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lyrics",
+ "columnName": "lyrics",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'REMOTE'"
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "local_music_alias",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` TEXT NOT NULL, `targetId` TEXT NOT NULL, `kind` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `kind`))",
+ "fields": [
+ {
+ "fieldPath": "sourceId",
+ "columnName": "sourceId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "targetId",
+ "columnName": "targetId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "sourceId",
+ "kind"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_local_music_alias_targetId",
+ "unique": false,
+ "columnNames": [
+ "targetId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_local_music_alias_targetId` ON `${TABLE_NAME}` (`targetId`)"
+ }
+ ]
+ },
+ {
+ "tableName": "event",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playTime",
+ "columnName": "playTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_event_songId",
+ "unique": false,
+ "columnNames": [
+ "songId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_event_songId` ON `${TABLE_NAME}` (`songId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "songId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "related_song_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `relatedSongId` TEXT NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`relatedSongId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relatedSongId",
+ "columnName": "relatedSongId",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_related_song_map_songId",
+ "unique": false,
+ "columnNames": [
+ "songId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_songId` ON `${TABLE_NAME}` (`songId`)"
+ },
+ {
+ "name": "index_related_song_map_relatedSongId",
+ "unique": false,
+ "columnNames": [
+ "relatedSongId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_relatedSongId` ON `${TABLE_NAME}` (`relatedSongId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "songId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "relatedSongId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "set_video_id",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `setVideoId` TEXT, PRIMARY KEY(`videoId`))",
+ "fields": [
+ {
+ "fieldPath": "videoId",
+ "columnName": "videoId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "setVideoId",
+ "columnName": "setVideoId",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "videoId"
+ ]
+ }
+ },
+ {
+ "tableName": "playCount",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song` TEXT NOT NULL, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`song`, `year`, `month`))",
+ "fields": [
+ {
+ "fieldPath": "song",
+ "columnName": "song",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "month",
+ "columnName": "month",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "count",
+ "columnName": "count",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "song",
+ "year",
+ "month"
+ ]
+ }
+ },
+ {
+ "tableName": "tag",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` TEXT NOT NULL DEFAULT '#FF6B6B', `createdAt` INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "color",
+ "columnName": "color",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'#FF6B6B'"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "CURRENT_TIMESTAMP"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "playlist_tag_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `tagId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(`playlistId`, `tagId`))",
+ "fields": [
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tagId",
+ "columnName": "tagId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "CURRENT_TIMESTAMP"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "playlistId",
+ "tagId"
+ ]
+ }
+ },
+ {
+ "tableName": "library_top_mix",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `position` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_library_top_mix_position",
+ "unique": false,
+ "columnNames": [
+ "position"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_top_mix_position` ON `${TABLE_NAME}` (`position`)"
+ }
+ ]
+ },
+ {
+ "tableName": "library_top_mix_song_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mixId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`mixId`, `position`), FOREIGN KEY(`mixId`) REFERENCES `library_top_mix`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mixId",
+ "columnName": "mixId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "mixId",
+ "position"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_library_top_mix_song_map_mixId",
+ "unique": false,
+ "columnNames": [
+ "mixId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_top_mix_song_map_mixId` ON `${TABLE_NAME}` (`mixId`)"
+ },
+ {
+ "name": "index_library_top_mix_song_map_songId",
+ "unique": false,
+ "columnNames": [
+ "songId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_top_mix_song_map_songId` ON `${TABLE_NAME}` (`songId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "library_top_mix",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "mixId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "songId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ }
+ ],
+ "views": [
+ {
+ "viewName": "library_song_artist_map",
+ "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT songId, artistId FROM song_artist_map\n UNION\n SELECT song.id AS songId, album_artist_map.artistId\n FROM song JOIN album_artist_map ON song.albumId = album_artist_map.albumId\n WHERE song.isLocal = 1"
+ },
+ {
+ "viewName": "sorted_song_artist_map",
+ "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position"
+ },
+ {
+ "viewName": "sorted_song_album_map",
+ "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`"
+ },
+ {
+ "viewName": "playlist_song_map_preview",
+ "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position"
+ }
+ ],
+ "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, '7f6e70307f77644c5b1042868d55474d')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/schemas/moe.rukamori.archivetune.db.InternalDatabase/38.json b/app/schemas/moe.rukamori.archivetune.db.InternalDatabase/38.json
new file mode 100644
index 0000000000..f1dfad1120
--- /dev/null
+++ b/app/schemas/moe.rukamori.archivetune.db.InternalDatabase/38.json
@@ -0,0 +1,1403 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 38,
+ "identityHash": "b57a26646cb1a03e3c32feae0ab70f02",
+ "entities": [
+ {
+ "tableName": "song",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `titleOverride` INTEGER NOT NULL DEFAULT 0, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `albumArtist` TEXT, `trackNumber` INTEGER, `discNumber` INTEGER, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `dateAdded` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isMusicVideo` INTEGER NOT NULL DEFAULT 0, `isPodcast` INTEGER NOT NULL DEFAULT 0, `isLocal` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "titleOverride",
+ "columnName": "titleOverride",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "albumId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "albumName",
+ "columnName": "albumName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "albumArtist",
+ "columnName": "albumArtist",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "trackNumber",
+ "columnName": "trackNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "discNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "explicit",
+ "columnName": "explicit",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "date",
+ "columnName": "date",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "dateModified",
+ "columnName": "dateModified",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "dateAdded",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "liked",
+ "columnName": "liked",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "likedDate",
+ "columnName": "likedDate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "totalPlayTime",
+ "columnName": "totalPlayTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "inLibrary",
+ "columnName": "inLibrary",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "dateDownload",
+ "columnName": "dateDownload",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "isMusicVideo",
+ "columnName": "isMusicVideo",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isPodcast",
+ "columnName": "isPodcast",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isLocal",
+ "columnName": "isLocal",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_song_albumId",
+ "unique": false,
+ "columnNames": [
+ "albumId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_albumId` ON `${TABLE_NAME}` (`albumId`)"
+ }
+ ]
+ },
+ {
+ "tableName": "artist",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `channelId` TEXT, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `blockedAt` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "channelId",
+ "columnName": "channelId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastUpdateTime",
+ "columnName": "lastUpdateTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bookmarkedAt",
+ "columnName": "bookmarkedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "blockedAt",
+ "columnName": "blockedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "isLocal",
+ "columnName": "isLocal",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "album",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `playlistId` TEXT, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `themeColor` INTEGER, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `explicit` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlistId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "themeColor",
+ "columnName": "themeColor",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "songCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "explicit",
+ "columnName": "explicit",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "lastUpdateTime",
+ "columnName": "lastUpdateTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bookmarkedAt",
+ "columnName": "bookmarkedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "likedDate",
+ "columnName": "likedDate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "inLibrary",
+ "columnName": "inLibrary",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "isLocal",
+ "columnName": "isLocal",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "playlist",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, `createdAt` INTEGER, `lastUpdateTime` INTEGER, `isEditable` INTEGER NOT NULL DEFAULT true, `bookmarkedAt` INTEGER, `remoteSongCount` INTEGER, `playEndpointParams` TEXT, `thumbnailUrl` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `customOrder` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT 0, `isAutoSync` INTEGER NOT NULL DEFAULT 0, `songSortType` TEXT DEFAULT NULL, `songSortDescending` INTEGER DEFAULT NULL, `isHidden` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "browseId",
+ "columnName": "browseId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastUpdateTime",
+ "columnName": "lastUpdateTime",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "isEditable",
+ "columnName": "isEditable",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "true"
+ },
+ {
+ "fieldPath": "bookmarkedAt",
+ "columnName": "bookmarkedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "remoteSongCount",
+ "columnName": "remoteSongCount",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "playEndpointParams",
+ "columnName": "playEndpointParams",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "shuffleEndpointParams",
+ "columnName": "shuffleEndpointParams",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "radioEndpointParams",
+ "columnName": "radioEndpointParams",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "customOrder",
+ "columnName": "customOrder",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "isLocal",
+ "columnName": "isLocal",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isAutoSync",
+ "columnName": "isAutoSync",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "songSortType",
+ "columnName": "songSortType",
+ "affinity": "TEXT",
+ "defaultValue": "NULL"
+ },
+ {
+ "fieldPath": "songSortDescending",
+ "columnName": "songSortDescending",
+ "affinity": "INTEGER",
+ "defaultValue": "NULL"
+ },
+ {
+ "fieldPath": "isHidden",
+ "columnName": "isHidden",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "podcast",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`browseId` TEXT NOT NULL, `playlistId` TEXT, `title` TEXT NOT NULL, `authorName` TEXT, `authorId` TEXT, `thumbnailUrl` TEXT, `localSavedAt` INTEGER, `remoteSavedAt` INTEGER, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`browseId`))",
+ "fields": [
+ {
+ "fieldPath": "browseId",
+ "columnName": "browseId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlistId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "authorName",
+ "columnName": "authorName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "authorId",
+ "columnName": "authorId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "localSavedAt",
+ "columnName": "localSavedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "remoteSavedAt",
+ "columnName": "remoteSavedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastUpdateTime",
+ "columnName": "lastUpdateTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "browseId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_podcast_playlistId",
+ "unique": true,
+ "columnNames": [
+ "playlistId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_podcast_playlistId` ON `${TABLE_NAME}` (`playlistId`)"
+ }
+ ]
+ },
+ {
+ "tableName": "song_artist_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "songId",
+ "artistId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_song_artist_map_songId",
+ "unique": false,
+ "columnNames": [
+ "songId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)"
+ },
+ {
+ "name": "index_song_artist_map_artistId",
+ "unique": false,
+ "columnNames": [
+ "artistId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "songId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "artist",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "artistId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "song_album_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "albumId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "index",
+ "columnName": "index",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "songId",
+ "albumId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_song_album_map_songId",
+ "unique": false,
+ "columnNames": [
+ "songId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)"
+ },
+ {
+ "name": "index_song_album_map_albumId",
+ "unique": false,
+ "columnNames": [
+ "albumId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_albumId` ON `${TABLE_NAME}` (`albumId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "songId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "album",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "albumId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "album_artist_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "albumId",
+ "columnName": "albumId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "order",
+ "columnName": "order",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "albumId",
+ "artistId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_album_artist_map_albumId",
+ "unique": false,
+ "columnNames": [
+ "albumId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)"
+ },
+ {
+ "name": "index_album_artist_map_artistId",
+ "unique": false,
+ "columnNames": [
+ "artistId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "album",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "albumId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "artist",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "artistId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "playlist_song_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, `setVideoId` TEXT, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "setVideoId",
+ "columnName": "setVideoId",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_playlist_song_map_playlistId",
+ "unique": false,
+ "columnNames": [
+ "playlistId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)"
+ },
+ {
+ "name": "index_playlist_song_map_songId",
+ "unique": false,
+ "columnNames": [
+ "songId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "playlist",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "playlistId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "songId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "search_history",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "query",
+ "columnName": "query",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_search_history_query",
+ "unique": true,
+ "columnNames": [
+ "query"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)"
+ }
+ ]
+ },
+ {
+ "tableName": "format",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, `perceptualLoudnessDb` REAL, `playbackUrl` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itag",
+ "columnName": "itag",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mimeType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "codecs",
+ "columnName": "codecs",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sampleRate",
+ "columnName": "sampleRate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "contentLength",
+ "columnName": "contentLength",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "loudnessDb",
+ "columnName": "loudnessDb",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "perceptualLoudnessDb",
+ "columnName": "perceptualLoudnessDb",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "playbackUrl",
+ "columnName": "playbackUrl",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "lyrics",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, `source` TEXT NOT NULL DEFAULT 'REMOTE', `updatedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lyrics",
+ "columnName": "lyrics",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'REMOTE'"
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "local_music_alias",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` TEXT NOT NULL, `targetId` TEXT NOT NULL, `kind` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `kind`))",
+ "fields": [
+ {
+ "fieldPath": "sourceId",
+ "columnName": "sourceId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "targetId",
+ "columnName": "targetId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "sourceId",
+ "kind"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_local_music_alias_targetId",
+ "unique": false,
+ "columnNames": [
+ "targetId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_local_music_alias_targetId` ON `${TABLE_NAME}` (`targetId`)"
+ }
+ ]
+ },
+ {
+ "tableName": "event",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playTime",
+ "columnName": "playTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_event_songId",
+ "unique": false,
+ "columnNames": [
+ "songId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_event_songId` ON `${TABLE_NAME}` (`songId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "songId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "related_song_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `relatedSongId` TEXT NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`relatedSongId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relatedSongId",
+ "columnName": "relatedSongId",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_related_song_map_songId",
+ "unique": false,
+ "columnNames": [
+ "songId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_songId` ON `${TABLE_NAME}` (`songId`)"
+ },
+ {
+ "name": "index_related_song_map_relatedSongId",
+ "unique": false,
+ "columnNames": [
+ "relatedSongId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_relatedSongId` ON `${TABLE_NAME}` (`relatedSongId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "songId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "relatedSongId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "set_video_id",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `setVideoId` TEXT, PRIMARY KEY(`videoId`))",
+ "fields": [
+ {
+ "fieldPath": "videoId",
+ "columnName": "videoId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "setVideoId",
+ "columnName": "setVideoId",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "videoId"
+ ]
+ }
+ },
+ {
+ "tableName": "playCount",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song` TEXT NOT NULL, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`song`, `year`, `month`))",
+ "fields": [
+ {
+ "fieldPath": "song",
+ "columnName": "song",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "month",
+ "columnName": "month",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "count",
+ "columnName": "count",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "song",
+ "year",
+ "month"
+ ]
+ }
+ },
+ {
+ "tableName": "tag",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` TEXT NOT NULL DEFAULT '#FF6B6B', `createdAt` INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "color",
+ "columnName": "color",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'#FF6B6B'"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "CURRENT_TIMESTAMP"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "playlist_tag_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `tagId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(`playlistId`, `tagId`))",
+ "fields": [
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tagId",
+ "columnName": "tagId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "CURRENT_TIMESTAMP"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "playlistId",
+ "tagId"
+ ]
+ }
+ },
+ {
+ "tableName": "library_top_mix",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `position` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_library_top_mix_position",
+ "unique": false,
+ "columnNames": [
+ "position"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_top_mix_position` ON `${TABLE_NAME}` (`position`)"
+ }
+ ]
+ },
+ {
+ "tableName": "library_top_mix_song_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mixId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`mixId`, `position`), FOREIGN KEY(`mixId`) REFERENCES `library_top_mix`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mixId",
+ "columnName": "mixId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "mixId",
+ "position"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_library_top_mix_song_map_mixId",
+ "unique": false,
+ "columnNames": [
+ "mixId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_top_mix_song_map_mixId` ON `${TABLE_NAME}` (`mixId`)"
+ },
+ {
+ "name": "index_library_top_mix_song_map_songId",
+ "unique": false,
+ "columnNames": [
+ "songId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_top_mix_song_map_songId` ON `${TABLE_NAME}` (`songId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "library_top_mix",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "mixId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "songId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ }
+ ],
+ "views": [
+ {
+ "viewName": "library_song_artist_map",
+ "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT songId, artistId FROM song_artist_map\n UNION\n SELECT song.id AS songId, album_artist_map.artistId\n FROM song JOIN album_artist_map ON song.albumId = album_artist_map.albumId\n WHERE song.isLocal = 1"
+ },
+ {
+ "viewName": "sorted_song_artist_map",
+ "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position"
+ },
+ {
+ "viewName": "sorted_song_album_map",
+ "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`"
+ },
+ {
+ "viewName": "playlist_song_map_preview",
+ "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position"
+ }
+ ],
+ "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, 'b57a26646cb1a03e3c32feae0ab70f02')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/schemas/moe.rukamori.archivetune.db.InternalDatabase/39.json b/app/schemas/moe.rukamori.archivetune.db.InternalDatabase/39.json
new file mode 100644
index 0000000000..5b402e25ea
--- /dev/null
+++ b/app/schemas/moe.rukamori.archivetune.db.InternalDatabase/39.json
@@ -0,0 +1,1408 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 39,
+ "identityHash": "06e4a46e222b01eaf0ec23d8d64fb4d9",
+ "entities": [
+ {
+ "tableName": "song",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `titleOverride` INTEGER NOT NULL DEFAULT 0, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `albumArtist` TEXT, `trackNumber` INTEGER, `discNumber` INTEGER, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `dateAdded` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isMusicVideo` INTEGER NOT NULL DEFAULT 0, `isPodcast` INTEGER NOT NULL DEFAULT 0, `isLocal` INTEGER NOT NULL DEFAULT 0, `exportedDownloadId` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "titleOverride",
+ "columnName": "titleOverride",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "albumId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "albumName",
+ "columnName": "albumName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "albumArtist",
+ "columnName": "albumArtist",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "trackNumber",
+ "columnName": "trackNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "discNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "explicit",
+ "columnName": "explicit",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "date",
+ "columnName": "date",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "dateModified",
+ "columnName": "dateModified",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "dateAdded",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "liked",
+ "columnName": "liked",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "likedDate",
+ "columnName": "likedDate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "totalPlayTime",
+ "columnName": "totalPlayTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "inLibrary",
+ "columnName": "inLibrary",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "dateDownload",
+ "columnName": "dateDownload",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "isMusicVideo",
+ "columnName": "isMusicVideo",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isPodcast",
+ "columnName": "isPodcast",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isLocal",
+ "columnName": "isLocal",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "exportedDownloadId",
+ "columnName": "exportedDownloadId",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_song_albumId",
+ "unique": false,
+ "columnNames": [
+ "albumId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_albumId` ON `${TABLE_NAME}` (`albumId`)"
+ }
+ ]
+ },
+ {
+ "tableName": "artist",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `channelId` TEXT, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `blockedAt` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "channelId",
+ "columnName": "channelId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastUpdateTime",
+ "columnName": "lastUpdateTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bookmarkedAt",
+ "columnName": "bookmarkedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "blockedAt",
+ "columnName": "blockedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "isLocal",
+ "columnName": "isLocal",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "album",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `playlistId` TEXT, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `themeColor` INTEGER, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `explicit` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlistId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "themeColor",
+ "columnName": "themeColor",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "songCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "explicit",
+ "columnName": "explicit",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "lastUpdateTime",
+ "columnName": "lastUpdateTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bookmarkedAt",
+ "columnName": "bookmarkedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "likedDate",
+ "columnName": "likedDate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "inLibrary",
+ "columnName": "inLibrary",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "isLocal",
+ "columnName": "isLocal",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "playlist",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, `createdAt` INTEGER, `lastUpdateTime` INTEGER, `isEditable` INTEGER NOT NULL DEFAULT true, `bookmarkedAt` INTEGER, `remoteSongCount` INTEGER, `playEndpointParams` TEXT, `thumbnailUrl` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `customOrder` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT 0, `isAutoSync` INTEGER NOT NULL DEFAULT 0, `songSortType` TEXT DEFAULT NULL, `songSortDescending` INTEGER DEFAULT NULL, `isHidden` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "browseId",
+ "columnName": "browseId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastUpdateTime",
+ "columnName": "lastUpdateTime",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "isEditable",
+ "columnName": "isEditable",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "true"
+ },
+ {
+ "fieldPath": "bookmarkedAt",
+ "columnName": "bookmarkedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "remoteSongCount",
+ "columnName": "remoteSongCount",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "playEndpointParams",
+ "columnName": "playEndpointParams",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "shuffleEndpointParams",
+ "columnName": "shuffleEndpointParams",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "radioEndpointParams",
+ "columnName": "radioEndpointParams",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "customOrder",
+ "columnName": "customOrder",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "isLocal",
+ "columnName": "isLocal",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isAutoSync",
+ "columnName": "isAutoSync",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "songSortType",
+ "columnName": "songSortType",
+ "affinity": "TEXT",
+ "defaultValue": "NULL"
+ },
+ {
+ "fieldPath": "songSortDescending",
+ "columnName": "songSortDescending",
+ "affinity": "INTEGER",
+ "defaultValue": "NULL"
+ },
+ {
+ "fieldPath": "isHidden",
+ "columnName": "isHidden",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "podcast",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`browseId` TEXT NOT NULL, `playlistId` TEXT, `title` TEXT NOT NULL, `authorName` TEXT, `authorId` TEXT, `thumbnailUrl` TEXT, `localSavedAt` INTEGER, `remoteSavedAt` INTEGER, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`browseId`))",
+ "fields": [
+ {
+ "fieldPath": "browseId",
+ "columnName": "browseId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlistId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "authorName",
+ "columnName": "authorName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "authorId",
+ "columnName": "authorId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnailUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "localSavedAt",
+ "columnName": "localSavedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "remoteSavedAt",
+ "columnName": "remoteSavedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastUpdateTime",
+ "columnName": "lastUpdateTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "browseId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_podcast_playlistId",
+ "unique": true,
+ "columnNames": [
+ "playlistId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_podcast_playlistId` ON `${TABLE_NAME}` (`playlistId`)"
+ }
+ ]
+ },
+ {
+ "tableName": "song_artist_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "songId",
+ "artistId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_song_artist_map_songId",
+ "unique": false,
+ "columnNames": [
+ "songId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)"
+ },
+ {
+ "name": "index_song_artist_map_artistId",
+ "unique": false,
+ "columnNames": [
+ "artistId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "songId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "artist",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "artistId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "song_album_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "albumId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "index",
+ "columnName": "index",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "songId",
+ "albumId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_song_album_map_songId",
+ "unique": false,
+ "columnNames": [
+ "songId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)"
+ },
+ {
+ "name": "index_song_album_map_albumId",
+ "unique": false,
+ "columnNames": [
+ "albumId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_albumId` ON `${TABLE_NAME}` (`albumId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "songId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "album",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "albumId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "album_artist_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "albumId",
+ "columnName": "albumId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "order",
+ "columnName": "order",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "albumId",
+ "artistId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_album_artist_map_albumId",
+ "unique": false,
+ "columnNames": [
+ "albumId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)"
+ },
+ {
+ "name": "index_album_artist_map_artistId",
+ "unique": false,
+ "columnNames": [
+ "artistId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "album",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "albumId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "artist",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "artistId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "playlist_song_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, `setVideoId` TEXT, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "setVideoId",
+ "columnName": "setVideoId",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_playlist_song_map_playlistId",
+ "unique": false,
+ "columnNames": [
+ "playlistId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)"
+ },
+ {
+ "name": "index_playlist_song_map_songId",
+ "unique": false,
+ "columnNames": [
+ "songId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "playlist",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "playlistId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "songId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "search_history",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "query",
+ "columnName": "query",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_search_history_query",
+ "unique": true,
+ "columnNames": [
+ "query"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)"
+ }
+ ]
+ },
+ {
+ "tableName": "format",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, `perceptualLoudnessDb` REAL, `playbackUrl` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itag",
+ "columnName": "itag",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mimeType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "codecs",
+ "columnName": "codecs",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sampleRate",
+ "columnName": "sampleRate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "contentLength",
+ "columnName": "contentLength",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "loudnessDb",
+ "columnName": "loudnessDb",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "perceptualLoudnessDb",
+ "columnName": "perceptualLoudnessDb",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "playbackUrl",
+ "columnName": "playbackUrl",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "lyrics",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, `source` TEXT NOT NULL DEFAULT 'REMOTE', `updatedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lyrics",
+ "columnName": "lyrics",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'REMOTE'"
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "local_music_alias",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` TEXT NOT NULL, `targetId` TEXT NOT NULL, `kind` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `kind`))",
+ "fields": [
+ {
+ "fieldPath": "sourceId",
+ "columnName": "sourceId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "targetId",
+ "columnName": "targetId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "sourceId",
+ "kind"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_local_music_alias_targetId",
+ "unique": false,
+ "columnNames": [
+ "targetId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_local_music_alias_targetId` ON `${TABLE_NAME}` (`targetId`)"
+ }
+ ]
+ },
+ {
+ "tableName": "event",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playTime",
+ "columnName": "playTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_event_songId",
+ "unique": false,
+ "columnNames": [
+ "songId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_event_songId` ON `${TABLE_NAME}` (`songId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "songId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "related_song_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `relatedSongId` TEXT NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`relatedSongId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relatedSongId",
+ "columnName": "relatedSongId",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_related_song_map_songId",
+ "unique": false,
+ "columnNames": [
+ "songId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_songId` ON `${TABLE_NAME}` (`songId`)"
+ },
+ {
+ "name": "index_related_song_map_relatedSongId",
+ "unique": false,
+ "columnNames": [
+ "relatedSongId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_relatedSongId` ON `${TABLE_NAME}` (`relatedSongId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "songId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "relatedSongId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "set_video_id",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `setVideoId` TEXT, PRIMARY KEY(`videoId`))",
+ "fields": [
+ {
+ "fieldPath": "videoId",
+ "columnName": "videoId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "setVideoId",
+ "columnName": "setVideoId",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "videoId"
+ ]
+ }
+ },
+ {
+ "tableName": "playCount",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song` TEXT NOT NULL, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`song`, `year`, `month`))",
+ "fields": [
+ {
+ "fieldPath": "song",
+ "columnName": "song",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "month",
+ "columnName": "month",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "count",
+ "columnName": "count",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "song",
+ "year",
+ "month"
+ ]
+ }
+ },
+ {
+ "tableName": "tag",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` TEXT NOT NULL DEFAULT '#FF6B6B', `createdAt` INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "color",
+ "columnName": "color",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'#FF6B6B'"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "CURRENT_TIMESTAMP"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "playlist_tag_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `tagId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(`playlistId`, `tagId`))",
+ "fields": [
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tagId",
+ "columnName": "tagId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "CURRENT_TIMESTAMP"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "playlistId",
+ "tagId"
+ ]
+ }
+ },
+ {
+ "tableName": "library_top_mix",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `position` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_library_top_mix_position",
+ "unique": false,
+ "columnNames": [
+ "position"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_top_mix_position` ON `${TABLE_NAME}` (`position`)"
+ }
+ ]
+ },
+ {
+ "tableName": "library_top_mix_song_map",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mixId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`mixId`, `position`), FOREIGN KEY(`mixId`) REFERENCES `library_top_mix`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mixId",
+ "columnName": "mixId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "mixId",
+ "position"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_library_top_mix_song_map_mixId",
+ "unique": false,
+ "columnNames": [
+ "mixId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_top_mix_song_map_mixId` ON `${TABLE_NAME}` (`mixId`)"
+ },
+ {
+ "name": "index_library_top_mix_song_map_songId",
+ "unique": false,
+ "columnNames": [
+ "songId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_top_mix_song_map_songId` ON `${TABLE_NAME}` (`songId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "library_top_mix",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "mixId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "song",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "songId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ }
+ ],
+ "views": [
+ {
+ "viewName": "library_song_artist_map",
+ "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT songId, artistId FROM song_artist_map\n UNION\n SELECT song.id AS songId, album_artist_map.artistId\n FROM song JOIN album_artist_map ON song.albumId = album_artist_map.albumId\n WHERE song.isLocal = 1"
+ },
+ {
+ "viewName": "sorted_song_artist_map",
+ "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position"
+ },
+ {
+ "viewName": "sorted_song_album_map",
+ "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`"
+ },
+ {
+ "viewName": "playlist_song_map_preview",
+ "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position"
+ }
+ ],
+ "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, '06e4a46e222b01eaf0ec23d8d64fb4d9')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/automotive/AndroidManifest.xml b/app/src/automotive/AndroidManifest.xml
index 8808b23ebe..e5b8feeae9 100644
--- a/app/src/automotive/AndroidManifest.xml
+++ b/app/src/automotive/AndroidManifest.xml
@@ -49,6 +49,7 @@
+
diff --git a/app/src/gms/kotlin/moe/rukamori/archivetune/ads/data/BrowserSupportPageRepository.kt b/app/src/gms/kotlin/moe/rukamori/archivetune/ads/data/BrowserSupportPageRepository.kt
index decfba54df..c71defd535 100644
--- a/app/src/gms/kotlin/moe/rukamori/archivetune/ads/data/BrowserSupportPageRepository.kt
+++ b/app/src/gms/kotlin/moe/rukamori/archivetune/ads/data/BrowserSupportPageRepository.kt
@@ -51,7 +51,7 @@ internal class BrowserSupportPageRepository
}
private companion object {
- val SUPPORT_PAGE_URI: Uri = Uri.parse("https://archivetune.koiiverse.cloud/support")
+ val SUPPORT_PAGE_URI: Uri = Uri.parse("https://archivetune.miuka.moe/support")
}
}
diff --git a/app/src/gms/kotlin/moe/rukamori/archivetune/cast/DefaultCastPlaybackRepository.kt b/app/src/gms/kotlin/moe/rukamori/archivetune/cast/DefaultCastPlaybackRepository.kt
index 735e0980f7..207b3b87f6 100644
--- a/app/src/gms/kotlin/moe/rukamori/archivetune/cast/DefaultCastPlaybackRepository.kt
+++ b/app/src/gms/kotlin/moe/rukamori/archivetune/cast/DefaultCastPlaybackRepository.kt
@@ -20,6 +20,7 @@ import androidx.media3.cast.DefaultMediaItemConverter
import androidx.media3.cast.MediaItemConverter
import androidx.media3.cast.RemoteCastPlayer
import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.PlayerTransferState
import androidx.media3.common.util.UnstableApi
@@ -29,6 +30,10 @@ import com.google.android.gms.cast.MediaQueueItem
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastSession
import com.google.android.gms.cast.framework.SessionManagerListener
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.withContext
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
@@ -44,8 +49,6 @@ import io.ktor.server.response.respondOutputStream
import io.ktor.server.routing.get
import io.ktor.server.routing.options
import io.ktor.server.routing.routing
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
import timber.log.Timber
import java.io.ByteArrayInputStream
import java.io.File
@@ -62,6 +65,13 @@ import kotlin.math.max
import kotlin.math.min
import com.google.android.gms.cast.MediaMetadata as CastMetadata
+private const val MaxCastTransferPayloadBytes = 48 * 1024
+private const val CastTransferMessageOverheadBytes = 2 * 1024
+private const val CastMediaItemResolutionOverheadBytes = 512
+private const val MaxCastTransferItems = 100
+private const val MaxCastMetadataTextLength = 512
+private const val MaxCastArtworkUriLength = 2_048
+
class DefaultCastPlaybackRepository(
context: Context,
) : CastPlaybackRepository {
@@ -108,7 +118,7 @@ class DefaultCastPlaybackRepository(
.Builder(context.applicationContext)
.setLocalPlayer(localPlayer)
.setRemotePlayer(remotePlayer)
- .setTransferCallback(SafeCastTransferCallback(localPlayer))
+ .setTransferCallback(SafeCastTransferCallback(localPlayer, converter))
.build()
}
@@ -211,17 +221,83 @@ class DefaultCastPlaybackRepository(
private class SafeCastTransferCallback(
private val localPlayer: Player,
+ private val mediaItemConverter: GmsCastMediaItemConverter,
) : CastPlayer.TransferCallback {
+ private var castTransferWindow: CastTransferWindow? = null
+
override fun transferState(
sourcePlayer: Player,
targetPlayer: Player,
) {
val transferState = PlayerTransferState.fromPlayer(sourcePlayer)
if (targetPlayer !== localPlayer) {
- transferState.setToPlayer(targetPlayer)
+ val sourceItems = transferState.mediaItems
+ if (sourcePlayer !== localPlayer || sourceItems.isEmpty()) {
+ castTransferWindow = null
+ transferState.setToPlayer(targetPlayer)
+ return
+ }
+
+ val currentIndex =
+ transferState.currentMediaItemIndex
+ .takeIf { it in sourceItems.indices }
+ ?: 0
+ val castItems = ArrayList(minOf(MaxCastTransferItems, sourceItems.size - currentIndex))
+ var estimatedPayloadBytes = CastTransferMessageOverheadBytes
+
+ for (index in currentIndex until sourceItems.size) {
+ if (castItems.size == MaxCastTransferItems) break
+
+ val mediaItem = sourceItems[index]
+ val itemSizeBytes = mediaItemConverter.estimatedSerializedCastItemBytes(mediaItem)
+ if (castItems.isNotEmpty() &&
+ estimatedPayloadBytes + itemSizeBytes > MaxCastTransferPayloadBytes
+ ) {
+ break
+ }
+
+ castItems += mediaItem
+ estimatedPayloadBytes += itemSizeBytes
+ if (estimatedPayloadBytes > MaxCastTransferPayloadBytes) {
+ Timber.tag("Cast").w("The current Cast item exceeds the transfer payload budget")
+ break
+ }
+ }
+
+ val endIndex = currentIndex + castItems.size
+ val transferWindow =
+ CastTransferWindow(
+ originalItemCount = sourceItems.size,
+ startIndex = currentIndex,
+ endIndexExclusive = endIndex,
+ )
+ val wasLimited = currentIndex != 0 || endIndex != sourceItems.size
+ val boundedTransferState =
+ if (wasLimited) {
+ transferState
+ .buildUpon()
+ .setMediaItems(castItems)
+ .setCurrentMediaItemIndex(0)
+ .build()
+ } else {
+ transferState
+ }
+ boundedTransferState.setToPlayer(targetPlayer)
+ castTransferWindow = transferWindow.takeIf { wasLimited }
+
+ if (wasLimited) {
+ Timber.tag("Cast").w(
+ "Limited Cast transfer to %d of %d queued media items (%d estimated bytes)",
+ castItems.size,
+ sourceItems.size,
+ estimatedPayloadBytes,
+ )
+ }
return
}
+ val transferWindow = castTransferWindow
+ castTransferWindow = null
val localItems = localPlayer.mediaItems()
val sourceCurrentIndex = transferState.currentMediaItemIndex
val repairedItems = ArrayList(transferState.mediaItems.size)
@@ -253,24 +329,63 @@ private class SafeCastTransferCallback(
}
}
+ val usedLocalItemsAsFallback = repairedItems.isEmpty()
val playableItems =
- repairedItems.ifEmpty {
+ if (usedLocalItemsAsFallback) {
localItems.filter { it.localConfiguration != null }
+ } else {
+ repairedItems
+ }
+ val matchingTransferWindow =
+ transferWindow?.takeIf {
+ it.originalItemCount == localItems.size &&
+ it.startIndex in 0..localItems.size &&
+ it.endIndexExclusive in it.startIndex..localItems.size
}
- if (repairedItems.isEmpty()) {
- repairedCurrentIndex = sourceCurrentIndex
+ if (transferWindow != null && matchingTransferWindow == null) {
+ Timber.tag("Cast").w("The local queue changed before the Cast queue was restored")
}
+ val omittedPrefix =
+ if (!usedLocalItemsAsFallback && matchingTransferWindow != null) {
+ localItems.subList(0, matchingTransferWindow.startIndex)
+ .filter { it.localConfiguration != null }
+ } else {
+ emptyList()
+ }
+ val omittedSuffix =
+ if (!usedLocalItemsAsFallback && matchingTransferWindow != null) {
+ localItems.subList(matchingTransferWindow.endIndexExclusive, localItems.size)
+ .filter { it.localConfiguration != null }
+ } else {
+ emptyList()
+ }
+ val currentIndexInPlayableItems =
+ if (usedLocalItemsAsFallback && matchingTransferWindow != null) {
+ matchingTransferWindow.startIndex + sourceCurrentIndex.coerceAtLeast(0)
+ } else {
+ repairedCurrentIndex
+ }
val safeCurrentIndex =
if (playableItems.isEmpty()) {
0
} else {
- repairedCurrentIndex.coerceIn(playableItems.indices)
+ currentIndexInPlayableItems.coerceIn(playableItems.indices)
+ }
+ val restoredItems =
+ if (omittedPrefix.isEmpty() && omittedSuffix.isEmpty()) {
+ playableItems
+ } else {
+ buildList(omittedPrefix.size + playableItems.size + omittedSuffix.size) {
+ addAll(omittedPrefix)
+ addAll(playableItems)
+ addAll(omittedSuffix)
+ }
}
transferState
.buildUpon()
- .setMediaItems(playableItems)
- .setCurrentMediaItemIndex(safeCurrentIndex)
+ .setMediaItems(restoredItems)
+ .setCurrentMediaItemIndex(omittedPrefix.size + safeCurrentIndex)
.build()
.setToPlayer(targetPlayer)
}
@@ -283,6 +398,12 @@ private class SafeCastTransferCallback(
}
}
+private data class CastTransferWindow(
+ val originalItemCount: Int,
+ val startIndex: Int,
+ val endIndexExclusive: Int,
+)
+
private class GmsCastMediaItemConverter(
private val mediaItemResolver: CastMediaItemResolver,
private val localMediaServer: LocalCastMediaServer,
@@ -290,10 +411,18 @@ private class GmsCastMediaItemConverter(
private val delegate = DefaultMediaItemConverter()
override fun toMediaQueueItem(mediaItem: MediaItem): MediaQueueItem {
- val castMediaItem = mediaItem.resolveForReceiver()
+ val castMediaItem = mediaItem.withCompactCastMetadata().resolveForReceiver()
return delegate.toMediaQueueItem(castMediaItem)
}
+ fun estimatedSerializedCastItemBytes(mediaItem: MediaItem): Int =
+ delegate
+ .toMediaQueueItem(mediaItem.withCompactCastMetadata())
+ .toJson()
+ .toString()
+ .toByteArray(Charsets.UTF_8)
+ .size + CastMediaItemResolutionOverheadBytes
+
override fun toMediaItem(mediaQueueItem: MediaQueueItem): MediaItem =
try {
delegate.toMediaItem(mediaQueueItem)
@@ -302,6 +431,30 @@ private class GmsCastMediaItemConverter(
mediaQueueItem.toFallbackMediaItem()
}
+ private fun MediaItem.withCompactCastMetadata(): MediaItem {
+ val metadata = mediaMetadata
+ val artworkUri =
+ metadata.artworkUri?.takeIf {
+ it.toString().length <= MaxCastArtworkUriLength
+ }
+ val compactMetadata =
+ MediaMetadata
+ .Builder()
+ .setTitle(metadata.title?.toString()?.take(MaxCastMetadataTextLength))
+ .setSubtitle(metadata.subtitle?.toString()?.take(MaxCastMetadataTextLength))
+ .setArtist(metadata.artist?.toString()?.take(MaxCastMetadataTextLength))
+ .setAlbumTitle(metadata.albumTitle?.toString()?.take(MaxCastMetadataTextLength))
+ .setAlbumArtist(metadata.albumArtist?.toString()?.take(MaxCastMetadataTextLength))
+ .setArtworkUri(artworkUri)
+ .setTrackNumber(metadata.trackNumber)
+ .setDiscNumber(metadata.discNumber)
+ .setReleaseYear(metadata.releaseYear)
+ .setMediaType(metadata.mediaType)
+ .setIsPlayable(metadata.isPlayable)
+ .build()
+ return buildUpon().setMediaMetadata(compactMetadata).build()
+ }
+
private fun MediaItem.resolveForReceiver(): MediaItem {
val localConfiguration = localConfiguration ?: return this
val uri = localConfiguration.uri
@@ -503,7 +656,8 @@ private class LocalCastMediaServer(
call.respond(HttpStatusCode.NotFound)
return@get
}
- val source = openSource(item, call.request.header(HttpHeaders.Range))
+ val rangeHeader = call.request.header(HttpHeaders.Range)
+ val source = withContext(Dispatchers.IO) { openSource(item, rangeHeader) }
if (source == null) {
call.respond(HttpStatusCode.NotFound)
return@get
@@ -671,7 +825,7 @@ private class LocalCastMediaServer(
val resolved =
item.resolvedMediaItem
?: item.mediaItemResolver?.let { resolver ->
- runCatching { resolver.resolveForCast(item.mediaItem) }
+ runCatching { resolver.resolveForCastBlocking(item.mediaItem) }
.onFailure { Timber.tag("Cast").w(it, "Unable to resolve media item for Cast") }
.getOrNull()
}?.also { item.resolvedMediaItem = it }
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 10f73caddc..47f3fd6cbe 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -71,6 +71,12 @@
android:launchMode="singleTask"
android:theme="@style/Theme.ArchiveTune"
android:windowSoftInputMode="adjustResize">
+
+
+
+
+
+
@@ -285,6 +291,14 @@
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/App.kt b/app/src/main/kotlin/moe/rukamori/archivetune/App.kt
index f7c1d36ed9..43e1c4685b 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/App.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/App.kt
@@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+import moe.rukamori.archivetune.appicon.RestoreLauncherEntryUseCase
import moe.rukamori.archivetune.canvas.StartCanvasPolicyUseCase
import moe.rukamori.archivetune.constants.*
import moe.rukamori.archivetune.downloads.DownloadedArtworkRepository
@@ -92,6 +93,9 @@ class App :
@Inject
lateinit var startCanvasPolicy: StartCanvasPolicyUseCase
+ @Inject
+ lateinit var restoreLauncherEntry: RestoreLauncherEntryUseCase
+
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
@Volatile private var isInitialized = false
@@ -132,6 +136,21 @@ class App :
initializeGatekeeper()
initializeCriticalSync()
initializeDeferredAsync()
+ initializeLauncherEntry()
+ }
+
+ private fun initializeLauncherEntry() {
+ if (BuildConfig.DEVICE == "automotive") return
+ applicationScope.launch(Dispatchers.IO) {
+ try {
+ restoreLauncherEntry()
+ } catch (cancellation: CancellationException) {
+ throw cancellation
+ } catch (error: Exception) {
+ Timber.e(error, "Unable to restore launcher entry")
+ reportException(error)
+ }
+ }
}
private fun initializeGatekeeper() {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/MainActivity.kt b/app/src/main/kotlin/moe/rukamori/archivetune/MainActivity.kt
index 8ed461ef51..172307f4ab 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/MainActivity.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/MainActivity.kt
@@ -28,12 +28,13 @@ import android.view.WindowManager
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.activity.ComponentActivity
-import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
@@ -92,11 +93,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
+import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.RichTooltip
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Surface
+import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
@@ -168,11 +171,14 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata.MEDIA_TYPE_MUSIC
import androidx.media3.common.Player
import androidx.media3.common.Timeline
+import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
+import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
+import androidx.navigationevent.compose.rememberNavigationEventDispatcherOwner
import androidx.window.core.layout.WindowSizeClass
import coil3.imageLoader
import coil3.request.ImageRequest
@@ -180,6 +186,8 @@ import coil3.request.allowHardware
import coil3.toBitmap
import com.valentinilk.shimmer.LocalShimmerTheme
import dagger.hilt.android.AndroidEntryPoint
+import dev.chrisbanes.haze.hazeSource
+import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -214,9 +222,13 @@ import moe.rukamori.archivetune.constants.MiniPlayerBottomSpacing
import moe.rukamori.archivetune.constants.MiniPlayerHeight
import moe.rukamori.archivetune.constants.MiniPlayerLastAnchorKey
import moe.rukamori.archivetune.constants.NavigationBarAnimationSpec
+import moe.rukamori.archivetune.constants.NavigationBarBackgroundStyle
+import moe.rukamori.archivetune.constants.NavigationBarBackgroundStyleKey
import moe.rukamori.archivetune.constants.NavigationBarBottomPadding
import moe.rukamori.archivetune.constants.NavigationBarHeight
import moe.rukamori.archivetune.constants.NavigationBarHorizontalPadding
+import moe.rukamori.archivetune.constants.NavigationBarMode
+import moe.rukamori.archivetune.constants.NavigationBarModeKey
import moe.rukamori.archivetune.constants.PauseSearchHistoryKey
import moe.rukamori.archivetune.constants.PlayerBackgroundStyle
import moe.rukamori.archivetune.constants.PlayerBackgroundStyleKey
@@ -272,6 +284,7 @@ import moe.rukamori.archivetune.ui.component.LocalMenuState
import moe.rukamori.archivetune.ui.component.MarkdownText
import moe.rukamori.archivetune.ui.component.NetworkStatusBanner
import moe.rukamori.archivetune.ui.component.StarDialog
+import moe.rukamori.archivetune.ui.component.TopAppBarOverflowMenu
import moe.rukamori.archivetune.ui.component.TopSearch
import moe.rukamori.archivetune.ui.component.TvNavigationRail
import moe.rukamori.archivetune.ui.component.rememberBottomSheetState
@@ -300,6 +313,8 @@ import moe.rukamori.archivetune.ui.theme.extractThemeColor
import moe.rukamori.archivetune.ui.theme.extractWallpaperThemeColor
import moe.rukamori.archivetune.ui.utils.appBarScrollBehavior
import moe.rukamori.archivetune.ui.utils.backToMain
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.ui.utils.resetHeightOffset
import moe.rukamori.archivetune.updates.ObserveUpdateSettingsUseCase
import moe.rukamori.archivetune.utils.PreferenceStore
@@ -316,6 +331,7 @@ import moe.rukamori.archivetune.viewmodels.BackupCategory
import moe.rukamori.archivetune.viewmodels.BackupRestoreViewModel
import moe.rukamori.archivetune.viewmodels.GatekeeperViewModel
import moe.rukamori.archivetune.viewmodels.HomeViewModel
+import moe.rukamori.archivetune.viewmodels.MainMenuViewModel
import moe.rukamori.archivetune.viewmodels.NetworkBannerViewModel
import moe.rukamori.archivetune.viewmodels.NewsViewModel
import moe.rukamori.archivetune.viewmodels.OnlineSearchSort
@@ -990,6 +1006,28 @@ class MainActivity : ComponentActivity() {
val homeViewModel: HomeViewModel = hiltViewModel()
val networkBannerViewModel: NetworkBannerViewModel = hiltViewModel()
val newsViewModel: NewsViewModel = hiltViewModel()
+ val mainMenuViewModel: MainMenuViewModel = hiltViewModel()
+ val topAppBarOverflowExpanded by mainMenuViewModel.expanded.collectAsStateWithLifecycle()
+ val menuProfileState by mainMenuViewModel.profileState.collectAsStateWithLifecycle()
+ val menuAccountImageUrl by homeViewModel.accountImageUrl.collectAsStateWithLifecycle()
+ val menuHazeState = rememberHazeState()
+ val floatingBarsHazeState = rememberHazeState()
+ val navigationBarBackgroundStyle by rememberEnumPreference(
+ key = NavigationBarBackgroundStyleKey,
+ defaultValue = NavigationBarBackgroundStyle.THEME,
+ )
+ val navigationBarMode by rememberEnumPreference(
+ key = NavigationBarModeKey,
+ defaultValue = NavigationBarMode.CONNECTED,
+ )
+ val menuLifecycleOwner = LocalLifecycleOwner.current
+ LaunchedEffect(mainMenuViewModel, navController, menuLifecycleOwner) {
+ menuLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ mainMenuViewModel.destinations.collect { destination ->
+ navController.navigate(destination.route) { launchSingleTop = true }
+ }
+ }
+ }
val allLocalItems by homeViewModel.allLocalItems.collectAsState()
val allYtItems by homeViewModel.allYtItems.collectAsState()
val networkBannerState by networkBannerViewModel.bannerState.collectAsStateWithLifecycle()
@@ -1143,6 +1181,9 @@ class MainActivity : ComponentActivity() {
homeOverflowMenuExpanded = false
}
}
+ LaunchedEffect(currentRoute) {
+ mainMenuViewModel.dismiss()
+ }
val playerBackground by rememberEnumPreference(
key = PlayerBackgroundStyleKey,
@@ -1219,7 +1260,7 @@ class MainActivity : ComponentActivity() {
setSystemBarAppearance(isDarkStatusBar)
}
- val miniPlayerAnchor by remember {
+ val miniPlayerAnchor by remember(playerBottomSheetState) {
derivedStateOf {
when {
playerBottomSheetState.isExpanded -> EXPANDED_ANCHOR
@@ -1414,9 +1455,10 @@ class MainActivity : ComponentActivity() {
if ((
currentRoute?.startsWith("artist/") == true ||
- currentRoute?.startsWith("album/") == true
+ currentRoute?.startsWith("album/") == true ||
+ currentRoute?.startsWith("local_playlist/") == true
) &&
- playerBottomSheetState.isExpanded
+ playerBottomSheetState.isExpandedOrExpanding
) {
playerBottomSheetState.collapseSoft()
}
@@ -1477,7 +1519,7 @@ class MainActivity : ComponentActivity() {
var restoredMiniPlayerAnchor by remember(playerConnection) { mutableStateOf(false) }
- LaunchedEffect(playerConnection, savedMiniPlayerAnchor, isYearInMusicScreen) {
+ LaunchedEffect(playerConnection, savedMiniPlayerAnchor, isYearInMusicScreen, playerBottomSheetState) {
if (restoredMiniPlayerAnchor) return@LaunchedEffect
val connection = playerConnection ?: return@LaunchedEffect
connection.queueRestoreCompleted.first { it }
@@ -1665,6 +1707,19 @@ class MainActivity : ComponentActivity() {
}
}
+ val playerBackDispatcherOwner = checkNotNull(LocalNavigationEventDispatcherOwner.current)
+ val screenBackEnabled by remember(playerBottomSheetState) {
+ derivedStateOf {
+ !playerBottomSheetState.isExpandedOrExpanding &&
+ playerBottomSheetState.progress <= 0f
+ }
+ }
+ val screenBackDispatcherOwner =
+ rememberNavigationEventDispatcherOwner(
+ enabled = screenBackEnabled,
+ parent = playerBackDispatcherOwner,
+ )
+
CompositionLocalProvider(
LocalHapticFeedback provides customHaptic,
LocalAnimationsDisabled provides disableAnimations,
@@ -1678,402 +1733,409 @@ class MainActivity : ComponentActivity() {
moe.rukamori.archivetune.ui.component.LocalBottomSheetPageState provides bottomSheetPageState,
moe.rukamori.archivetune.ui.component.LocalMenuState provides menuState,
) {
- Row {
- AnimatedVisibility(
- visible =
- useRail &&
- shouldShowNavigationBar &&
- (isTvDevice || !playerBottomSheetState.isExpandedOrExpanding),
- enter = fadeIn(animationSpec = tween(durationMillis = if (disableAnimations) 0 else 150)),
- exit = fadeOut(animationSpec = tween(durationMillis = if (disableAnimations) 0 else 100)),
- ) {
- if (isTvDevice) {
- TvNavigationRail(
- items = navigationItems,
- selectedItemRoute =
- if (active) {
- Screens.Search.route
- } else {
- currentRoute
- },
- modifier = Modifier,
- firstItemFocusRequester = tvRailFocusRequester,
- contentFocusRequester =
- if (active ||
- navBackStackEntry?.destination?.route?.startsWith(OnlineSearchResultRoutePrefix) == true
- ) {
- searchBarFocusRequester
- } else {
- contentAreaFocusRequester
- },
- onItemClick = { screen ->
- val wasPlayerActive = playerBottomSheetState.isExpanded
- if (wasPlayerActive) {
- playerBottomSheetState.collapse(if (disableAnimations) snap() else spring())
- }
- val isSelected =
- navBackStackEntry?.destination?.hierarchy?.any { it.route == screen.route } == true
- if (wasPlayerActive && isSelected) {
- return@TvNavigationRail
- }
- handlePrimaryNavigationClick(screen, isSelected)
- },
- )
- } else {
- NavigationRail(
- containerColor = if (pureBlack) Color.Black else MaterialTheme.colorScheme.surfaceContainer,
- contentColor = if (pureBlack) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
- header = { Spacer(Modifier.height(24.dp)) },
- ) {
- navigationItems.fastForEach { screen ->
- val isSelected =
- navBackStackEntry?.destination?.hierarchy?.any { it.route == screen.route } == true
-
- NavigationRailItem(
- selected = isSelected,
- icon = {
- Icon(
- painter =
- painterResource(
- id = if (isSelected) screen.iconIdActive else screen.iconIdInactive,
- ),
- contentDescription = null,
- )
+ CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides screenBackDispatcherOwner) {
+ Row {
+ AnimatedVisibility(
+ visible =
+ useRail &&
+ shouldShowNavigationBar &&
+ (isTvDevice || !playerBottomSheetState.isExpandedOrExpanding),
+ enter = fadeIn(animationSpec = tween(durationMillis = if (disableAnimations) 0 else 150)),
+ exit = fadeOut(animationSpec = tween(durationMillis = if (disableAnimations) 0 else 100)),
+ ) {
+ if (isTvDevice) {
+ TvNavigationRail(
+ items = navigationItems,
+ selectedItemRoute =
+ if (active) {
+ Screens.Search.route
+ } else {
+ currentRoute
},
- label = {
- Text(
- text = stringResource(screen.titleId),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
+ modifier = Modifier,
+ firstItemFocusRequester = tvRailFocusRequester,
+ contentFocusRequester =
+ if (active ||
+ navBackStackEntry?.destination?.route?.startsWith(OnlineSearchResultRoutePrefix) == true
+ ) {
+ searchBarFocusRequester
+ } else {
+ contentAreaFocusRequester
},
- onClick = {
- val wasPlayerActive = playerBottomSheetState.isExpanded
+ onItemClick = { screen ->
+ val wasPlayerActive = playerBottomSheetState.isExpandedOrExpanding
+ if (wasPlayerActive) {
+ playerBottomSheetState.collapse(if (disableAnimations) snap() else spring())
+ }
+ val isSelected =
+ navBackStackEntry?.destination?.hierarchy?.any { it.route == screen.route } == true
+ if (wasPlayerActive && isSelected) {
+ return@TvNavigationRail
+ }
+ handlePrimaryNavigationClick(screen, isSelected)
+ },
+ )
+ } else {
+ NavigationRail(
+ containerColor = if (pureBlack) Color.Black else MaterialTheme.colorScheme.surfaceContainer,
+ contentColor = if (pureBlack) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
+ header = { Spacer(Modifier.height(24.dp)) },
+ ) {
+ navigationItems.fastForEach { screen ->
+ val isSelected =
+ navBackStackEntry?.destination?.hierarchy?.any { it.route == screen.route } == true
- if (wasPlayerActive) {
- playerBottomSheetState.collapse(if (disableAnimations) snap() else spring())
- }
+ NavigationRailItem(
+ selected = isSelected,
+ icon = {
+ Icon(
+ painter =
+ painterResource(
+ id = if (isSelected) screen.iconIdActive else screen.iconIdInactive,
+ ),
+ contentDescription = null,
+ )
+ },
+ label = {
+ Text(
+ text = stringResource(screen.titleId),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ onClick = {
+ val wasPlayerActive = playerBottomSheetState.isExpanded
- if (wasPlayerActive && isSelected) return@NavigationRailItem
- handlePrimaryNavigationClick(screen, isSelected)
- },
- )
+ if (wasPlayerActive) {
+ playerBottomSheetState.collapse(if (disableAnimations) snap() else spring())
+ }
+
+ if (wasPlayerActive && isSelected) return@NavigationRailItem
+ handlePrimaryNavigationClick(screen, isSelected)
+ },
+ )
+ }
}
}
}
- }
- Scaffold(
- topBar = {
- if (shouldShowTopBar) {
- val shouldUseFloatingTopBar =
- remember(navBackStackEntry) {
- navBackStackEntry?.destination?.route == Screens.Home.route ||
- navBackStackEntry?.destination?.route == Screens.Search.route ||
- navBackStackEntry?.destination?.route == Screens.Library.route
- }
- val shouldShowBlurBackground =
- remember(navBackStackEntry) {
- shouldUseFloatingTopBar
- }
+ Scaffold(
+ topBar = {
+ if (shouldShowTopBar) {
+ val shouldUseFloatingTopBar =
+ remember(navBackStackEntry) {
+ navBackStackEntry?.destination?.route == Screens.Home.route ||
+ navBackStackEntry?.destination?.route == Screens.Search.route ||
+ navBackStackEntry?.destination?.route == Screens.Library.route
+ }
+ val shouldShowBlurBackground =
+ remember(navBackStackEntry) {
+ shouldUseFloatingTopBar
+ }
- val surfaceColor = MaterialTheme.colorScheme.surface
- val currentScrollBehavior =
- when (navBackStackEntry?.destination?.route) {
- Screens.Home.route -> homeScrollBehavior
+ val surfaceColor = MaterialTheme.colorScheme.surface
+ val currentScrollBehavior =
+ when (navBackStackEntry?.destination?.route) {
+ Screens.Home.route -> homeScrollBehavior
- Screens.Search.route -> searchScrollBehavior
+ Screens.Search.route -> searchScrollBehavior
- Screens.Library.route -> libraryScrollBehavior
+ Screens.Library.route -> libraryScrollBehavior
- else -> topAppBarScrollBehavior
- }
- val isLibraryRoute = navBackStackEntry?.destination?.route == Screens.Library.route
-
- // Rigid slide (Step 3): the header translates as a block via
- // Modifier.offset while the M3 TopAppBar itself gets
- // scrollBehavior = null (below), so it never collapses or
- // double-renders. The floating behavior only listens to
- // scroll (non-consuming) and updates heightOffset.
- //
- // heightOffsetLimit must be set from the measured header
- // height. The header Box is a single shared shell composable
- // whose size is identical for Home/Search, so onSizeChanged
- // fires only once (size doesn't change on tab switch).
- // Therefore: measure once here, then apply the limit to the
- // CURRENT route's state via LaunchedEffect so every route gets
- // its limit on entry (not just the first-measured one).
- var headerHeightPx by remember { mutableStateOf(0) }
- val libraryChipHeightPx = with(LocalDensity.current) { LibraryHeaderContentPadding.toPx() }
- LaunchedEffect(currentScrollBehavior, headerHeightPx, libraryChipHeightPx) {
- if (headerHeightPx > 0) {
- val limit = -(headerHeightPx + if (isLibraryRoute) libraryChipHeightPx else 0f)
- val state = currentScrollBehavior.state
- if (state.heightOffsetLimit != limit) {
- state.heightOffsetLimit = limit
- state.heightOffset = state.heightOffset.coerceIn(limit, 0f)
+ else -> topAppBarScrollBehavior
+ }
+ val isLibraryRoute = navBackStackEntry?.destination?.route == Screens.Library.route
+
+ // Rigid slide (Step 3): the header translates as a block via
+ // Modifier.offset while the M3 TopAppBar itself gets
+ // scrollBehavior = null (below), so it never collapses or
+ // double-renders. The floating behavior only listens to
+ // scroll (non-consuming) and updates heightOffset.
+ //
+ // heightOffsetLimit must be set from the measured header
+ // height. The header Box is a single shared shell composable
+ // whose size is identical for Home/Search, so onSizeChanged
+ // fires only once (size doesn't change on tab switch).
+ // Therefore: measure once here, then apply the limit to the
+ // CURRENT route's state via LaunchedEffect so every route gets
+ // its limit on entry (not just the first-measured one).
+ var headerHeightPx by remember { mutableStateOf(0) }
+ val libraryChipHeightPx = with(LocalDensity.current) { LibraryHeaderContentPadding.toPx() }
+ LaunchedEffect(currentScrollBehavior, headerHeightPx, libraryChipHeightPx) {
+ if (headerHeightPx > 0) {
+ val limit = -(headerHeightPx + if (isLibraryRoute) libraryChipHeightPx else 0f)
+ val state = currentScrollBehavior.state
+ if (state.heightOffsetLimit != limit) {
+ state.heightOffsetLimit = limit
+ state.heightOffset = state.heightOffset.coerceIn(limit, 0f)
+ }
}
}
- }
- Box(
- modifier =
- Modifier
- .onSizeChanged { size ->
- if (size.height > 0) headerHeightPx = size.height
- }.offset {
- IntOffset(
- x = 0,
- y =
- currentScrollBehavior.state.heightOffset.roundToInt(),
- )
- },
- ) {
- // Gradient shadow background. It lives inside the
- // translating Box (which moves by the full heightOffset,
- // so the TopAppBar fully hides), but a counter-offset
- // clamps the gradient's net translation to [-appBarHeight, 0]
- // so it parks with its top band over the status bar instead
- // of sliding fully off — a legibility scrim once the header
- // is hidden.
- if (shouldShowBlurBackground) {
- val appBarHeightPx = with(LocalDensity.current) { AppBarHeight.toPx() }
- Box(
- modifier =
- Modifier
- .offset {
- val raw = currentScrollBehavior.state.heightOffset
- val clamped = raw.coerceAtLeast(-appBarHeightPx)
- IntOffset(x = 0, y = (clamped - raw).roundToInt())
- }.fillMaxWidth()
- .height(
- AppBarHeight +
- with(LocalDensity.current) {
- WindowInsets.systemBars.getTop(LocalDensity.current).toDp()
- },
- ).background(
- Brush.verticalGradient(
- colors =
- listOf(
- surfaceColor.copy(alpha = 0.95f),
- surfaceColor.copy(alpha = 0.85f),
- surfaceColor.copy(alpha = 0.6f),
- Color.Transparent,
- ),
+ Box(
+ modifier =
+ Modifier
+ .onSizeChanged { size ->
+ if (size.height > 0) headerHeightPx = size.height
+ }.offset {
+ IntOffset(
+ x = 0,
+ y =
+ currentScrollBehavior.state.heightOffset.roundToInt(),
+ )
+ },
+ ) {
+ // Gradient shadow background. It lives inside the
+ // translating Box (which moves by the full heightOffset,
+ // so the TopAppBar fully hides), but a counter-offset
+ // clamps the gradient's net translation to [-appBarHeight, 0]
+ // so it parks with its top band over the status bar instead
+ // of sliding fully off — a legibility scrim once the header
+ // is hidden.
+ if (shouldShowBlurBackground) {
+ val appBarHeightPx = with(LocalDensity.current) { AppBarHeight.toPx() }
+ Box(
+ modifier =
+ Modifier
+ .offset {
+ val raw = currentScrollBehavior.state.heightOffset
+ val clamped = raw.coerceAtLeast(-appBarHeightPx)
+ IntOffset(x = 0, y = (clamped - raw).roundToInt())
+ }.fillMaxWidth()
+ .height(
+ AppBarHeight +
+ with(LocalDensity.current) {
+ WindowInsets.systemBars.getTop(LocalDensity.current).toDp()
+ },
+ ).background(
+ Brush.verticalGradient(
+ colors =
+ listOf(
+ surfaceColor.copy(alpha = 0.95f),
+ surfaceColor.copy(alpha = 0.85f),
+ surfaceColor.copy(alpha = 0.6f),
+ Color.Transparent,
+ ),
+ ),
),
- ),
- )
- }
+ )
+ }
- TopAppBar(
- windowInsets =
- WindowInsets.safeDrawing.only(
- (
- if (useRail) {
- WindowInsetsSides.Right
- } else {
- WindowInsetsSides.Horizontal
- }
- ) + WindowInsetsSides.Top,
- ),
- title = {
- Row(verticalAlignment = Alignment.CenterVertically) {
- // app icon
- Icon(
- painter = painterResource(R.drawable.about_appbar),
- contentDescription = null,
- modifier =
- Modifier
- .size(35.dp)
- .padding(end = 3.dp),
- )
- Text(
- text = stringResource(R.string.app_name),
- style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
- },
- actions = {
- TranslucentTopAppBarIconButton(
- onClick = { navController.navigate("history") },
- ) {
- Icon(
- painter = painterResource(R.drawable.history),
- contentDescription = stringResource(R.string.history),
- )
- }
- val newsTooltipState = rememberTooltipState()
- val newsLifecycleOwner = LocalLifecycleOwner.current
- LaunchedEffect(latestUnreadNewsTimestamp, newsLifecycleOwner, newsTooltipState) {
- val timestamp = latestUnreadNewsTimestamp
- if (timestamp == null) {
- newsTooltipState.dismiss()
- return@LaunchedEffect
+ TopAppBar(
+ windowInsets =
+ WindowInsets.safeDrawing.only(
+ (
+ if (useRail) {
+ WindowInsetsSides.Right
+ } else {
+ WindowInsetsSides.Horizontal
+ }
+ ) + WindowInsetsSides.Top,
+ ),
+ title = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ // app icon
+ Icon(
+ painter = painterResource(R.drawable.about_appbar),
+ contentDescription = null,
+ modifier =
+ Modifier
+ .size(35.dp)
+ .padding(end = 3.dp),
+ )
+ Text(
+ text = stringResource(R.string.app_name),
+ style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
}
- newsLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
- if (newsViewModel.claimUnreadNewsTooltip(timestamp)) {
- try {
- newsTooltipState.show()
- } finally {
- newsTooltipState.dismiss()
+ },
+ actions = {
+ TranslucentTopAppBarIconButton(
+ onClick = { navController.navigate("history") },
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.history),
+ contentDescription = stringResource(R.string.history),
+ )
+ }
+ TranslucentTopAppBarIconButton(
+ onClick = { navController.navigate("new_release") },
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.new_release),
+ contentDescription = stringResource(R.string.new_releases),
+ )
+ }
+ val hasUpdateAvailable =
+ BuildConfig.UPDATER_AVAILABLE &&
+ latestUpdateChannel == updateChannel &&
+ latestUpdateChannel != UpdateChannel.ARTIFACT &&
+ Updater.isUpdateAvailable(latestVersionName, BuildConfig.VERSION_NAME)
+ val newsTooltipState = rememberTooltipState()
+ val newsLifecycleOwner = LocalLifecycleOwner.current
+ LaunchedEffect(
+ latestUnreadNewsTimestamp,
+ newsLifecycleOwner,
+ newsTooltipState,
+ ) {
+ val timestamp = latestUnreadNewsTimestamp
+ if (timestamp == null) {
+ newsTooltipState.dismiss()
+ return@LaunchedEffect
+ }
+ newsLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ if (newsViewModel.claimUnreadNewsTooltip(timestamp)) {
+ try {
+ newsTooltipState.show()
+ } finally {
+ newsTooltipState.dismiss()
+ }
}
}
}
- }
- TooltipBox(
- positionProvider =
- if (hasUnreadNews) {
- TooltipDefaults.rememberRichTooltipPositionProvider()
- } else {
- TooltipDefaults.rememberPlainTooltipPositionProvider()
- },
- tooltip = {
- if (hasUnreadNews) {
- RichTooltip(
- title = { Text(stringResource(R.string.news_tooltip_title)) },
- ) {
- Text(stringResource(R.string.news_tooltip_body))
- }
- } else {
- PlainTooltip {
- Text(stringResource(R.string.news))
+ TooltipBox(
+ positionProvider =
+ if (hasUnreadNews) {
+ TooltipDefaults.rememberRichTooltipPositionProvider()
+ } else {
+ TooltipDefaults.rememberPlainTooltipPositionProvider()
+ },
+ tooltip = {
+ if (hasUnreadNews) {
+ RichTooltip(
+ title = { Text(stringResource(R.string.news_tooltip_title)) },
+ ) {
+ Text(stringResource(R.string.news_tooltip_body))
+ }
+ } else {
+ PlainTooltip {
+ Text(stringResource(R.string.more_options))
+ }
}
- }
- },
- state = newsTooltipState,
- ) {
- TranslucentTopAppBarIconButton(
- onClick = {
- newsTooltipState.dismiss()
- navController.navigate("news")
},
+ state = newsTooltipState,
) {
- BadgedBox(badge = {
- if (hasUnreadNews) {
- Badge()
+ Box {
+ TranslucentTopAppBarIconButton(
+ onClick = {
+ newsTooltipState.dismiss()
+ mainMenuViewModel.toggle()
+ },
+ ) {
+ BadgedBox(badge = {
+ if (hasUnreadNews || hasUpdateAvailable) {
+ Badge()
+ }
+ }) {
+ Icon(
+ painter = painterResource(R.drawable.more_horiz),
+ contentDescription =
+ stringResource(R.string.more_options),
+ )
+ }
}
- }) {
- Icon(
- painter = painterResource(R.drawable.newspaper),
- contentDescription = stringResource(R.string.news),
+ TopAppBarOverflowMenu(
+ expanded = topAppBarOverflowExpanded,
+ profileState = menuProfileState,
+ accountImageUrl = menuAccountImageUrl,
+ hazeState = menuHazeState,
+ pureBlack = pureBlack,
+ hasUnreadNews = hasUnreadNews,
+ hasUpdateAvailable = hasUpdateAvailable,
+ onDismissRequest = mainMenuViewModel::dismiss,
+ onSelect = { destination ->
+ newsTooltipState.dismiss()
+ mainMenuViewModel.select(destination)
+ },
)
}
}
- }
- TranslucentTopAppBarIconButton(
- onClick = { navController.navigate("new_release") },
- ) {
- Icon(
- painter = painterResource(R.drawable.new_release),
- contentDescription = stringResource(R.string.new_release_albums),
- )
- }
- TranslucentTopAppBarIconButton(
- onClick = { navController.navigate("settings") },
- ) {
- BadgedBox(badge = {
- if (
- BuildConfig.UPDATER_AVAILABLE &&
- latestUpdateChannel == updateChannel &&
- latestUpdateChannel != UpdateChannel.ARTIFACT &&
- Updater.isUpdateAvailable(latestVersionName, BuildConfig.VERSION_NAME)
- ) {
- Badge()
- }
- }) {
- Icon(
- painter = painterResource(R.drawable.settings),
- contentDescription = stringResource(R.string.settings),
- modifier = Modifier.size(24.dp),
- )
- }
- }
- },
- scrollBehavior =
- if (shouldUseFloatingTopBar) {
- null
- } else {
- topAppBarScrollBehavior
},
- colors =
- TopAppBarDefaults.topAppBarColors(
- containerColor =
- if (shouldUseFloatingTopBar) {
- Color.Transparent
- } else if (pureBlack) {
- Color.Black
- } else {
- MaterialTheme.colorScheme.surface
- },
- scrolledContainerColor =
- if (shouldUseFloatingTopBar) {
- Color.Transparent
- } else if (pureBlack) {
- Color.Black
- } else {
- MaterialTheme.colorScheme.surface
- },
- titleContentColor = MaterialTheme.colorScheme.onSurface,
- actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
- navigationIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
- ),
- )
- }
- }
- AnimatedVisibility(
- visible =
- active ||
- navBackStackEntry?.destination?.route?.startsWith(OnlineSearchResultRoutePrefix) == true,
- enter = fadeIn(animationSpec = tween(durationMillis = if (disableAnimations) 0 else 300)),
- exit = fadeOut(animationSpec = tween(durationMillis = if (disableAnimations) 0 else 200)),
- ) {
- TopSearch(
- query = query,
- onQueryChange = onQueryChange,
- onSearch = onSearch,
- active = active,
- onActiveChange = onActiveChange,
- placeholder = {
- Text(
- text =
- stringResource(
- when (searchSource) {
- SearchSource.LOCAL -> R.string.search_library
- SearchSource.ONLINE -> R.string.search_yt_music
- },
+ scrollBehavior =
+ if (shouldUseFloatingTopBar) {
+ null
+ } else {
+ topAppBarScrollBehavior
+ },
+ colors =
+ TopAppBarDefaults.topAppBarColors(
+ containerColor =
+ if (shouldUseFloatingTopBar) {
+ Color.Transparent
+ } else if (pureBlack) {
+ Color.Black
+ } else {
+ MaterialTheme.colorScheme.surface
+ },
+ scrolledContainerColor =
+ if (shouldUseFloatingTopBar) {
+ Color.Transparent
+ } else if (pureBlack) {
+ Color.Black
+ } else {
+ MaterialTheme.colorScheme.surface
+ },
+ titleContentColor = MaterialTheme.colorScheme.onSurface,
+ actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ navigationIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
- },
- leadingIcon = {
- IconButton(
- onClick = {
- when {
- active -> {
- onActiveChange(false)
- }
+ }
+ }
+ AnimatedVisibility(
+ visible =
+ active ||
+ navBackStackEntry?.destination?.route?.startsWith(OnlineSearchResultRoutePrefix) == true,
+ enter = fadeIn(animationSpec = tween(durationMillis = if (disableAnimations) 0 else 300)),
+ exit = fadeOut(animationSpec = tween(durationMillis = if (disableAnimations) 0 else 200)),
+ ) {
+ TopSearch(
+ query = query,
+ onQueryChange = onQueryChange,
+ onSearch = onSearch,
+ active = active,
+ onActiveChange = onActiveChange,
+ placeholder = {
+ Text(
+ text =
+ stringResource(
+ when (searchSource) {
+ SearchSource.LOCAL -> R.string.search_library
+ SearchSource.ONLINE -> R.string.search_yt_music
+ },
+ ),
+ )
+ },
+ leadingIcon = {
+ IconButton(
+ onClick = {
+ when {
+ active -> {
+ onActiveChange(false)
+ }
- !navigationItems.fastAny {
- it.route == navBackStackEntry?.destination?.route
- } -> {
- navController.navigateUp()
- }
+ !navigationItems.fastAny {
+ it.route == navBackStackEntry?.destination?.route
+ } -> {
+ navController.navigateUp()
+ }
- else -> {
- onActiveChange(true)
- }
- }
- },
- onLongClick = {
- when {
- active -> {}
-
- !navigationItems.fastAny {
- it.route == navBackStackEntry?.destination?.route
- } -> {
- navController.backToMain()
+ else -> {
+ onActiveChange(true)
+ }
}
+ },
+ onLongClick = {
+ when {
+ active -> {}
+
+ !navigationItems.fastAny {
+ it.route == navBackStackEntry?.destination?.route
+ } -> {
+ navController.backToMain()
+ }
else -> {}
}
@@ -2114,29 +2176,6 @@ class MainActivity : ComponentActivity() {
)
}
}
- IconButton(
- onClick = {
- searchSource =
- if (searchSource ==
- SearchSource.ONLINE
- ) {
- SearchSource.LOCAL
- } else {
- SearchSource.ONLINE
- }
- },
- ) {
- Icon(
- painter =
- painterResource(
- when (searchSource) {
- SearchSource.LOCAL -> R.drawable.library_music
- SearchSource.ONLINE -> R.drawable.language
- },
- ),
- contentDescription = null,
- )
- }
} else if (currentRoute?.startsWith(OnlineSearchResultRoutePrefix) == true) {
OnlineSearchSortMenu(
selectedSort = onlineSearchSort,
@@ -2173,43 +2212,66 @@ class MainActivity : ComponentActivity() {
)
},
) {
- Crossfade(
- targetState = searchSource,
- animationSpec = tween(durationMillis = if (disableAnimations) 0 else 300),
- label = "",
+ Column(
modifier =
Modifier
.fillMaxSize()
.padding(
bottom = if (!playerBottomSheetState.isDismissed) MiniPlayerHeight else 0.dp,
).navigationBarsPadding(),
- ) { searchSource ->
- when (searchSource) {
- SearchSource.LOCAL -> {
- LocalSearchScreen(
- query = query.text,
- navController = navController,
- onDismiss = { onActiveChange(false) },
- pureBlack = pureBlack,
+ ) {
+ val searchTabs =
+ listOf(
+ SearchSource.LOCAL to R.string.search_library,
+ SearchSource.ONLINE to R.string.search_yt_music,
+ )
+ PrimaryTabRow(
+ selectedTabIndex =
+ searchTabs.indexOfFirst { (source, _) ->
+ source == searchSource
+ },
+ ) {
+ searchTabs.forEach { (source, titleResId) ->
+ Tab(
+ selected = searchSource == source,
+ onClick = { searchSource = source },
+ text = { Text(stringResource(titleResId)) },
)
}
+ }
+ Crossfade(
+ targetState = searchSource,
+ animationSpec = tween(durationMillis = if (disableAnimations) 0 else 300),
+ label = "",
+ modifier = Modifier.weight(1f),
+ ) { searchSource ->
+ when (searchSource) {
+ SearchSource.LOCAL -> {
+ LocalSearchScreen(
+ query = query.text,
+ navController = navController,
+ onDismiss = { onActiveChange(false) },
+ pureBlack = pureBlack,
+ )
+ }
- SearchSource.ONLINE -> {
- OnlineSearchScreen(
- query = query.text,
- onQueryChange = onQueryChange,
- navController = navController,
- onSearch = {
- navController.navigate(onlineSearchResultRoute(it))
- if (!pauseSearchHistory) {
- database.query {
- insert(SearchHistory(query = it))
+ SearchSource.ONLINE -> {
+ OnlineSearchScreen(
+ query = query.text,
+ onQueryChange = onQueryChange,
+ navController = navController,
+ onSearch = {
+ navController.navigate(onlineSearchResultRoute(it))
+ if (!pauseSearchHistory) {
+ database.query {
+ insert(SearchHistory(query = it))
+ }
}
- }
- },
- onDismiss = { onActiveChange(false) },
- pureBlack = pureBlack,
- )
+ },
+ onDismiss = { onActiveChange(false) },
+ pureBlack = pureBlack,
+ )
+ }
}
}
}
@@ -2244,326 +2306,368 @@ class MainActivity : ComponentActivity() {
}
}
- BottomSheetPlayer(
- state = playerBottomSheetState,
- navController = navController,
- pureBlack = pureBlack,
- navigationProximityProvider = navigationProximityProvider,
- )
+ CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides playerBackDispatcherOwner) {
+ BottomSheetPlayer(
+ state = playerBottomSheetState,
+ navController = navController,
+ hazeState = floatingBarsHazeState,
+ pureBlack = pureBlack,
+ navigationProximityProvider = navigationProximityProvider,
+ navigationBarMode = navigationBarMode,
+ )
+ }
- if (useRail) return@Box
+ if (useRail) return@Box
- val navSlideDistance =
- bottomInset + floatingBarsBottomPadding + navVisibleHeight
+ val navSlideDistance =
+ bottomInset + floatingBarsBottomPadding + navVisibleHeight
- Box(
- modifier =
- Modifier
- .align(Alignment.BottomCenter)
- .height(navSlideDistance)
- .offset {
- if (bottomNavigationBarHeight == 0.dp) {
- IntOffset(
- x = 0,
- y = navSlideDistance.roundToPx(),
- )
- } else {
- val slideOffset =
- navSlideDistance *
- playerBottomSheetState.progress.coerceIn(
- 0f,
- 1f,
- )
- val hideOffset =
- navSlideDistance *
- (
- 1 -
- bottomNavigationBarHeight.coerceAtMost(navVisibleHeight) /
- navVisibleHeight
- )
- IntOffset(
- x = 0,
- y = (slideOffset + hideOffset).roundToPx(),
+ Box(
+ modifier =
+ Modifier
+ .align(Alignment.BottomCenter)
+ .height(navSlideDistance)
+ .offset {
+ if (bottomNavigationBarHeight == 0.dp) {
+ IntOffset(
+ x = 0,
+ y = navSlideDistance.roundToPx(),
+ )
+ } else {
+ val slideOffset =
+ navSlideDistance *
+ playerBottomSheetState.progress.coerceIn(
+ 0f,
+ 1f,
+ )
+ val hideOffset =
+ navSlideDistance *
+ (
+ 1 -
+ bottomNavigationBarHeight.coerceAtMost(navVisibleHeight) /
+ navVisibleHeight
+ )
+ IntOffset(
+ x = 0,
+ y = (slideOffset + hideOffset).roundToPx(),
+ )
+ }
+ },
+ ) {
+ FloatingNavigationToolbar(
+ items = navigationItems,
+ pureBlack = pureBlack,
+ backgroundStyle = navigationBarBackgroundStyle,
+ hazeState = floatingBarsHazeState,
+ miniPlayerProximityProvider = navigationProximityProvider,
+ navigationBarMode = navigationBarMode,
+ modifier =
+ Modifier
+ .align(Alignment.BottomCenter)
+ .padding(
+ start = NavigationBarHorizontalPadding,
+ end = NavigationBarHorizontalPadding,
+ bottom = bottomInset + floatingBarsBottomPadding,
+ ).height(navVisibleHeight),
+ isSelected = { screen ->
+ navBackStackEntry?.destination?.hierarchy?.any { it.route == screen.route } ==
+ true
+ },
+ onItemClick = { screen, isSelected ->
+ val wasPlayerActive = playerBottomSheetState.isExpandedOrExpanding
+ if (wasPlayerActive) {
+ playerBottomSheetState.collapse(
+ if (disableAnimations) snap() else spring(),
)
}
+ if (!wasPlayerActive || !isSelected) {
+ handlePrimaryNavigationClick(screen, isSelected)
+ }
},
- ) {
- FloatingNavigationToolbar(
- items = navigationItems,
- pureBlack = pureBlack,
- miniPlayerProximityProvider = navigationProximityProvider,
+ onSearchItemDoubleClick = {
+ searchSource = SearchSource.ONLINE
+ openSearch()
+ },
+ )
+ }
+
+ val homeOverflowFabBottomPadding =
+ bottomInset +
+ floatingBarsBottomPadding +
+ navVisibleHeight +
+ HomeOverflowFabSpacing +
+ if (playerBottomSheetState.isCollapsed) {
+ MiniPlayerHeight + MiniPlayerBottomSpacing
+ } else {
+ 0.dp
+ }
+ HomeOverflowFabVisibility(
+ visible = showHomeOverflowFab,
modifier =
Modifier
- .align(Alignment.BottomCenter)
+ .align(Alignment.BottomEnd)
.padding(
- start = NavigationBarHorizontalPadding,
end = NavigationBarHorizontalPadding,
- bottom = bottomInset + floatingBarsBottomPadding,
- ).height(navVisibleHeight),
- isSelected = { screen ->
- navBackStackEntry?.destination?.hierarchy?.any { it.route == screen.route } ==
- true
- },
- onItemClick = { screen, isSelected ->
- handlePrimaryNavigationClick(screen, isSelected)
- },
- onSearchItemDoubleClick = {
- searchSource = SearchSource.ONLINE
- openSearch()
- },
- )
- }
+ bottom = homeOverflowFabBottomPadding,
+ ),
+ ) {
+ HomeOverflowFab(
+ expanded = homeOverflowMenuExpanded,
+ pureBlack = pureBlack,
+ onExpandedChange = { homeOverflowMenuExpanded = it },
+ onShuffleClick = {
+ val useLocalSource =
+ when {
+ allLocalItems.isNotEmpty() && allYtItems.isNotEmpty() -> {
+ Random.nextFloat() < 0.5f
+ }
- val homeOverflowFabBottomPadding =
- bottomInset +
- floatingBarsBottomPadding +
- navVisibleHeight +
- HomeOverflowFabSpacing +
- if (playerBottomSheetState.isCollapsed) {
- MiniPlayerHeight + MiniPlayerBottomSpacing
- } else {
- 0.dp
- }
- HomeOverflowFabVisibility(
- visible = showHomeOverflowFab,
- modifier =
- Modifier
- .align(Alignment.BottomEnd)
- .padding(
- end = NavigationBarHorizontalPadding,
- bottom = homeOverflowFabBottomPadding,
- ),
- ) {
- HomeOverflowFab(
- expanded = homeOverflowMenuExpanded,
- pureBlack = pureBlack,
- onExpandedChange = { homeOverflowMenuExpanded = it },
- onShuffleClick = {
- val useLocalSource =
- when {
- allLocalItems.isNotEmpty() && allYtItems.isNotEmpty() -> {
- Random.nextFloat() < 0.5f
- }
+ allLocalItems.isNotEmpty() -> {
+ true
+ }
- allLocalItems.isNotEmpty() -> {
- true
+ else -> {
+ false
+ }
}
- else -> {
- false
- }
- }
+ coroutineScope.launch(Dispatchers.Main) {
+ if (useLocalSource) {
+ when (val luckyItem = allLocalItems.random()) {
+ is Song -> {
+ playerConnection?.playQueue(
+ if (luckyItem.song.isLocal) {
+ ListQueue(items = listOf(luckyItem.toMediaItem()))
+ } else {
+ YouTubeQueue.radio(luckyItem.toMediaMetadata())
+ },
+ )
+ }
- coroutineScope.launch(Dispatchers.Main) {
- if (useLocalSource) {
- when (val luckyItem = allLocalItems.random()) {
- is Song -> {
- playerConnection?.playQueue(
- if (luckyItem.song.isLocal) {
- ListQueue(items = listOf(luckyItem.toMediaItem()))
- } else {
- YouTubeQueue.radio(luckyItem.toMediaMetadata())
- },
- )
- }
+ is Album -> {
+ val albumWithSongs =
+ withContext(Dispatchers.IO) {
+ database.albumWithSongs(luckyItem.id).first()
+ }
- is Album -> {
- val albumWithSongs =
- withContext(Dispatchers.IO) {
- database.albumWithSongs(luckyItem.id).first()
+ albumWithSongs?.let {
+ playerConnection?.playQueue(LocalAlbumRadio(it))
}
-
- albumWithSongs?.let {
- playerConnection?.playQueue(LocalAlbumRadio(it))
}
- }
- is Artist -> {
- Unit
- }
+ is Artist -> {
+ Unit
+ }
- is Playlist -> {
- Unit
- }
- }
- } else {
- when (val luckyItem = allYtItems.random()) {
- is SongItem -> {
- playerConnection?.playQueue(
- YouTubeQueue.radio(luckyItem.toMediaMetadata()),
- )
+ is Playlist -> {
+ Unit
+ }
}
+ } else {
+ when (val luckyItem = allYtItems.random()) {
+ is SongItem -> {
+ playerConnection?.playQueue(
+ YouTubeQueue.radio(luckyItem.toMediaMetadata()),
+ )
+ }
- is AlbumItem -> {
- playerConnection?.playQueue(
- YouTubeAlbumRadio(luckyItem.playlistId),
- )
- }
+ is AlbumItem -> {
+ playerConnection?.playQueue(
+ YouTubeAlbumRadio(luckyItem.playlistId),
+ )
+ }
- is ArtistItem -> {
- luckyItem.radioEndpoint?.let {
- playerConnection?.playQueue(YouTubeQueue(it))
+ is ArtistItem -> {
+ luckyItem.radioEndpoint?.let {
+ playerConnection?.playQueue(YouTubeQueue(it))
+ }
}
- }
- is PlaylistItem -> {
- luckyItem.playEndpoint?.let {
- playerConnection?.playQueue(YouTubeQueue.playlist(it))
+ is PlaylistItem -> {
+ luckyItem.playEndpoint?.let {
+ playerConnection?.playQueue(YouTubeQueue.playlist(it))
+ }
}
- }
- is PodcastItem -> {
- navController.navigate("podcast/${Uri.encode(luckyItem.browseId)}")
- }
+ is PodcastItem -> {
+ navController.navigate("podcast/${Uri.encode(luckyItem.browseId)}")
+ }
- is EpisodeItem -> {
- playerConnection?.playQueue(
- ListQueue(
- title = luckyItem.podcast?.name ?: luckyItem.title,
- items = listOf(luckyItem.toMediaItem()),
- ),
- )
+ is EpisodeItem -> {
+ playerConnection?.playQueue(
+ ListQueue(
+ title = luckyItem.podcast?.name ?: luckyItem.title,
+ items = listOf(luckyItem.toMediaItem()),
+ ),
+ )
+ }
}
}
}
- }
- },
- onMusicRecognitionClick = {
- navController.navigate(MusicRecognitionRoute)
- },
- onMusicTogetherClick = {
- navController.navigate("settings/music_together")
- },
- )
+ },
+ onMusicRecognitionClick = {
+ navController.navigate(MusicRecognitionRoute)
+ },
+ onMusicTogetherClick = {
+ navController.navigate("settings/music_together")
+ },
+ )
+ }
}
- }
- },
- modifier = Modifier.fillMaxSize(),
- ) {
- var transitionDirection =
- AnimatedContentTransitionScope.SlideDirection.Left
-
- if (navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route }) {
- if (navigationItems.fastAny { it.route == previousTab }) {
- val curIndex =
- navigationItems.indexOf(
- navigationItems.fastFirstOrNull {
- it.route == navBackStackEntry?.destination?.route
- },
- )
+ },
+ modifier = Modifier.fillMaxSize().hazeSource(menuHazeState),
+ ) {
+ var transitionDirection =
+ AnimatedContentTransitionScope.SlideDirection.Left
+
+ if (navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route }) {
+ if (navigationItems.fastAny { it.route == previousTab }) {
+ val curIndex =
+ navigationItems.indexOf(
+ navigationItems.fastFirstOrNull {
+ it.route == navBackStackEntry?.destination?.route
+ },
+ )
- val prevIndex =
- navigationItems.indexOf(
- navigationItems.fastFirstOrNull {
- it.route == previousTab
- },
- )
+ val prevIndex =
+ navigationItems.indexOf(
+ navigationItems.fastFirstOrNull {
+ it.route == previousTab
+ },
+ )
- if (prevIndex > curIndex) {
- AnimatedContentTransitionScope.SlideDirection.Right.also {
- transitionDirection = it
+ if (prevIndex > curIndex) {
+ AnimatedContentTransitionScope.SlideDirection.Right.also {
+ transitionDirection = it
+ }
}
}
}
- }
- NavHost(
- navController = navController,
- startDestination =
- if (launchMusicRecognitionFromShortcut) {
- MusicRecognitionRoute
- } else {
- when (tabOpenedFromShortcut ?: defaultOpenTab) {
- NavigationTab.HOME -> Screens.Home.route
- NavigationTab.LIBRARY -> Screens.Library.route
- else -> Screens.Home.route
+ val backEnterTransition: AnimatedContentTransitionScope.() -> EnterTransition =
+ remember(disableAnimations, navigationItems) {
+ {
+ if (disableAnimations) {
+ fadeIn(tween(0))
+ } else if ((
+ navigationItems.fastAny { it.route == initialState.destination.route } ||
+ initialState.destination.route?.startsWith(OnlineSearchResultRoutePrefix) == true
+ ) &&
+ navigationItems.fastAny { it.route == targetState.destination.route }
+ ) {
+ fadeIn(tween(250))
+ } else {
+ slideInHorizontally(animationSpec = tween(250)) { -it }
+ }
}
- },
- enterTransition = {
- if (disableAnimations) {
- fadeIn(tween(0))
- } else if (initialState.destination.route in topLevelScreens &&
- targetState.destination.route in topLevelScreens
- ) {
- fadeIn(tween(250))
- } else {
- fadeIn(tween(250)) + slideInHorizontally { it / 2 }
- }
- },
- exitTransition = {
- if (disableAnimations) {
- fadeOut(tween(0))
- } else if (initialState.destination.route in topLevelScreens &&
- targetState.destination.route in topLevelScreens
- ) {
- fadeOut(tween(200))
- } else {
- fadeOut(tween(200)) + slideOutHorizontally { -it / 2 }
- }
- },
- popEnterTransition = {
- if (disableAnimations) {
- fadeIn(tween(0))
- } else if ((
- initialState.destination.route in topLevelScreens ||
- initialState.destination.route?.startsWith(OnlineSearchResultRoutePrefix) == true
- ) &&
- targetState.destination.route in topLevelScreens
- ) {
- fadeIn(tween(250))
- } else {
- fadeIn(tween(250)) + slideInHorizontally { -it / 2 }
}
- },
- popExitTransition = {
- if (disableAnimations) {
- fadeOut(tween(0))
- } else if ((
- initialState.destination.route in topLevelScreens ||
- initialState.destination.route?.startsWith(OnlineSearchResultRoutePrefix) == true
- ) &&
- targetState.destination.route in topLevelScreens
- ) {
- fadeOut(tween(200))
- } else {
- fadeOut(tween(200)) + slideOutHorizontally { it / 2 }
+ val backExitTransition: AnimatedContentTransitionScope.() -> ExitTransition =
+ remember(disableAnimations, navigationItems) {
+ {
+ if (disableAnimations) {
+ fadeOut(tween(0))
+ } else if ((
+ navigationItems.fastAny { it.route == initialState.destination.route } ||
+ initialState.destination.route?.startsWith(OnlineSearchResultRoutePrefix) == true
+ ) &&
+ navigationItems.fastAny { it.route == targetState.destination.route }
+ ) {
+ fadeOut(tween(200))
+ } else {
+ slideOutHorizontally(animationSpec = tween(200)) { it }
+ }
+ }
}
- },
- modifier =
- Modifier
- .then(
- if (isTvDevice) {
- Modifier
- .focusRequester(contentAreaFocusRequester)
- .focusGroup()
- .focusable()
+
+ NavHost(
+ navController = navController,
+ startDestination =
+ if (launchMusicRecognitionFromShortcut) {
+ MusicRecognitionRoute
+ } else {
+ when (tabOpenedFromShortcut ?: defaultOpenTab) {
+ NavigationTab.HOME -> Screens.Home.route
+ NavigationTab.LIBRARY -> Screens.Library.route
+ else -> Screens.Home.route
+ }
+ },
+ enterTransition = {
+ if (disableAnimations) {
+ fadeIn(tween(0))
+ } else if (initialState.destination.route in topLevelScreens &&
+ targetState.destination.route in topLevelScreens
+ ) {
+ fadeIn(tween(250))
+ } else {
+ fadeIn(tween(250)) + slideInHorizontally(animationSpec = tween(250)) { it }
+ }
+ },
+ exitTransition = {
+ if (disableAnimations) {
+ fadeOut(tween(0))
+ } else if (initialState.destination.route in topLevelScreens &&
+ targetState.destination.route in topLevelScreens
+ ) {
+ fadeOut(tween(200))
+ } else {
+ fadeOut(tween(200)) + slideOutHorizontally(animationSpec = tween(200)) { -it }
+ }
+ },
+ popEnterTransition = backEnterTransition,
+ popExitTransition = backExitTransition,
+ predictivePopEnterTransition = { _ ->
+ if (targetState.destination.route?.startsWith(OnlineSearchResultRoutePrefix) == true) {
+ if (disableAnimations) {
+ fadeIn(tween(0))
+ } else if (initialState.destination.route?.startsWith(OnlineSearchResultRoutePrefix) == true) {
+ fadeIn(tween(250))
} else {
- Modifier
- },
- ).nestedScroll(
- topAppBarScrollBehavior.nestedScrollConnection,
- ),
- ) {
- navigationBuilder(
- navController,
- topAppBarScrollBehavior,
- homeViewModel,
- { latestVersionName },
- disableAnimations,
- onClearUpdateBadge = { latestVersionName = BuildConfig.VERSION_NAME },
- homeScrollConnection = homeScrollBehavior.nestedScrollConnection,
- searchScrollConnection = searchScrollBehavior.nestedScrollConnection,
- libraryScrollBehavior = libraryScrollBehavior,
- onlineSearchSort = onlineSearchSort,
- )
+ fadeIn(tween(250)) + slideInHorizontally { -it / 2 }
+ }
+ } else {
+ backEnterTransition()
+ }
+ },
+ predictivePopExitTransition = { _ ->
+ if (initialState.destination.route?.startsWith(OnlineSearchResultRoutePrefix) == true) {
+ fadeOut(tween(if (disableAnimations) 0 else 200))
+ } else {
+ backExitTransition()
+ }
+ },
+ modifier =
+ Modifier
+ .hazeSource(floatingBarsHazeState)
+ .then(
+ if (isTvDevice) {
+ Modifier
+ .focusRequester(contentAreaFocusRequester)
+ .focusGroup()
+ .focusable()
+ } else {
+ Modifier
+ },
+ ).nestedScroll(
+ topAppBarScrollBehavior.nestedScrollConnection,
+ ),
+ ) {
+ navigationBuilder(
+ navController,
+ topAppBarScrollBehavior,
+ homeViewModel,
+ { latestVersionName },
+ disableAnimations,
+ onClearUpdateBadge = { latestVersionName = BuildConfig.VERSION_NAME },
+ homeScrollConnection = homeScrollBehavior.nestedScrollConnection,
+ searchScrollConnection = searchScrollBehavior.nestedScrollConnection,
+ libraryScrollBehavior = libraryScrollBehavior,
+ onlineSearchSort = onlineSearchSort,
+ )
+ }
}
}
}
- BackHandler(enabled = playerBottomSheetState.isExpanded && !aodModeEnabled) {
- playerBottomSheetState.collapseSoft()
- }
-
BottomSheetMenu(
state = LocalMenuState.current,
modifier = Modifier.align(Alignment.BottomCenter),
@@ -2816,7 +2920,7 @@ class MainActivity : ComponentActivity() {
.albumSongs(playlistId)
.onSuccess { songs ->
songs.firstOrNull()?.album?.id?.let { browseId ->
- navController.navigate("album/$browseId")
+ navController.navigateToAlbum(browseId)
}
}.onFailure { reportException(it) }
}
@@ -2828,13 +2932,13 @@ class MainActivity : ComponentActivity() {
"browse" -> {
uri.lastPathSegment?.let { browseId ->
- navController.navigate("album/$browseId")
+ navController.navigateToAlbum(browseId)
}
}
"channel", "c" -> {
uri.lastPathSegment?.let { artistId ->
- navController.navigate("artist/$artistId")
+ navController.navigateToArtist(artistId)
}
}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ai/AiIntegrationSettingsRepository.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ai/AiIntegrationSettingsRepository.kt
index c39be2dbfb..32e7573d91 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ai/AiIntegrationSettingsRepository.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ai/AiIntegrationSettingsRepository.kt
@@ -22,6 +22,7 @@ import moe.rukamori.archivetune.constants.AiCustomModelKey
import moe.rukamori.archivetune.constants.AiCustomPromptKey
import moe.rukamori.archivetune.constants.AiProvider
import moe.rukamori.archivetune.constants.AiProviderKey
+import moe.rukamori.archivetune.constants.AiRequestParametersKey
import moe.rukamori.archivetune.constants.AiSelectedModelKey
import moe.rukamori.archivetune.extensions.toEnum
import moe.rukamori.archivetune.utils.dataStore
@@ -36,6 +37,7 @@ data class AiIntegrationSettingsData(
val selectedModel: String,
val customModel: String,
val customPrompt: String,
+ val requestParameters: String,
) {
fun toServiceConfig(): AiServiceConfig =
AiServiceConfig(
@@ -43,6 +45,7 @@ data class AiIntegrationSettingsData(
apiKey = apiKey,
customEndpoint = customEndpoint,
model = if (provider == AiProvider.CUSTOM) customModel else selectedModel,
+ requestParameters = requestParameters,
)
}
@@ -103,10 +106,23 @@ class AiIntegrationSettingsRepository
}
}
- suspend fun setValidationStatus(status: AiApiValidationStatus) {
+ suspend fun setRequestParameters(value: String) {
+ val normalized = AiRequestParameters.normalize(value)
context.dataStore.edit { preferences ->
- preferences[AiApiValidationStatusKey] = status.name
+ preferences[AiRequestParametersKey] = normalized
+ preferences[AiApiValidationStatusKey] = AiApiValidationStatus.UNKNOWN.name
+ }
+ }
+
+ suspend fun setValidationStatus(status: AiApiValidationStatus, config: AiServiceConfig): Boolean {
+ var updated = false
+ context.dataStore.edit { preferences ->
+ if (settingsFromPreferences(preferences).toServiceConfig() == config) {
+ preferences[AiApiValidationStatusKey] = status.name
+ updated = true
+ }
}
+ return updated
}
suspend fun fetchModels(config: AiServiceConfig): List =
@@ -126,5 +142,6 @@ class AiIntegrationSettingsRepository
selectedModel = preferences[AiSelectedModelKey].orEmpty(),
customModel = preferences[AiCustomModelKey].orEmpty(),
customPrompt = preferences[AiCustomPromptKey].orEmpty(),
+ requestParameters = preferences[AiRequestParametersKey].orEmpty(),
)
}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ai/AiIntegrationSettingsUseCases.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ai/AiIntegrationSettingsUseCases.kt
index fca889882a..3e7da4a281 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ai/AiIntegrationSettingsUseCases.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ai/AiIntegrationSettingsUseCases.kt
@@ -49,6 +49,10 @@ class UpdateAiIntegrationSettingsUseCase
suspend fun setCustomPrompt(prompt: String) {
repository.setCustomPrompt(prompt)
}
+
+ suspend fun setRequestParameters(value: String) {
+ repository.setRequestParameters(value)
+ }
}
class FetchAiModelsUseCase
@@ -65,15 +69,16 @@ class TestAiIntegrationUseCase
constructor(
private val repository: AiIntegrationSettingsRepository,
) {
- suspend operator fun invoke() {
+ suspend operator fun invoke(): Boolean {
+ val config = repository.currentSettings().toServiceConfig()
try {
- repository.test(repository.currentSettings().toServiceConfig())
- repository.setValidationStatus(AiApiValidationStatus.SUCCESS)
+ repository.test(config)
+ return repository.setValidationStatus(AiApiValidationStatus.SUCCESS, config)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
- repository.setValidationStatus(AiApiValidationStatus.FAILED)
- throw e
+ if (repository.setValidationStatus(AiApiValidationStatus.FAILED, config)) throw e
+ return false
}
}
}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ai/AiModels.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ai/AiModels.kt
index f8272619d6..07fa535fcf 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ai/AiModels.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ai/AiModels.kt
@@ -22,6 +22,7 @@ data class AiServiceConfig(
val apiKey: String,
val customEndpoint: String,
val model: String,
+ val requestParameters: String = "",
) {
val canCallApi: Boolean
get() =
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ai/AiRequestParameters.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ai/AiRequestParameters.kt
new file mode 100644
index 0000000000..38bf4b2ff5
--- /dev/null
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ai/AiRequestParameters.kt
@@ -0,0 +1,61 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.ai
+
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import org.json.JSONObject
+
+object AiRequestParameters {
+ private val reservedNames = setOf("model", "messages", "stream", "n")
+
+ fun normalize(value: String): String {
+ if (value.isBlank()) return ""
+ require(value.length <= 16_384) { "Request parameters are too large" }
+ val parameters = Json.parseToJsonElement(value) as? JsonObject
+ ?: throw IllegalArgumentException("Request parameters must be a JSON object")
+ require(parameters.keys.none { it.isBlank() || it in reservedNames }) {
+ "Request parameters cannot override model, messages, stream, or n"
+ }
+ for (name in listOf("max_tokens", "max_completion_tokens")) {
+ val parameter = parameters[name] ?: continue
+ if (parameter == JsonNull) continue
+ val primitive = parameter as? JsonPrimitive
+ require(primitive != null && !primitive.isString && primitive.content.toLongOrNull()?.let { it > 0 } == true) {
+ "$name must be a positive integer or null"
+ }
+ }
+ require(parameters["max_tokens"].let { it == null || it == JsonNull } ||
+ parameters["max_completion_tokens"].let { it == null || it == JsonNull }) {
+ "Use only one token limit parameter"
+ }
+ for ((name, maximum) in listOf("temperature" to 2.0, "top_p" to 1.0)) {
+ val parameter = parameters[name] ?: continue
+ if (parameter == JsonNull) continue
+ val primitive = parameter as? JsonPrimitive
+ val number = primitive?.takeUnless { it.isString }?.content?.toDoubleOrNull()
+ require(number != null && number.isFinite() && number in 0.0..maximum) {
+ "$name is outside its supported range"
+ }
+ }
+ return parameters.toString()
+ }
+
+ internal fun applyTo(body: JSONObject, value: String) {
+ val normalized = normalize(value)
+ if (normalized.isEmpty()) return
+ val parameters = JSONObject(normalized)
+ if (parameters.has("max_tokens") && !parameters.isNull("max_tokens")) body.remove("max_completion_tokens")
+ if (parameters.has("max_completion_tokens") && !parameters.isNull("max_completion_tokens")) body.remove("max_tokens")
+ parameters.keys().forEach { name ->
+ if (parameters.isNull(name)) body.remove(name) else body.put(name, parameters.get(name))
+ }
+ }
+}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ai/AiTextService.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ai/AiTextService.kt
index 41febc8940..915ee5975d 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ai/AiTextService.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ai/AiTextService.kt
@@ -17,6 +17,7 @@ import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import moe.rukamori.archivetune.constants.AiProvider
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.TimeUnit
@@ -52,7 +53,7 @@ object AiTextService {
systemPrompt = "You are a health check endpoint. Reply with OK only.",
userPrompt = "Reply exactly OK.",
temperature = 0.0,
- maxTokens = 32,
+ maxTokens = 8192,
).trim()
if (!response.contains("OK", ignoreCase = true)) {
throw AiServiceException("AI API returned an unexpected test response")
@@ -88,7 +89,10 @@ object AiTextService {
)
val array = extractJsonArray(response)
require(array.length() == lines.size) { "AI response changed the lyric segment count" }
- return List(array.length()) { index -> array.optString(index) }
+ return List(array.length()) { index ->
+ array.get(index) as? String
+ ?: throw AiServiceException("AI response contained a non-text lyric segment")
+ }
}
private fun String.appendCustomPrompt(customPrompt: String): String {
@@ -115,6 +119,7 @@ object AiTextService {
userPrompt = userPrompt,
temperature = temperature,
maxTokens = maxTokens,
+ requestParameters = config.requestParameters,
)
}
@@ -127,6 +132,7 @@ object AiTextService {
userPrompt = userPrompt,
temperature = temperature,
maxTokens = maxTokens,
+ requestParameters = config.requestParameters,
)
}
@@ -139,6 +145,7 @@ object AiTextService {
userPrompt = userPrompt,
temperature = temperature,
maxTokens = maxTokens,
+ requestParameters = config.requestParameters,
)
}
@@ -178,35 +185,74 @@ object AiTextService {
userPrompt: String,
temperature: Double,
maxTokens: Int,
+ requestParameters: String,
): String {
val messages =
JSONArray()
.put(JSONObject().put("role", "system").put("content", systemPrompt))
.put(JSONObject().put("role", "user").put("content", userPrompt))
+ val reasoningModel = isReasoningModel(model)
val body =
JSONObject()
- .put("model", model)
+ .put("model", model.trim())
.put("messages", messages)
- .put("temperature", temperature)
- .put("max_tokens", maxTokens)
- .toString()
+ .put(if (reasoningModel) "max_completion_tokens" else "max_tokens", maxTokens)
+ if (!reasoningModel) body.put("temperature", temperature)
+ AiRequestParameters.applyTo(body, requestParameters)
val response =
- client.post(endpoint.trim()) {
+ client.post(chatCompletionEndpoint(endpoint)) {
header("Authorization", "Bearer ${apiKey.trim()}")
contentType(ContentType.Application.Json)
- setBody(body)
+ setBody(body.toString())
}
val raw = response.bodyAsText()
if (response.status.value !in 200..299) throw apiException(response.status.value, raw)
val json = JSONObject(raw)
- val content =
- json
- .optJSONArray("choices")
- ?.optJSONObject(0)
- ?.optJSONObject("message")
- ?.optString("content")
- ?.takeIf { it.isNotBlank() }
- return content ?: throw AiServiceException("AI API returned an empty response")
+ val choice = json.optJSONArray("choices")?.optJSONObject(0)
+ ?: throw AiServiceException("AI API returned no completion choices")
+ when (choice.optString("finish_reason")) {
+ "length" -> throw AiServiceException("AI response exceeded the token limit. Increase the token limit in request parameters.")
+ "content_filter" -> throw AiServiceException("AI provider filtered the response")
+ }
+ val message = choice.optJSONObject("message")
+ ?: throw AiServiceException("AI API returned no completion message")
+ val content = when (val value = message.opt("content")) {
+ is String -> value
+ is JSONArray -> buildString {
+ for (index in 0 until value.length()) {
+ val part = value.optJSONObject(index) ?: continue
+ if (part.optString("type") == "text") append(part.optString("text", ""))
+ }
+ }
+ else -> ""
+ }
+ if (content.isBlank()) {
+ if (!message.isNull("refusal")) throw AiServiceException("AI provider refused the request")
+ throw AiServiceException("AI API returned no text. Check the model and its token or reasoning parameters.")
+ }
+ return content
+ }
+
+ private fun isReasoningModel(model: String): Boolean {
+ val name = model.trim().substringAfterLast('/').lowercase()
+ if (name.startsWith("gpt-")) {
+ return name.removePrefix("gpt-").takeWhile(Char::isDigit).toIntOrNull()?.let { it >= 5 } == true
+ }
+ return name.startsWith('o') && name.drop(1).substringBefore('-').toIntOrNull()?.let { it > 0 } == true
+ }
+
+ private fun chatCompletionEndpoint(endpoint: String): String {
+ val url = endpoint.trim().toHttpUrlOrNull()
+ ?: throw AiServiceException("AI endpoint must be an HTTP or HTTPS URL")
+ if (url.username.isNotEmpty() || url.password.isNotEmpty() || url.fragment != null) {
+ throw AiServiceException("AI endpoint cannot contain credentials or a fragment")
+ }
+ val path = url.encodedPath.trimEnd('/')
+ return when {
+ path.isEmpty() -> url.newBuilder().encodedPath("/v1/chat/completions").build().toString()
+ path.endsWith("/v1") -> url.newBuilder().encodedPath("$path/chat/completions").build().toString()
+ else -> url.toString()
+ }
}
private suspend fun completeGemini(
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/appicon/AppIconRepository.kt b/app/src/main/kotlin/moe/rukamori/archivetune/appicon/AppIconRepository.kt
index ec396bc34a..b399a9a369 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/appicon/AppIconRepository.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/appicon/AppIconRepository.kt
@@ -15,6 +15,8 @@ import androidx.annotation.DrawableRes
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -28,7 +30,6 @@ data class AppIcon(
val author: String?,
val githubAuthorUrl: String?,
@DrawableRes val previewDrawableResId: Int,
- val aliasClassName: String,
val isDefault: Boolean,
)
@@ -48,6 +49,12 @@ private data class GeneratedAppIcon(
val aliasClassName: String,
)
+private data class LauncherAlias(
+ val id: String,
+ val componentName: ComponentName,
+ val isDefault: Boolean,
+)
+
@Singleton
class AppIconRepository
@Inject
@@ -56,63 +63,76 @@ class AppIconRepository
) {
private val packageManager: PackageManager = context.packageManager
private val json = Json { ignoreUnknownKeys = true }
+ private val stateLock = Mutex()
suspend fun loadCatalog(): AppIconCatalog =
withContext(Dispatchers.IO) {
- val icons = loadIcons()
- val selectedIcon = findSelectedIcon(icons)
- if (!isSelectionApplied(icons, selectedIcon)) {
- applySelection(icons, selectedIcon)
+ stateLock.withLock {
+ val generatedIcons = readGeneratedIcons()
+ val selectedAlias = reconcileLauncherEntry(launcherAliases(generatedIcons))
+ AppIconCatalog(
+ icons = appIcons(generatedIcons),
+ selectedIconId = selectedAlias.id,
+ )
+ }
+ }
+
+ suspend fun restoreLauncherEntry() {
+ withContext(Dispatchers.IO) {
+ stateLock.withLock {
+ reconcileLauncherEntry(launcherAliases(readGeneratedIcons()))
}
- AppIconCatalog(
- icons = icons,
- selectedIconId = selectedIcon.id,
- )
}
+ }
suspend fun selectIcon(iconId: String): AppIconCatalog =
withContext(Dispatchers.IO + NonCancellable) {
- val icons = loadIcons()
- val selectedIcon =
- icons.firstOrNull { icon -> icon.id == iconId }
- ?: throw IllegalArgumentException("Unknown app icon ID.")
- if (!isSelectionApplied(icons, selectedIcon)) {
- applySelection(icons, selectedIcon)
+ stateLock.withLock {
+ val generatedIcons = readGeneratedIcons()
+ val aliases = launcherAliases(generatedIcons)
+ val selectedAlias =
+ aliases.firstOrNull { alias -> alias.id == iconId }
+ ?: throw IllegalArgumentException("Unknown app icon ID.")
+ if (!isSelectionApplied(aliases, selectedAlias)) {
+ applySelection(aliases, selectedAlias)
+ }
+ AppIconCatalog(
+ icons = appIcons(generatedIcons),
+ selectedIconId = selectedAlias.id,
+ )
}
- AppIconCatalog(
- icons = icons,
- selectedIconId = selectedIcon.id,
- )
}
- private fun loadIcons(): List {
- val generatedIcons =
- context.assets
- .open(CatalogAssetPath)
- .bufferedReader()
- .use { reader -> json.decodeFromString>(reader.readText()) }
- .map { generated ->
- val drawableResId =
- context.resources.getIdentifier(
- generated.drawableResourceName,
- "drawable",
+ private fun readGeneratedIcons(): List =
+ context.assets
+ .open(CatalogAssetPath)
+ .bufferedReader()
+ .use { reader -> json.decodeFromString>(reader.readText()) }
+
+ private fun launcherAliases(generatedIcons: List): List =
+ buildList(generatedIcons.size + 1) {
+ add(
+ LauncherAlias(
+ id = DefaultIconId,
+ componentName =
+ ComponentName(
context.packageName,
- )
- check(drawableResId != 0) {
- "Missing generated drawable ${generated.drawableResourceName} for ${generated.source}."
- }
- AppIcon(
- id = generated.id,
- name = generated.name,
- author = generated.author,
- githubAuthorUrl = generated.githubAuthorUrl.takeIf(String::isNotBlank),
- previewDrawableResId = drawableResId,
- aliasClassName = generated.aliasClassName,
- isDefault = false,
- )
- }
+ "${context.packageName}.launcher.DefaultIconAlias",
+ ),
+ isDefault = true,
+ ),
+ )
+ generatedIcons.mapTo(this) { generated ->
+ LauncherAlias(
+ id = generated.id,
+ componentName = ComponentName(context.packageName, generated.aliasClassName),
+ isDefault = false,
+ )
+ }
+ }
- return buildList(generatedIcons.size + 1) {
+ private fun appIcons(generatedIcons: List): List =
+ buildList(generatedIcons.size + 1) {
add(
AppIcon(
id = DefaultIconId,
@@ -120,45 +140,71 @@ class AppIconRepository
author = null,
githubAuthorUrl = null,
previewDrawableResId = R.drawable.app_icon_small,
- aliasClassName = "${context.packageName}.launcher.DefaultIconAlias",
isDefault = true,
),
)
- addAll(generatedIcons)
+ generatedIcons.mapTo(this) { generated -> communityAppIcon(generated) }
+ }
+
+ private fun communityAppIcon(generated: GeneratedAppIcon): AppIcon {
+ val drawableResId =
+ context.resources.getIdentifier(
+ generated.drawableResourceName,
+ "drawable",
+ context.packageName,
+ )
+ check(drawableResId != 0) {
+ "Missing generated drawable ${generated.drawableResourceName} for ${generated.source}."
}
+ return AppIcon(
+ id = generated.id,
+ name = generated.name,
+ author = generated.author,
+ githubAuthorUrl = generated.githubAuthorUrl.takeIf(String::isNotBlank),
+ previewDrawableResId = drawableResId,
+ isDefault = false,
+ )
}
- private fun findSelectedIcon(icons: List): AppIcon =
- icons.firstOrNull { icon ->
- packageManager.getComponentEnabledSetting(icon.componentName()) ==
+ private fun reconcileLauncherEntry(aliases: List): LauncherAlias {
+ val selectedAlias = findSelectedAlias(aliases)
+ if (!isSelectionApplied(aliases, selectedAlias)) {
+ applySelection(aliases, selectedAlias)
+ }
+ return selectedAlias
+ }
+
+ private fun findSelectedAlias(aliases: List): LauncherAlias =
+ aliases.firstOrNull { alias ->
+ packageManager.getComponentEnabledSetting(alias.componentName) ==
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
}
- ?: icons.first { icon -> icon.id == DefaultIconId }
+ ?: aliases.first(LauncherAlias::isDefault)
private fun isSelectionApplied(
- icons: List,
- selectedIcon: AppIcon,
+ aliases: List,
+ selectedAlias: LauncherAlias,
): Boolean =
- icons.count(::isEffectivelyEnabled) == 1 &&
- isEffectivelyEnabled(selectedIcon)
+ aliases.count(::isEffectivelyEnabled) == 1 &&
+ isEffectivelyEnabled(selectedAlias)
- private fun isEffectivelyEnabled(icon: AppIcon): Boolean =
- when (packageManager.getComponentEnabledSetting(icon.componentName())) {
+ private fun isEffectivelyEnabled(alias: LauncherAlias): Boolean =
+ when (packageManager.getComponentEnabledSetting(alias.componentName)) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> true
- PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -> icon.isDefault
+ PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -> alias.isDefault
else -> false
}
private fun applySelection(
- icons: List,
- selectedIcon: AppIcon,
+ aliases: List,
+ selectedAlias: LauncherAlias,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.setComponentEnabledSettings(
- icons.map { icon ->
+ aliases.map { alias ->
PackageManager.ComponentEnabledSetting(
- icon.componentName(),
- if (icon.id == selectedIcon.id) {
+ alias.componentName,
+ if (alias.id == selectedAlias.id) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
@@ -169,30 +215,30 @@ class AppIconRepository
)
} else {
val previousStates =
- icons.associateWith { icon ->
- packageManager.getComponentEnabledSetting(icon.componentName())
+ aliases.associateWith { alias ->
+ packageManager.getComponentEnabledSetting(alias.componentName)
}
try {
packageManager.setComponentEnabledSetting(
- selectedIcon.componentName(),
+ selectedAlias.componentName,
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
PackageManager.DONT_KILL_APP,
)
- icons
+ aliases
.asSequence()
- .filterNot { icon -> icon.id == selectedIcon.id }
- .forEach { icon ->
+ .filterNot { alias -> alias.id == selectedAlias.id }
+ .forEach { alias ->
packageManager.setComponentEnabledSetting(
- icon.componentName(),
+ alias.componentName,
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP,
)
}
} catch (error: RuntimeException) {
- previousStates.forEach { (icon, state) ->
+ previousStates.forEach { (alias, state) ->
runCatching {
packageManager.setComponentEnabledSetting(
- icon.componentName(),
+ alias.componentName,
state,
PackageManager.DONT_KILL_APP,
)
@@ -202,13 +248,11 @@ class AppIconRepository
}
}
- check(isSelectionApplied(icons, selectedIcon)) {
- "Unable to apply launcher icon ${selectedIcon.id} exclusively."
+ check(isSelectionApplied(aliases, selectedAlias)) {
+ "Unable to apply launcher icon ${selectedAlias.id} exclusively."
}
}
- private fun AppIcon.componentName(): ComponentName = ComponentName(context.packageName, aliasClassName)
-
private companion object {
const val CatalogAssetPath = "icon_pack/catalog.json"
const val DefaultIconId = "default"
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/appicon/AppIconUseCases.kt b/app/src/main/kotlin/moe/rukamori/archivetune/appicon/AppIconUseCases.kt
index d2d888dd4a..dbaa7e33bb 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/appicon/AppIconUseCases.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/appicon/AppIconUseCases.kt
@@ -24,3 +24,11 @@ class SelectAppIconUseCase
) {
suspend operator fun invoke(iconId: String): AppIconCatalog = repository.selectIcon(iconId)
}
+
+class RestoreLauncherEntryUseCase
+ @Inject
+ constructor(
+ private val repository: AppIconRepository,
+ ) {
+ suspend operator fun invoke() = repository.restoreLauncherEntry()
+ }
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/appicon/LauncherEntryReceiver.kt b/app/src/main/kotlin/moe/rukamori/archivetune/appicon/LauncherEntryReceiver.kt
new file mode 100644
index 0000000000..e1d933dd8a
--- /dev/null
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/appicon/LauncherEntryReceiver.kt
@@ -0,0 +1,53 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.appicon
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import moe.rukamori.archivetune.utils.reportException
+
+class LauncherEntryReceiver : BroadcastReceiver() {
+ override fun onReceive(
+ context: Context,
+ intent: Intent,
+ ) {
+ if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
+
+ val restoreLauncherEntry =
+ EntryPointAccessors
+ .fromApplication(context.applicationContext, LauncherEntryReceiverEntryPoint::class.java)
+ .restoreLauncherEntry()
+ val pendingResult = goAsync()
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ restoreLauncherEntry()
+ } catch (cancellation: CancellationException) {
+ throw cancellation
+ } catch (error: Exception) {
+ reportException(error)
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+}
+
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface LauncherEntryReceiverEntryPoint {
+ fun restoreLauncherEntry(): RestoreLauncherEntryUseCase
+}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/auth/ObserveMenuProfileUseCase.kt b/app/src/main/kotlin/moe/rukamori/archivetune/auth/ObserveMenuProfileUseCase.kt
new file mode 100644
index 0000000000..428eae90ee
--- /dev/null
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/auth/ObserveMenuProfileUseCase.kt
@@ -0,0 +1,34 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.auth
+
+import androidx.compose.runtime.Immutable
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+
+@Immutable
+data class MenuProfile(
+ val name: String,
+ val email: String,
+)
+
+class ObserveMenuProfileUseCase
+ @Inject
+ constructor(
+ private val repository: YouTubeLoginRepository,
+ ) {
+ operator fun invoke(): Flow =
+ repository.observeAccountIdentity().map { identity ->
+ if (identity.isLoggedIn) {
+ MenuProfile(name = identity.name.trim(), email = identity.email.trim())
+ } else {
+ null
+ }
+ }
+ }
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/auth/YouTubeLoginRepository.kt b/app/src/main/kotlin/moe/rukamori/archivetune/auth/YouTubeLoginRepository.kt
index 087473823f..f927a2761c 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/auth/YouTubeLoginRepository.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/auth/YouTubeLoginRepository.kt
@@ -12,7 +12,10 @@ import androidx.datastore.preferences.core.edit
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import moe.rukamori.archivetune.constants.AccountChannelHandleKey
import moe.rukamori.archivetune.constants.AccountEmailKey
@@ -47,6 +50,12 @@ data class YouTubeLoginSession(
val accountChannelHandle: String,
)
+data class AccountIdentity(
+ val isLoggedIn: Boolean,
+ val name: String,
+ val email: String,
+)
+
class MissingYouTubeDataSyncIdException : IllegalStateException("YouTube DataSyncId is missing")
@Singleton
@@ -55,6 +64,16 @@ class YouTubeLoginRepository
constructor(
@ApplicationContext private val context: Context,
) {
+ fun observeAccountIdentity(): Flow =
+ context.dataStore.data
+ .map { preferences ->
+ AccountIdentity(
+ isLoggedIn = hasYouTubeLoginCookie(preferences[InnerTubeCookieKey]),
+ name = preferences[AccountNameKey].orEmpty(),
+ email = preferences[AccountEmailKey].orEmpty(),
+ )
+ }.distinctUntilChanged()
+
suspend fun completeLogin(
cookie: String,
visitorData: String?,
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/cast/CastPlaybackRepository.kt b/app/src/main/kotlin/moe/rukamori/archivetune/cast/CastPlaybackRepository.kt
index ae169b2f6d..e054ef7b0c 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/cast/CastPlaybackRepository.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/cast/CastPlaybackRepository.kt
@@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.StateFlow
fun interface CastMediaItemResolver {
fun resolveForCast(mediaItem: MediaItem): MediaItem
+ fun resolveForCastBlocking(mediaItem: MediaItem): MediaItem = resolveForCast(mediaItem)
+
fun mimeTypeForCast(mediaItem: MediaItem): String? = null
}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/constants/LibraryFilter.kt b/app/src/main/kotlin/moe/rukamori/archivetune/constants/LibraryFilter.kt
index 8e0708f650..8d93911f6a 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/constants/LibraryFilter.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/constants/LibraryFilter.kt
@@ -14,12 +14,14 @@ enum class LibraryFilter {
PLAYLISTS,
PODCASTS,
SPOTIFY,
+ LOCAL,
LIBRARY,
}
val DefaultLibraryFilterOrder =
listOf(
LibraryFilter.LIBRARY,
+ LibraryFilter.LOCAL,
LibraryFilter.PLAYLISTS,
LibraryFilter.PODCASTS,
LibraryFilter.SPOTIFY,
@@ -36,9 +38,36 @@ fun String.toLibraryFilterOrder(): List {
split(",")
.mapNotNull { savedFilter ->
DefaultLibraryFilterOrder.firstOrNull { it.name == savedFilter.trim() }
- }.distinct()
+ }.distinct().toMutableList()
- return savedOrder + DefaultLibraryFilterOrder.filterNot(savedOrder::contains)
+ if (LibraryFilter.LOCAL !in savedOrder) {
+ val libraryIndex = savedOrder.indexOf(LibraryFilter.LIBRARY)
+ if (libraryIndex != -1) {
+ savedOrder.add(libraryIndex + 1, LibraryFilter.LOCAL)
+ } else {
+ savedOrder.add(0, LibraryFilter.LOCAL)
+ }
+ } else {
+ val oldDefaultWithLocalAtEnd =
+ listOf(
+ LibraryFilter.LIBRARY,
+ LibraryFilter.PLAYLISTS,
+ LibraryFilter.PODCASTS,
+ LibraryFilter.SPOTIFY,
+ LibraryFilter.SONGS,
+ LibraryFilter.ARTISTS,
+ LibraryFilter.ALBUMS,
+ LibraryFilter.LOCAL,
+ )
+ if (savedOrder == oldDefaultWithLocalAtEnd) {
+ savedOrder.remove(LibraryFilter.LOCAL)
+ savedOrder.add(1, LibraryFilter.LOCAL)
+ }
+ }
+
+ val missing = DefaultLibraryFilterOrder.filterNot(savedOrder::contains)
+ savedOrder.addAll(missing)
+ return savedOrder
}
fun List.toLibraryFilterPreference(): String {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/constants/MediaSessionConstants.kt b/app/src/main/kotlin/moe/rukamori/archivetune/constants/MediaSessionConstants.kt
index dd738ca81a..ad2c06028a 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/constants/MediaSessionConstants.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/constants/MediaSessionConstants.kt
@@ -16,9 +16,11 @@ object MediaSessionConstants {
const val ACTION_TOGGLE_LIKE = "TOGGLE_LIKE"
const val ACTION_TOGGLE_SHUFFLE = "TOGGLE_SHUFFLE"
const val ACTION_TOGGLE_REPEAT_MODE = "TOGGLE_REPEAT_MODE"
+ const val ACTION_DISMISS_PLAYBACK = "DISMISS_PLAYBACK"
val CommandToggleLibrary = SessionCommand(ACTION_TOGGLE_LIBRARY, Bundle.EMPTY)
val CommandToggleLike = SessionCommand(ACTION_TOGGLE_LIKE, Bundle.EMPTY)
val CommandToggleStartRadio = SessionCommand(ACTION_TOGGLE_START_RADIO, Bundle.EMPTY)
val CommandToggleShuffle = SessionCommand(ACTION_TOGGLE_SHUFFLE, Bundle.EMPTY)
val CommandToggleRepeatMode = SessionCommand(ACTION_TOGGLE_REPEAT_MODE, Bundle.EMPTY)
+ val CommandDismissPlayback = SessionCommand(ACTION_DISMISS_PLAYBACK, Bundle.EMPTY)
}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/constants/PreferenceKeys.kt b/app/src/main/kotlin/moe/rukamori/archivetune/constants/PreferenceKeys.kt
index 4fec61ddb3..551f373c6c 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/constants/PreferenceKeys.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/constants/PreferenceKeys.kt
@@ -100,6 +100,8 @@ val BackdropEnabledKey = booleanPreferencesKey("backdropEnabled")
val BackdropBlurAmountKey = intPreferencesKey("backdropBlurAmount")
val MiniPlayerLastAnchorKey = intPreferencesKey("miniPlayerLastAnchor")
val MiniPlayerBackgroundStyleKey = stringPreferencesKey("miniPlayerBackgroundStyle")
+val NavigationBarBackgroundStyleKey = stringPreferencesKey("navigationBarBackgroundStyle")
+val NavigationBarModeKey = stringPreferencesKey("navigationBarMode")
enum class AodThumbnailShape {
ROUNDED,
@@ -173,6 +175,7 @@ enum class SliderStyle {
Thick,
Circular,
Simple,
+ Waveform,
}
const val SYSTEM_DEFAULT = "SYSTEM_DEFAULT"
@@ -251,6 +254,7 @@ val AiApiValidationStatusKey = stringPreferencesKey("ai_api_validation_status")
val AiSelectedModelKey = stringPreferencesKey("ai_selected_model")
val AiCustomModelKey = stringPreferencesKey("ai_custom_model")
val AiCustomPromptKey = stringPreferencesKey("ai_custom_prompt")
+val AiRequestParametersKey = stringPreferencesKey("ai_request_parameters")
enum class AiProvider {
CHATGPT,
@@ -353,6 +357,8 @@ val StorageFolderIdKey = stringPreferencesKey("storageFolderId")
val StorageFolderTreeUriKey = stringPreferencesKey("storageFolderTreeUri")
val StorageFolderPathKey = stringPreferencesKey("storageFolderPath")
val StorageFolderDisplayNameKey = stringPreferencesKey("storageFolderDisplayName")
+val DownloadFolderTreeUriKey = stringPreferencesKey("downloadFolderTreeUri")
+val DownloadFolderDisplayNameKey = stringPreferencesKey("downloadFolderDisplayName")
val PauseListenHistoryKey = booleanPreferencesKey("pauseListenHistory")
val PauseSearchHistoryKey = booleanPreferencesKey("pauseSearchHistory")
@@ -699,6 +705,19 @@ enum class MiniPlayerBackgroundStyle {
THEME,
GRADIENT,
GLOW,
+ ACRYLIC,
+ BLUR,
+}
+
+enum class NavigationBarBackgroundStyle {
+ THEME,
+ BLUR,
+ ACRYLIC,
+}
+
+enum class NavigationBarMode {
+ CONNECTED,
+ STANDALONE,
}
// Keys for customized background
@@ -888,7 +907,7 @@ val CountryCodeToName =
"AE" to "United Arab Emirates",
)
-// App rating / star prompt preferences
+// Onboarding launch tracking and app rating prompt preferences
val LaunchCountKey = intPreferencesKey("launch_count")
val OnboardingCompletedKey = booleanPreferencesKey("onboarding_completed")
val HasPressedStarKey = booleanPreferencesKey("has_pressed_star")
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/db/LocalMusicIdentity.kt b/app/src/main/kotlin/moe/rukamori/archivetune/db/LocalMusicIdentity.kt
index f60db495e4..c64167ad4c 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/db/LocalMusicIdentity.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/db/LocalMusicIdentity.kt
@@ -12,10 +12,32 @@ import java.util.Locale
object LocalMusicIdentity {
private val whitespace = Regex("\\s+")
+ private val exportedDownloadSuffix = Regex("""\s\[([^\[\]]+)]\.[^.]+$""")
fun normalize(value: String): String =
Normalizer.normalize(value, Normalizer.Form.NFKC)
.trim()
.replace(whitespace, " ")
.lowercase(Locale.ROOT)
+
+ fun downloadIdFromExportedFilename(fileName: String): String? =
+ exportedDownloadSuffix.find(fileName)?.groupValues?.getOrNull(1)?.takeIf(String::isNotBlank)
+
+ fun trackKey(
+ title: String,
+ artists: Iterable,
+ durationSeconds: Int,
+ fallbackId: String,
+ ): String {
+ val normalizedTitle = normalize(title)
+ val normalizedArtists = artists.map(::normalize).filter(String::isNotEmpty).sorted()
+ if (normalizedTitle.isEmpty() || normalizedArtists.isEmpty() || durationSeconds <= 0) {
+ return "id:$fallbackId"
+ }
+ return listOf(
+ normalizedTitle,
+ normalizedArtists.joinToString("\u0000"),
+ durationSeconds.toString(),
+ ).joinToString("\u0001")
+ }
}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/db/MusicDatabase.kt b/app/src/main/kotlin/moe/rukamori/archivetune/db/MusicDatabase.kt
index 1d7bf46af9..8b01ef58ab 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/db/MusicDatabase.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/db/MusicDatabase.kt
@@ -62,7 +62,7 @@ import java.util.concurrent.Executor
import kotlin.coroutines.resume
private const val TAG = "MusicDatabase"
-private const val CURRENT_VERSION = 37
+private const val CURRENT_VERSION = 39
class MusicDatabase(
private val delegate: InternalDatabase,
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/db/entities/SongEntity.kt b/app/src/main/kotlin/moe/rukamori/archivetune/db/entities/SongEntity.kt
index 43e5c78ee9..c2677c4624 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/db/entities/SongEntity.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/db/entities/SongEntity.kt
@@ -40,6 +40,7 @@ data class SongEntity(
val year: Int? = null,
val date: LocalDateTime? = null, // ID3 tag property
val dateModified: LocalDateTime? = null, // file property
+ val dateAdded: LocalDateTime? = null,
val liked: Boolean = false,
val likedDate: LocalDateTime? = null,
val totalPlayTime: Long = 0, // in milliseconds
@@ -51,6 +52,7 @@ data class SongEntity(
val isPodcast: Boolean = false,
@ColumnInfo(name = "isLocal", defaultValue = "0")
val isLocal: Boolean = false,
+ val exportedDownloadId: String? = null,
) {
fun localToggleLike() =
copy(
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/downloads/DownloadFolderRepository.kt b/app/src/main/kotlin/moe/rukamori/archivetune/downloads/DownloadFolderRepository.kt
new file mode 100644
index 0000000000..c98964f9b0
--- /dev/null
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/downloads/DownloadFolderRepository.kt
@@ -0,0 +1,243 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.downloads
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.provider.DocumentsContract
+import android.provider.OpenableColumns
+import android.webkit.MimeTypeMap
+import androidx.core.net.toUri
+import androidx.datastore.preferences.core.edit
+import androidx.media3.common.C
+import androidx.media3.datasource.DataSpec
+import androidx.media3.datasource.FileDataSource
+import androidx.media3.datasource.cache.Cache
+import androidx.media3.datasource.cache.CacheDataSource
+import androidx.media3.exoplayer.offline.Download
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withContext
+import moe.rukamori.archivetune.constants.DownloadFolderDisplayNameKey
+import moe.rukamori.archivetune.constants.DownloadFolderTreeUriKey
+import moe.rukamori.archivetune.db.MusicDatabase
+import moe.rukamori.archivetune.di.DownloadCache
+import moe.rukamori.archivetune.utils.dataStore
+import timber.log.Timber
+import java.io.IOException
+import javax.inject.Inject
+import javax.inject.Singleton
+
+data class DownloadFolderSelection(
+ val treeUri: String?,
+ val displayName: String?,
+)
+
+class ObserveDownloadFolderUseCase
+ @Inject
+ constructor(
+ private val repository: DownloadFolderRepository,
+ ) {
+ operator fun invoke(): Flow = repository.selection
+ }
+
+class SetDownloadFolderUseCase
+ @Inject
+ constructor(
+ private val repository: DownloadFolderRepository,
+ ) {
+ suspend operator fun invoke(uri: Uri) = repository.setFolder(uri)
+ }
+
+class ExportCompletedDownloadUseCase
+ @Inject
+ constructor(
+ private val repository: DownloadFolderRepository,
+ ) {
+ suspend operator fun invoke(download: Download) = repository.exportCompletedDownload(download)
+ }
+
+@Singleton
+class DownloadFolderRepository
+ @Inject
+ constructor(
+ @ApplicationContext private val context: Context,
+ @DownloadCache private val downloadCache: Cache,
+ private val database: MusicDatabase,
+ ) {
+ val selection: Flow =
+ context.dataStore.data.map { preferences ->
+ DownloadFolderSelection(
+ treeUri = preferences[DownloadFolderTreeUriKey],
+ displayName = preferences[DownloadFolderDisplayNameKey],
+ )
+ }
+
+ suspend fun setFolder(uri: Uri) =
+ withContext(Dispatchers.IO) {
+ if (uri.scheme != "content") throw IOException("Download folder must be a document provider URI")
+ val previousUri = context.dataStore.data.first()[DownloadFolderTreeUriKey]
+ val permissions =
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ context.contentResolver.takePersistableUriPermission(uri, permissions)
+ try {
+ val displayName = queryDisplayName(uri)
+ context.dataStore.edit { preferences ->
+ preferences[DownloadFolderTreeUriKey] = uri.toString()
+ preferences[DownloadFolderDisplayNameKey] = displayName
+ }
+ } catch (exception: Exception) {
+ if (previousUri != uri.toString()) {
+ try {
+ context.contentResolver.releasePersistableUriPermission(uri, permissions)
+ } catch (releaseException: SecurityException) {
+ exception.addSuppressed(releaseException)
+ }
+ }
+ throw exception
+ }
+ if (!previousUri.isNullOrBlank() && previousUri != uri.toString()) {
+ try {
+ context.contentResolver.releasePersistableUriPermission(previousUri.toUri(), permissions)
+ } catch (exception: SecurityException) {
+ Timber.w(exception, "Unable to release the previous download folder permission")
+ }
+ }
+ }
+
+ suspend fun exportCompletedDownload(download: Download) =
+ withContext(Dispatchers.IO) {
+ if (download.state != Download.STATE_COMPLETED) return@withContext
+ val treeUri =
+ context.dataStore.data.first()[DownloadFolderTreeUriKey]
+ ?.toUri()
+ ?: return@withContext
+ val song = database.getSongByIdBlocking(download.request.id)
+ val mimeType = song?.format?.mimeType?.substringBefore(';') ?: "application/octet-stream"
+ val extension =
+ MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)
+ ?: "audio"
+ val title = song?.song?.title?.takeIf(String::isNotBlank) ?: download.request.id
+ val artist = song?.artists?.joinToString(", ") { it.name }?.takeIf(String::isNotBlank)
+ val fileName =
+ buildString {
+ append(title)
+ if (artist != null) append(" - ").append(artist)
+ append(" [").append(download.request.id).append("]")
+ }.toSafeFileName(MAX_DOWNLOAD_FILE_NAME_LENGTH - extension.length - 1) + ".$extension"
+
+ val rootDocumentId = DocumentsContract.getTreeDocumentId(treeUri)
+ val rootDocumentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, rootDocumentId)
+ if (findChildDocument(treeUri, rootDocumentId, fileName) != null) return@withContext
+ val outputUri =
+ DocumentsContract.createDocument(
+ context.contentResolver,
+ rootDocumentUri,
+ mimeType,
+ fileName,
+ ) ?: throw IOException("The selected download folder rejected file creation")
+
+ try {
+ val output =
+ context.contentResolver.openOutputStream(outputUri, "wt")
+ ?: throw IOException("Unable to open the downloaded audio file for writing")
+ val source =
+ CacheDataSource.Factory()
+ .setCache(downloadCache)
+ .setUpstreamDataSourceFactory(FileDataSource.Factory())
+ .setCacheWriteDataSinkFactory(null)
+ .createDataSource()
+ output.use { outputStream ->
+ try {
+ source.open(
+ DataSpec
+ .Builder()
+ .setUri(download.request.uri)
+ .setKey(download.request.id)
+ .setLength(download.contentLength.takeIf { length -> length > 0L } ?: C.LENGTH_UNSET.toLong())
+ .build(),
+ )
+ val buffer = ByteArray(EXPORT_BUFFER_SIZE)
+ while (true) {
+ val bytesRead = source.read(buffer, 0, buffer.size)
+ if (bytesRead == C.RESULT_END_OF_INPUT) break
+ outputStream.write(buffer, 0, bytesRead)
+ }
+ } finally {
+ source.close()
+ }
+ }
+ } catch (exception: Exception) {
+ try {
+ DocumentsContract.deleteDocument(context.contentResolver, outputUri)
+ } catch (cleanupException: Exception) {
+ exception.addSuppressed(cleanupException)
+ }
+ throw exception
+ }
+ }
+
+ private fun queryDisplayName(uri: Uri): String {
+ val documentId = DocumentsContract.getTreeDocumentId(uri)
+ val documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId)
+ context.contentResolver.query(
+ documentUri,
+ arrayOf(OpenableColumns.DISPLAY_NAME),
+ null,
+ null,
+ null,
+ )?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ return cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
+ }
+ }
+ throw IOException("Unable to read the selected download folder name")
+ }
+
+ private fun findChildDocument(
+ treeUri: Uri,
+ rootDocumentId: String,
+ displayName: String,
+ ): Uri? {
+ val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, rootDocumentId)
+ context.contentResolver.query(
+ childrenUri,
+ arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME),
+ null,
+ null,
+ null,
+ )?.use { cursor ->
+ val idColumn = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
+ val nameColumn = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
+ while (cursor.moveToNext()) {
+ if (cursor.getString(nameColumn) == displayName) {
+ return DocumentsContract.buildDocumentUriUsingTree(treeUri, cursor.getString(idColumn))
+ }
+ }
+ }
+ return null
+ }
+
+ private companion object {
+ const val EXPORT_BUFFER_SIZE = 16 * 1024
+ }
+ }
+
+private fun String.toSafeFileName(maxLength: Int): String =
+ replace(Regex("[\\\\/:*?\"<>|\\p{Cc}]"), "_")
+ .trim()
+ .trim('.')
+ .take(maxLength.coerceAtLeast(1))
+ .ifBlank { "download" }
+
+private const val MAX_DOWNLOAD_FILE_NAME_LENGTH = 180
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/downloads/DownloadRepository.kt b/app/src/main/kotlin/moe/rukamori/archivetune/downloads/DownloadRepository.kt
index 0527b63862..c38fcf1d22 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/downloads/DownloadRepository.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/downloads/DownloadRepository.kt
@@ -15,6 +15,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.onStart
import moe.rukamori.archivetune.db.MusicDatabase
import moe.rukamori.archivetune.db.entities.Album
import moe.rukamori.archivetune.db.entities.Playlist
@@ -53,10 +54,10 @@ class Media3DownloadRepository
) : DownloadRepository {
override fun observeDownloads(): Flow =
combine(
- database.allSongs(),
- database.allAlbumsForDownloads(),
- database.allPlaylistsForDownloads(),
- database.allPlaylistSongMapsForDownloads(),
+ database.allSongs().onStart { emit(emptyList()) },
+ database.allAlbumsForDownloads().onStart { emit(emptyList()) },
+ database.allPlaylistsForDownloads().onStart { emit(emptyList()) },
+ database.allPlaylistSongMapsForDownloads().onStart { emit(emptyList()) },
) { songs, albums, playlists, playlistSongMaps ->
DownloadRepositorySnapshot(
songs = songs,
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/downloads/ManageDownloadsUseCase.kt b/app/src/main/kotlin/moe/rukamori/archivetune/downloads/ManageDownloadsUseCase.kt
index bc6dc52b24..95bdac58ba 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/downloads/ManageDownloadsUseCase.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/downloads/ManageDownloadsUseCase.kt
@@ -37,6 +37,7 @@ data class DownloadEntryUiModel(
val durationSeconds: Int?,
val songIds: List,
val totalCount: Int,
+ val modifiedAtMs: Long,
val progress: Float,
val percent: Int,
val speedBytesPerSecond: Long,
@@ -367,6 +368,7 @@ class ManageDownloadsUseCase
durationSeconds = durationSeconds,
songIds = songIds,
totalCount = songIds.size,
+ modifiedAtMs = entries.maxOfOrNull(Download::updateTimeMs) ?: 0L,
progress = progress,
percent = (progress * 100f).roundToInt().coerceIn(0, 100),
speedBytesPerSecond = activeEntries.sumOf { speeds[it.request.id] ?: 0L },
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/extensions/PlayerExt.kt b/app/src/main/kotlin/moe/rukamori/archivetune/extensions/PlayerExt.kt
index e7070d99bf..d677b1d6e3 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/extensions/PlayerExt.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/extensions/PlayerExt.kt
@@ -115,3 +115,7 @@ fun Player.findNextMediaItemById(mediaId: String): MediaItem? {
}
return null
}
+
+fun buildRestoredShuffleOrderIndices(originalQueueTimelineIndices: List): IntArray =
+ originalQueueTimelineIndices.toIntArray()
+
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/home/HomeModels.kt b/app/src/main/kotlin/moe/rukamori/archivetune/home/HomeModels.kt
index 0d69ee904c..f82d141ee6 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/home/HomeModels.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/home/HomeModels.kt
@@ -57,6 +57,8 @@ data class HomeUiState(
val isLoadingMore: Boolean,
)
+fun HomePage.Section.hasVisibleContent(): Boolean = items.isNotEmpty() || featuredCards.isNotEmpty()
+
sealed interface HomeAction {
data object Refresh : HomeAction
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/library/RefreshLibraryTopMixesUseCase.kt b/app/src/main/kotlin/moe/rukamori/archivetune/library/RefreshLibraryTopMixesUseCase.kt
index cc3154b48a..6dfedad000 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/library/RefreshLibraryTopMixesUseCase.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/library/RefreshLibraryTopMixesUseCase.kt
@@ -22,6 +22,7 @@ import moe.rukamori.archivetune.constants.AiCustomEndpointKey
import moe.rukamori.archivetune.constants.AiCustomModelKey
import moe.rukamori.archivetune.constants.AiProvider
import moe.rukamori.archivetune.constants.AiProviderKey
+import moe.rukamori.archivetune.constants.AiRequestParametersKey
import moe.rukamori.archivetune.constants.AiSelectedModelKey
import moe.rukamori.archivetune.db.entities.Song
import moe.rukamori.archivetune.extensions.toEnum
@@ -85,6 +86,7 @@ class RefreshLibraryTopMixesUseCase
provider = provider,
apiKey = prefs[AiApiKeyKey].orEmpty(),
customEndpoint = prefs[AiCustomEndpointKey].orEmpty(),
+ requestParameters = prefs[AiRequestParametersKey].orEmpty(),
model =
if (provider == AiProvider.CUSTOM) {
prefs[AiCustomModelKey].orEmpty()
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/localmedia/LocalMediaIndexer.kt b/app/src/main/kotlin/moe/rukamori/archivetune/localmedia/LocalMediaIndexer.kt
new file mode 100644
index 0000000000..d6272f9a9e
--- /dev/null
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/localmedia/LocalMediaIndexer.kt
@@ -0,0 +1,113 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.localmedia
+
+import android.content.Context
+import android.media.MediaScannerConnection
+import android.os.Environment
+import android.provider.MediaStore
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.io.File
+import java.util.Locale
+import java.util.concurrent.atomic.AtomicInteger
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.coroutines.resume
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+
+@Singleton
+class LocalMediaIndexer @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ suspend fun indexMissingAudio(scanConfig: LocalSongScanConfig? = null) {
+ withContext(Dispatchers.IO) {
+ val indexed = indexedPaths()
+ val missing = findAudioFiles(scanConfig).filter { it.absolutePath !in indexed }.map(File::getAbsolutePath)
+ if (missing.isEmpty()) return@withContext
+ currentCoroutineContext().ensureActive()
+ val failed = scan(missing)
+ if (failed > 0) Timber.tag(LogTag).w("MediaStore did not index %d of %d audio files", failed, missing.size)
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private suspend fun indexedPaths(): Set {
+ val paths = HashSet()
+ context.contentResolver.query(
+ MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
+ arrayOf(MediaStore.Audio.Media.DATA),
+ null,
+ null,
+ null,
+ )?.use { cursor ->
+ val dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA)
+ while (cursor.moveToNext()) {
+ currentCoroutineContext().ensureActive()
+ cursor.getString(dataIndex)?.let(paths::add)
+ }
+ }
+ return paths
+ }
+
+ @Suppress("DEPRECATION")
+ private suspend fun findAudioFiles(scanConfig: LocalSongScanConfig? = null): List {
+ val root = Environment.getExternalStorageDirectory().canonicalFile
+ val config = scanConfig ?: LocalSongScanConfig()
+ val includedFolders = config.sanitizedIncludedFolders
+ val targetDirs =
+ if (includedFolders.isNotEmpty()) {
+ includedFolders
+ .map { File(root, it).canonicalFile }
+ .filter { it.toPath().startsWith(root.toPath()) && it.exists() && it.isDirectory }
+ .distinct()
+ } else {
+ listOf(root)
+ }
+ if (includedFolders.isNotEmpty() && targetDirs.isEmpty()) return emptyList()
+
+ val files = linkedMapOf()
+ for (targetDir in targetDirs) {
+ targetDir.walkTopDown()
+ .onEnter { directory ->
+ (directory == targetDir || !directory.name.startsWith('.')) &&
+ config.shouldScanFolder(directory.relativeTo(root).invariantSeparatorsPath) &&
+ !File(directory, NoMediaFileName).exists()
+ }
+ .forEach { file ->
+ currentCoroutineContext().ensureActive()
+ if (file.isFile && file.extension.lowercase(Locale.ROOT) in IndexableExtensions && file.length() > 0L) {
+ files.putIfAbsent(file.canonicalPath, file)
+ }
+ }
+ }
+ return files.values.toList()
+ }
+
+ private suspend fun scan(paths: List): Int = suspendCancellableCoroutine { continuation ->
+ val remaining = AtomicInteger(paths.size)
+ val failed = AtomicInteger()
+ MediaScannerConnection.scanFile(context, paths.toTypedArray(), null) { path, uri ->
+ if (uri == null) {
+ failed.incrementAndGet()
+ Timber.tag(LogTag).w("Unable to index audio file: %s", path)
+ }
+ if (remaining.decrementAndGet() == 0 && continuation.isActive) continuation.resume(failed.get())
+ }
+ }
+
+ private companion object {
+ const val LogTag = "LocalMediaIndexer"
+ const val NoMediaFileName = ".nomedia"
+ val IndexableExtensions = setOf("aac", "amr", "flac", "m4a", "m4b", "mp3", "oga", "ogg", "opus", "wav", "3ga")
+ }
+}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/localmedia/LocalSongScanner.kt b/app/src/main/kotlin/moe/rukamori/archivetune/localmedia/LocalSongScanner.kt
index 2910fcd759..14decbe913 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/localmedia/LocalSongScanner.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/localmedia/LocalSongScanner.kt
@@ -60,8 +60,27 @@ data class LocalSongScanConfig(
val sanitizedExcludedFolders: Set
get() = deduplicateFolderEntries(excludedFolders)
+ fun shouldScanFolder(folderPath: String?): Boolean {
+ val normalizedPath = normalizeFolderEntry(folderPath.orEmpty()).lowercase(Locale.ROOT)
+ val included = sanitizedIncludedFolders.map { it.lowercase(Locale.ROOT) }.toSet()
+ val excluded = sanitizedExcludedFolders.map { it.lowercase(Locale.ROOT) }.toSet()
+ val includedByDefault =
+ included.isEmpty() &&
+ normalizedPath.split('/').none { it == "android" }
+ val matchesIncluded = includedByDefault || matchesFolderEntry(normalizedPath, included)
+ return matchesIncluded && !matchesFolderEntry(normalizedPath, excluded)
+ }
+
companion object {
private val DuplicateSlashRegex = Regex("/+")
+ private fun matchesFolderEntry(folderPath: String, folders: Set): Boolean =
+ folderPath.isNotEmpty() &&
+ folders.any { folder ->
+ folderPath == folder ||
+ folderPath.startsWith("$folder/") ||
+ folderPath.endsWith("/$folder") ||
+ folderPath.contains("/$folder/")
+ }
fun normalizeFolderEntry(raw: String): String =
raw
@@ -96,12 +115,14 @@ class LocalSongScanner @Inject constructor(
private val database: MusicDatabase,
private val metadataReader: LocalAudioMetadataReader,
private val metadataRepository: LocalMetadataRepository,
+ private val mediaIndexer: LocalMediaIndexer,
) {
private val scanMutex = Mutex()
suspend fun scanDevice(scanConfig: LocalSongScanConfig = LocalSongScanConfig()): LocalSongScanSummary =
withContext(Dispatchers.IO) {
scanMutex.withLock {
+ mediaIndexer.indexMissingAudio(scanConfig)
val scanned = queryTracks(scanConfig)
val ids = scanned.map(LocalTrackRecord::id)
val enriched = enrichTracks(scanned, loadSongs(ids), loadFormats(ids))
@@ -152,7 +173,9 @@ class LocalSongScanner @Inject constructor(
discNumber = track.discNumber,
year = track.year,
dateModified = track.dateModified,
+ dateAdded = track.dateAdded,
isLocal = true,
+ exportedDownloadId = track.exportedDownloadId,
))
val unchanged = isUnchanged(track, existing, oldFormat)
upsert(FormatEntity(
@@ -353,8 +376,6 @@ class LocalSongScanner @Inject constructor(
@Suppress("DEPRECATION")
private suspend fun queryTracks(scanConfig: LocalSongScanConfig): List {
- val included = scanConfig.sanitizedIncludedFolders.map { it.lowercase(Locale.ROOT) }.toSet()
- val excluded = scanConfig.sanitizedExcludedFolders.map { it.lowercase(Locale.ROOT) }.toSet()
val projection = buildList {
add(MediaStore.Audio.Media._ID)
add(MediaStore.Audio.Media.TITLE)
@@ -366,20 +387,30 @@ class LocalSongScanner @Inject constructor(
add(MediaStore.Audio.Media.YEAR)
add(MediaStore.Audio.Media.TRACK)
add(MediaStore.Audio.Media.DATE_MODIFIED)
+ add(MediaStore.Audio.Media.DATE_ADDED)
add(MediaStore.Audio.Media.SIZE)
add(MediaStore.Audio.Media.MIME_TYPE)
+ add(MediaStore.MediaColumns.DATA)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) add(MediaStore.Audio.Media.ALBUM_ARTIST)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) add(MediaStore.MediaColumns.RELATIVE_PATH)
- else add(MediaStore.MediaColumns.DATA)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ add(MediaStore.MediaColumns.RELATIVE_PATH)
+ add(MediaStore.MediaColumns.VOLUME_NAME)
+ }
}.toTypedArray()
+ val minDurationSec = scanConfig.sanitizedMinimumDurationSeconds
+ val minDurationMs = minDurationSec * 1000L
val selection = buildList {
add("${MediaStore.Audio.Media.SIZE} > 0")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) add("${MediaStore.MediaColumns.IS_PENDING} = 0")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) add("${MediaStore.MediaColumns.IS_TRASHED} = 0")
+ if (minDurationMs > 0) {
+ add("(${MediaStore.Audio.Media.DURATION} >= $minDurationMs OR ${MediaStore.Audio.Media.DURATION} = 0 OR ${MediaStore.Audio.Media.DURATION} IS NULL)")
+ }
}.joinToString(" AND ")
val unknownArtist = context.getString(R.string.unknown_artist)
val unknownTitle = context.getString(R.string.unknown)
val tracks = mutableListOf()
+ val seenPaths = mutableSetOf()
val lyricsExtractor = EmbeddedLyricsExtractor(context.contentResolver)
val cursor = context.contentResolver.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
@@ -400,24 +431,38 @@ class LocalSongScanner @Inject constructor(
val yearIndex = it.getColumnIndex(MediaStore.Audio.Media.YEAR)
val trackIndex = it.getColumnIndex(MediaStore.Audio.Media.TRACK)
val modifiedIndex = it.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_MODIFIED)
+ val addedIndex = it.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_ADDED)
val sizeIndex = it.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE)
val mimeIndex = it.getColumnIndexOrThrow(MediaStore.Audio.Media.MIME_TYPE)
val relativeIndex = it.getColumnIndex(MediaStore.MediaColumns.RELATIVE_PATH)
+ val volumeIndex = it.getColumnIndex(MediaStore.MediaColumns.VOLUME_NAME)
val dataIndex = it.getColumnIndex(MediaStore.MediaColumns.DATA)
while (it.moveToNext()) {
currentCoroutineContext().ensureActive()
val folder = resolveNormalizedFolderPath(it.getStringOrNull(relativeIndex), it.getStringOrNull(dataIndex))
- if (!shouldIncludeFolder(folder, included) || shouldExcludeFolder(folder, excluded)) continue
+ if (!scanConfig.shouldScanFolder(folder)) continue
val displayName = it.getStringOrNull(displayNameIndex)
val mime = it.getStringOrNull(mimeIndex)?.takeIf(String::isNotBlank) ?: "audio/*"
if (!SupportedLocalAudio.isSupported(displayName, mime)) continue
+ val path = it.getStringOrNull(dataIndex)
+ ?.let { value -> runCatching { File(value).canonicalPath }.getOrDefault(File(value).absolutePath) }
+ ?: listOfNotNull(it.getStringOrNull(volumeIndex), it.getStringOrNull(relativeIndex), displayName)
+ .joinToString("/")
+ if (!seenPaths.add(path)) continue
+
+ val mediaStoreDurationMs = it.getLongOrNull(durationIndex)?.coerceAtLeast(0L) ?: 0L
+ val mediaStoreDurationSec = (mediaStoreDurationMs / 1000L).toInt()
+ if (minDurationSec > 0 && mediaStoreDurationMs > 0 && mediaStoreDurationSec < minDurationSec) {
+ continue
+ }
+
val uri = ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, it.getLong(idIndex))
val modified = it.getLong(modifiedIndex)
+ val added = it.getLong(addedIndex)
val size = it.getLong(sizeIndex).coerceAtLeast(0L)
val metadata = metadataReader.read(uri, modified, size)
- val duration = metadata.durationSeconds ?: (it.getLong(durationIndex).coerceAtLeast(0L) / 1000L)
- .coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
- if (duration < scanConfig.sanitizedMinimumDurationSeconds) continue
+ val duration = metadata.durationSeconds ?: mediaStoreDurationSec
+ if (minDurationSec > 0 && duration < minDurationSec) continue
val artist = metadata.artist ?: cleanTag(it.getStringOrNull(artistIndex))
val album = metadata.album ?: cleanTag(it.getStringOrNull(albumIndex))
val encodedTrack = it.getIntOrNull(trackIndex)?.takeIf { value -> value > 0 }
@@ -435,6 +480,8 @@ class LocalSongScanner @Inject constructor(
year = metadata.year ?: it.getIntOrNull(yearIndex)?.takeIf { value -> value in 1..9999 },
dateModified = modified.takeIf { value -> value > 0L }
?.let { value -> LocalDateTime.ofInstant(Instant.ofEpochSecond(value), ZoneId.systemDefault()) },
+ dateAdded = added.takeIf { value -> value > 0L }
+ ?.let { value -> LocalDateTime.ofInstant(Instant.ofEpochSecond(value), ZoneId.systemDefault()) },
sizeBytes = size,
mimeType = mime,
thumbnailUrl = metadata.thumbnailUrl,
@@ -442,6 +489,7 @@ class LocalSongScanner @Inject constructor(
?.let { value -> ContentUris.withAppendedId(AlbumArtUri, value).toString() },
embeddedLyrics = lyricsExtractor.extract(contentUri = uri, displayName = displayName, mimeType = mime)
?.let(LyricsUtils::lyricsOrNotFound)?.takeIf { value -> value != LyricsEntity.LYRICS_NOT_FOUND },
+ exportedDownloadId = displayName?.let(LocalMusicIdentity::downloadIdFromExportedFilename),
)
}
}
@@ -515,35 +563,6 @@ class LocalSongScanner @Inject constructor(
return normalizedAbsoluteFolder.takeIf(String::isNotEmpty)?.lowercase(Locale.ROOT)
}
- private fun shouldIncludeFolder(
- folderPath: String?,
- includedFolders: Set,
- ): Boolean {
- if (includedFolders.isEmpty()) return true
- return matchesFolderEntry(folderPath, includedFolders)
- }
-
- private fun shouldExcludeFolder(
- folderPath: String?,
- excludedFolders: Set,
- ): Boolean {
- if (excludedFolders.isEmpty()) return false
- return matchesFolderEntry(folderPath, excludedFolders)
- }
-
- private fun matchesFolderEntry(
- folderPath: String?,
- folders: Set,
- ): Boolean {
- if (folderPath.isNullOrEmpty()) return false
- return folders.any { folder ->
- folderPath == folder ||
- folderPath.startsWith("$folder/") ||
- folderPath.endsWith("/$folder") ||
- folderPath.contains("/$folder/")
- }
- }
-
private fun android.database.Cursor.getLongOrNull(columnIndex: Int): Long? =
if (columnIndex >= 0 && !isNull(columnIndex)) getLong(columnIndex) else null
@@ -566,6 +585,7 @@ class LocalSongScanner @Inject constructor(
val durationSeconds: Int,
val year: Int?,
val dateModified: LocalDateTime?,
+ val dateAdded: LocalDateTime?,
val sizeBytes: Long,
val mimeType: String,
val thumbnailUrl: String?,
@@ -575,6 +595,7 @@ class LocalSongScanner @Inject constructor(
val matchedArtistName: String? = null,
val artistThumbnailUrl: String? = null,
val remoteAlbumId: String? = null,
+ val exportedDownloadId: String? = null,
)
private companion object {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/localmedia/SupportedLocalAudio.kt b/app/src/main/kotlin/moe/rukamori/archivetune/localmedia/SupportedLocalAudio.kt
index a76e56e193..e944fccca7 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/localmedia/SupportedLocalAudio.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/localmedia/SupportedLocalAudio.kt
@@ -46,6 +46,20 @@ object SupportedLocalAudio {
"wvc",
)
+ private val blockedMimeTypes =
+ setOf(
+ "audio/x-ape",
+ "audio/x-dsd",
+ "audio/x-dsf",
+ "audio/x-ms-wma",
+ "audio/x-tta",
+ "audio/x-wavpack",
+ "audio/x-mpegurl",
+ "audio/mpegurl",
+ "audio/x-scpls",
+ "audio/x-flac+cue",
+ )
+
private val supportedMimeTypes =
setOf(
"audio/aac",
@@ -93,17 +107,7 @@ object SupportedLocalAudio {
?.lowercase(Locale.ROOT)
.orEmpty()
- return normalizedMime in supportedMimeTypes
- }
-
- fun isSupportedMimeType(mimeType: String?): Boolean {
- val normalizedMime =
- mimeType
- ?.substringBefore(';')
- ?.trim()
- ?.lowercase(Locale.ROOT)
- .orEmpty()
-
- return normalizedMime in supportedMimeTypes || normalizedMime == "audio/*"
+ if (normalizedMime in blockedMimeTypes) return false
+ return normalizedMime in supportedMimeTypes || normalizedMime.startsWith("audio/")
}
}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/lyrics/LyricsRenderingModels.kt b/app/src/main/kotlin/moe/rukamori/archivetune/lyrics/LyricsRenderingModels.kt
index 0a54e6159b..911d1b2a3a 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/lyrics/LyricsRenderingModels.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/lyrics/LyricsRenderingModels.kt
@@ -82,6 +82,7 @@ data class LyricsRenderingPreferences(
val v2FillTransitionWidthDp: Float,
val v2LrcBounceEnabled: Boolean,
val romanization: LyricsRomanizationPreferences,
+ val showTranslations: Boolean = true,
)
@Immutable
@@ -90,4 +91,6 @@ data class PreparedLyrics(
val syncType: LyricsSyncType,
val lines: ImmutableList,
val preferences: LyricsRenderingPreferences,
-)
+) {
+ val hasTranslations: Boolean = lines.any { !it.translation.isNullOrBlank() }
+}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/lyrics/LyricsRenderingRepository.kt b/app/src/main/kotlin/moe/rukamori/archivetune/lyrics/LyricsRenderingRepository.kt
index 81988b2fb1..27290f355a 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/lyrics/LyricsRenderingRepository.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/lyrics/LyricsRenderingRepository.kt
@@ -8,6 +8,7 @@
package moe.rukamori.archivetune.lyrics
import android.content.Context
+import androidx.datastore.preferences.core.edit
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -26,6 +27,7 @@ import moe.rukamori.archivetune.constants.LyricsV2BounceFactorKey
import moe.rukamori.archivetune.constants.LyricsV2FillTransitionWidthKey
import moe.rukamori.archivetune.constants.LyricsV2GlowFactorKey
import moe.rukamori.archivetune.constants.LyricsV2LrcBounceEnabledKey
+import moe.rukamori.archivetune.constants.TranslateLyricsKey
import moe.rukamori.archivetune.db.MusicDatabase
import moe.rukamori.archivetune.utils.dataStore
import javax.inject.Inject
@@ -38,7 +40,12 @@ class LyricsRenderingRepository
private val database: MusicDatabase,
@ApplicationContext context: Context,
) {
- private val preferences = context.dataStore.data
+ private val dataStore = context.dataStore
+ private val preferences = dataStore.data
+
+ suspend fun setShowTranslations(show: Boolean) {
+ dataStore.edit { values -> values[TranslateLyricsKey] = show }
+ }
fun observeLyrics(mediaId: String): Flow =
database
@@ -51,6 +58,7 @@ class LyricsRenderingRepository
.map { values ->
LyricsRenderingPreferences(
clickEnabled = values[LyricsClickKey] ?: true,
+ showTranslations = values[TranslateLyricsKey] ?: true,
scrollEnabled = values[LyricsScrollKey] ?: true,
textSizeSp = values[LyricsTextSizeKey] ?: 26f,
lineSpacing = values[LyricsLineSpacingKey] ?: 1.3f,
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/lyrics/LyricsUtils.kt b/app/src/main/kotlin/moe/rukamori/archivetune/lyrics/LyricsUtils.kt
index 5606da123c..2d12446010 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/lyrics/LyricsUtils.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/lyrics/LyricsUtils.kt
@@ -772,6 +772,17 @@ object LyricsUtils {
return@forEach
}
+ if (existing.text.isBlank() && entry.text.isNotBlank()) {
+ mergedByTime[entry.time] = entry.copy(
+ providerTranslationText = existing.providerTranslationText ?: entry.providerTranslationText,
+ )
+ return@forEach
+ }
+
+ if (entry.text.isBlank()) {
+ return@forEach
+ }
+
val translatedText =
entry.text
.replace(WHITESPACE_REGEX, " ")
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/lyrics/PrepareLyricsUseCase.kt b/app/src/main/kotlin/moe/rukamori/archivetune/lyrics/PrepareLyricsUseCase.kt
index 4911d2de53..7be9525648 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/lyrics/PrepareLyricsUseCase.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/lyrics/PrepareLyricsUseCase.kt
@@ -32,6 +32,10 @@ class PrepareLyricsUseCase
constructor(
private val repository: LyricsRenderingRepository,
) {
+ suspend fun setShowTranslations(show: Boolean) {
+ repository.setShowTranslations(show)
+ }
+
fun observe(
mediaId: String,
durationMs: Long,
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/lyrics/PreparedLyricsAdapters.kt b/app/src/main/kotlin/moe/rukamori/archivetune/lyrics/PreparedLyricsAdapters.kt
index c70c4ed319..45e41fba52 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/lyrics/PreparedLyricsAdapters.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/lyrics/PreparedLyricsAdapters.kt
@@ -41,7 +41,7 @@ fun PreparedLyrics.toLyricsEntries(): List =
durationMs = (line.endMs - line.startMs).coerceAtLeast(0L),
providerRomanizedText = line.romanizedText,
providerRomanizedWords = providerWords,
- providerTranslationText = line.translation,
+ providerTranslationText = line.translation.takeIf { preferences.showTranslations },
isRtl = line.direction == LyricsTextDirection.RTL,
romanizedTextFlow = MutableStateFlow(line.romanizedText),
)
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/onboarding/OnboardingUseCases.kt b/app/src/main/kotlin/moe/rukamori/archivetune/onboarding/OnboardingUseCases.kt
index 315b3e2e1a..e22d541b4c 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/onboarding/OnboardingUseCases.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/onboarding/OnboardingUseCases.kt
@@ -248,7 +248,7 @@ class BuildOnboardingUiStateUseCase
titleResId = R.string.about_content_desc_donate,
descriptionResId = R.string.onboarding_community_donate_desc,
iconResId = R.drawable.coffee,
- url = "https://koiiverse.cloud/donate",
+ url = "https://miuka.moe/donate",
),
)
}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/playback/ArchiveTuneMediaNotificationProvider.kt b/app/src/main/kotlin/moe/rukamori/archivetune/playback/ArchiveTuneMediaNotificationProvider.kt
index 8a3f081b8e..864150f9de 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/playback/ArchiveTuneMediaNotificationProvider.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/playback/ArchiveTuneMediaNotificationProvider.kt
@@ -35,37 +35,29 @@ class ArchiveTuneMediaNotificationProvider(
setSmallIcon(smallIconResId)
}
+ @Volatile
+ var latestNotification: MediaNotification? = null
+ private set
+
+ fun clearLatestNotification() {
+ latestNotification = null
+ }
+
override fun createNotification(
mediaSession: MediaSession,
mediaButtonPreferences: ImmutableList,
actionFactory: MediaNotification.ActionFactory,
onNotificationChangedCallback: MediaNotification.Provider.Callback,
- ): MediaNotification {
- val mediaNotification =
+ ): MediaNotification =
+ decorate(
delegate.createNotification(
mediaSession,
mediaButtonPreferences,
actionFactory,
- onNotificationChangedCallback,
- )
-
- val originalDeleteIntent = mediaNotification.notification.deleteIntent ?: return mediaNotification
- mediaNotification.notification.deleteIntent =
- PendingIntent.getService(
- context,
- mediaNotification.notificationId,
- Intent(context, MusicService::class.java).apply {
- action = MusicService.ACTION_MEDIA_NOTIFICATION_DISMISSED
- putExtra(
- MusicService.EXTRA_MEDIA_NOTIFICATION_DELETE_INTENT,
- originalDeleteIntent,
- )
- },
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
- )
-
- return mediaNotification
- }
+ ) { updatedNotification ->
+ onNotificationChangedCallback.onNotificationChanged(decorate(updatedNotification))
+ },
+ )
override fun handleCustomCommand(
session: MediaSession,
@@ -74,4 +66,25 @@ class ArchiveTuneMediaNotificationProvider(
): Boolean = delegate.handleCustomCommand(session, action, extras)
override fun getNotificationChannelInfo(): MediaNotification.Provider.NotificationChannelInfo = delegate.notificationChannelInfo
+
+ private fun decorate(mediaNotification: MediaNotification): MediaNotification {
+ val notification = mediaNotification.notification
+ notification.deleteIntent?.let { originalDeleteIntent ->
+ notification.deleteIntent =
+ PendingIntent.getService(
+ context,
+ mediaNotification.notificationId,
+ Intent(context, MusicService::class.java).apply {
+ action = MusicService.ACTION_MEDIA_NOTIFICATION_DISMISSED
+ putExtra(
+ MusicService.EXTRA_MEDIA_NOTIFICATION_DELETE_INTENT,
+ originalDeleteIntent,
+ )
+ },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+ latestNotification = mediaNotification
+ return mediaNotification
+ }
}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/playback/AudioWaveformAnalyzer.kt b/app/src/main/kotlin/moe/rukamori/archivetune/playback/AudioWaveformAnalyzer.kt
new file mode 100644
index 0000000000..9df4ce8f81
--- /dev/null
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/playback/AudioWaveformAnalyzer.kt
@@ -0,0 +1,267 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.playback
+
+import android.media.AudioFormat
+import android.media.MediaCodec
+import android.media.MediaDataSource
+import android.media.MediaExtractor
+import android.media.MediaFormat
+import androidx.media3.common.C
+import androidx.media3.datasource.DataSource
+import androidx.media3.datasource.DataSpec
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
+import java.io.IOException
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import kotlin.math.abs
+import kotlin.math.pow
+
+internal object AudioWaveformAnalyzer {
+ suspend fun generate(
+ dataSourceFactory: DataSource.Factory,
+ dataSpec: DataSpec,
+ fallbackDurationMs: Int,
+ ): ByteArray {
+ val squaredAmplitudes = DoubleArray(BinCount)
+ val frameCounts = IntArray(BinCount)
+ val extractor = MediaExtractor()
+ val mediaDataSource = DataSourceMediaDataSource(dataSourceFactory, dataSpec)
+ var codec: MediaCodec? = null
+ try {
+ extractor.setDataSource(mediaDataSource)
+ val audioTrack = (0 until extractor.trackCount).firstOrNull { track ->
+ extractor.getTrackFormat(track).getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true
+ } ?: throw IOException("The audio source contains no audio track")
+ val trackFormat = extractor.getTrackFormat(audioTrack)
+ val mimeType = trackFormat.getString(MediaFormat.KEY_MIME)
+ ?: throw IOException("The audio source has no decoder MIME type")
+ val durationUs =
+ trackFormat
+ .takeIf { it.containsKey(MediaFormat.KEY_DURATION) }
+ ?.getLong(MediaFormat.KEY_DURATION)
+ ?.takeIf { it > 0L }
+ ?: fallbackDurationMs.toLong() * 1_000L
+ if (durationUs <= 0L) throw IOException("The audio duration is unavailable")
+
+ extractor.selectTrack(audioTrack)
+ codec = MediaCodec.createDecoderByType(mimeType)
+ codec.configure(trackFormat, null, null, 0)
+ codec.start()
+
+ val bufferInfo = MediaCodec.BufferInfo()
+ var inputEnded = false
+ var outputEnded = false
+ var sampleRate = trackFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
+ var channelCount = trackFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
+ var pcmEncoding = AudioFormat.ENCODING_PCM_16BIT
+
+ while (!outputEnded) {
+ currentCoroutineContext().ensureActive()
+
+ if (!inputEnded) {
+ val inputIndex = codec.dequeueInputBuffer(CodecBufferTimeoutUs)
+ if (inputIndex >= 0) {
+ val inputBuffer = codec.getInputBuffer(inputIndex)
+ ?: throw IOException("The audio decoder returned no input buffer")
+ inputBuffer.clear()
+ val sampleSize = extractor.readSampleData(inputBuffer, 0)
+ if (sampleSize < 0) {
+ codec.queueInputBuffer(
+ inputIndex,
+ 0,
+ 0,
+ 0L,
+ MediaCodec.BUFFER_FLAG_END_OF_STREAM,
+ )
+ inputEnded = true
+ } else {
+ codec.queueInputBuffer(inputIndex, 0, sampleSize, extractor.sampleTime, 0)
+ extractor.advance()
+ }
+ }
+ }
+
+ when (val outputIndex = codec.dequeueOutputBuffer(bufferInfo, CodecBufferTimeoutUs)) {
+ MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
+ val outputFormat = codec.outputFormat
+ sampleRate = outputFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
+ channelCount = outputFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
+ pcmEncoding =
+ if (outputFormat.containsKey(MediaFormat.KEY_PCM_ENCODING)) {
+ outputFormat.getInteger(MediaFormat.KEY_PCM_ENCODING)
+ } else {
+ AudioFormat.ENCODING_PCM_16BIT
+ }
+ }
+
+ MediaCodec.INFO_TRY_AGAIN_LATER,
+ MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED,
+ -> Unit
+
+ else -> {
+ if (outputIndex >= 0) {
+ if (bufferInfo.size > 0) {
+ val outputBuffer = codec.getOutputBuffer(outputIndex)
+ ?: throw IOException("The audio decoder returned no output buffer")
+ accumulateSamples(
+ buffer = outputBuffer,
+ info = bufferInfo,
+ sampleRate = sampleRate,
+ channelCount = channelCount,
+ pcmEncoding = pcmEncoding,
+ durationUs = durationUs,
+ squaredAmplitudes = squaredAmplitudes,
+ frameCounts = frameCounts,
+ )
+ }
+ outputEnded = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
+ codec.releaseOutputBuffer(outputIndex, false)
+ }
+ }
+ }
+ }
+ } finally {
+ try {
+ codec?.stop()
+ } finally {
+ codec?.release()
+ extractor.release()
+ mediaDataSource.close()
+ }
+ }
+
+ val rmsAmplitudes =
+ DoubleArray(BinCount) { index ->
+ if (frameCounts[index] == 0) {
+ 0.0
+ } else {
+ kotlin.math.sqrt(squaredAmplitudes[index] / frameCounts[index])
+ }
+ }
+ val maximumAmplitude = rmsAmplitudes.maxOrNull() ?: 0.0
+ if (maximumAmplitude <= 0.0) throw IOException("The audio waveform contains no samples")
+ return ByteArray(BinCount) { index ->
+ val normalized = (rmsAmplitudes[index] / maximumAmplitude).coerceIn(0.0, 1.0)
+ (normalized.pow(WaveformSensitivity) * UnsignedByteMaximum)
+ .toInt()
+ .coerceIn(0, UnsignedByteMaximum)
+ .toByte()
+ }
+ }
+
+ private fun accumulateSamples(
+ buffer: ByteBuffer,
+ info: MediaCodec.BufferInfo,
+ sampleRate: Int,
+ channelCount: Int,
+ pcmEncoding: Int,
+ durationUs: Long,
+ squaredAmplitudes: DoubleArray,
+ frameCounts: IntArray,
+ ) {
+ if (sampleRate <= 0 || channelCount <= 0) return
+ val sampleBytes =
+ when (pcmEncoding) {
+ AudioFormat.ENCODING_PCM_FLOAT -> Float.SIZE_BYTES
+ else -> Short.SIZE_BYTES
+ }
+ val frameSize = sampleBytes * channelCount
+ val frameCount = info.size / frameSize
+ val samples = buffer.duplicate().order(ByteOrder.LITTLE_ENDIAN)
+ samples.position(info.offset)
+ samples.limit(info.offset + info.size)
+
+ repeat(frameCount) { frame ->
+ var frameSquaredAmplitude = 0.0
+ repeat(channelCount) {
+ val value =
+ if (pcmEncoding == AudioFormat.ENCODING_PCM_FLOAT) {
+ abs(samples.float).coerceIn(0f, 1f).toDouble()
+ } else {
+ abs(samples.short.toInt() / Short.MAX_VALUE.toFloat()).coerceAtMost(1f).toDouble()
+ }
+ frameSquaredAmplitude += value * value
+ }
+ val frameTimeUs = info.presentationTimeUs + frame * 1_000_000L / sampleRate
+ val bin =
+ (frameTimeUs * squaredAmplitudes.size / durationUs)
+ .toInt()
+ .coerceIn(squaredAmplitudes.indices)
+ squaredAmplitudes[bin] += frameSquaredAmplitude / channelCount
+ frameCounts[bin]++
+ }
+ }
+
+ private class DataSourceMediaDataSource(
+ private val dataSourceFactory: DataSource.Factory,
+ private val dataSpec: DataSpec,
+ ) : MediaDataSource() {
+ private var dataSource: DataSource? = null
+ private var nextPosition = 0L
+
+ @Synchronized
+ override fun readAt(
+ position: Long,
+ buffer: ByteArray,
+ offset: Int,
+ size: Int,
+ ): Int {
+ if (position < 0L) return -1
+ if (size == 0) return 0
+ if (dataSource == null || position != nextPosition) openAt(position)
+ val source = checkNotNull(dataSource)
+ val bytesRead = source.read(buffer, offset, size)
+ if (bytesRead > 0) {
+ nextPosition += bytesRead
+ } else if (bytesRead == C.RESULT_END_OF_INPUT) {
+ closeSource()
+ }
+ return bytesRead
+ }
+
+ override fun getSize(): Long = C.LENGTH_UNSET.toLong()
+
+ @Synchronized
+ override fun close() {
+ closeSource()
+ }
+
+ private fun openAt(position: Long) {
+ closeSource()
+ val source = dataSourceFactory.createDataSource()
+ try {
+ source.open(
+ dataSpec
+ .buildUpon()
+ .setPosition(position)
+ .setLength(C.LENGTH_UNSET.toLong())
+ .build(),
+ )
+ } catch (exception: IOException) {
+ source.close()
+ throw exception
+ }
+ dataSource = source
+ nextPosition = position
+ }
+
+ private fun closeSource() {
+ val source = dataSource
+ dataSource = null
+ nextPosition = 0L
+ source?.close()
+ }
+ }
+
+ internal const val BinCount = 48
+ private const val CodecBufferTimeoutUs = 10_000L
+ private const val UnsignedByteMaximum = 255
+ private const val WaveformSensitivity = 0.65
+}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/playback/DownloadUtil.kt b/app/src/main/kotlin/moe/rukamori/archivetune/playback/DownloadUtil.kt
index 40aef6f843..18f8da76ad 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/playback/DownloadUtil.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/playback/DownloadUtil.kt
@@ -14,6 +14,7 @@ import androidx.media3.common.C
import androidx.media3.database.DatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
+import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.HttpDataSource
import androidx.media3.datasource.TransferListener
import androidx.media3.datasource.ResolvingDataSource
@@ -33,16 +34,18 @@ import androidx.media3.exoplayer.offline.DownloadProgress
import androidx.media3.exoplayer.offline.DownloaderFactory
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -54,6 +57,7 @@ import moe.rukamori.archivetune.db.entities.SongEntity
import moe.rukamori.archivetune.di.DownloadCache
import moe.rukamori.archivetune.di.PlayerCache
import moe.rukamori.archivetune.downloads.DownloadedArtworkRepository
+import moe.rukamori.archivetune.downloads.ExportCompletedDownloadUseCase
import moe.rukamori.archivetune.innertube.YouTube
import moe.rukamori.archivetune.playback.stream.AudioStreamRequest
import moe.rukamori.archivetune.playback.stream.ResolveAudioStreamUseCase
@@ -63,6 +67,7 @@ import moe.rukamori.archivetune.utils.StreamClientUtils
import moe.rukamori.archivetune.utils.YTPlayerUtils
import moe.rukamori.archivetune.utils.enumPreference
import moe.rukamori.archivetune.utils.isLowDataModeActive
+import moe.rukamori.archivetune.utils.isLocalMediaId
import okhttp3.ConnectionPool
import okhttp3.OkHttpClient
import timber.log.Timber
@@ -86,6 +91,7 @@ class DownloadUtil
@PlayerCache val playerCache: Cache,
private val downloadedArtworkRepository: DownloadedArtworkRepository,
private val resolveAudioStream: ResolveAudioStreamUseCase,
+ private val exportCompletedDownload: ExportCompletedDownloadUseCase,
) {
private val audioQuality by enumPreference(context, AudioQualityKey, AudioQuality.AUTO)
private val downloadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -93,6 +99,7 @@ class DownloadUtil
private val artworkJobs = mutableMapOf()
private val downloadPreloadLock = Any()
private val playbackCacheReuseIds = ConcurrentHashMap.newKeySet()
+ private val exportingDownloadIds = ConcurrentHashMap.newKeySet()
private val persistedMetadata = ConcurrentHashMap()
private var downloadPreloadTargetId: String? = null
@@ -226,7 +233,20 @@ class DownloadUtil
}
if (download.state == Download.STATE_COMPLETED) {
scheduleDownloadedArtwork(download.request.id)
+ exportDownloadedAudio(download)
playbackCacheReuseIds.remove(download.request.id)
+ downloadScope.launch {
+ val song = database.getSongByIdBlocking(download.request.id)?.song
+ if (song != null) {
+ val now = LocalDateTime.now()
+ database.update(
+ song.copy(
+ dateDownload = song.dateDownload ?: now,
+ dateAdded = song.dateAdded ?: now,
+ ),
+ )
+ }
+ }
} else if (download.state == Download.STATE_FAILED) {
playbackCacheReuseIds.remove(download.request.id)
invalidateResolvedStream(download.request.id)
@@ -274,6 +294,7 @@ class DownloadUtil
for (download in result.values) {
if (download.state == Download.STATE_COMPLETED) {
scheduleDownloadedArtwork(download.request.id).join()
+ exportDownloadedAudio(download)
}
}
}
@@ -297,6 +318,21 @@ class DownloadUtil
}
}
+ private fun exportDownloadedAudio(download: Download) {
+ if (!exportingDownloadIds.add(download.request.id)) return
+ downloadScope.launch {
+ try {
+ exportCompletedDownload(download)
+ } catch (exception: CancellationException) {
+ throw exception
+ } catch (exception: Exception) {
+ Timber.tag(TAG).e(exception, "Unable to export completed download %s", download.request.id)
+ } finally {
+ exportingDownloadIds.remove(download.request.id)
+ }
+ }
+ }
+
private fun invalidateResolvedStream(mediaId: String) =
resolveAudioStream.invalidate(mediaId, StreamPurpose.DOWNLOAD)
@@ -370,6 +406,34 @@ class DownloadUtil
fun getDownload(songId: String): Flow = downloads.map { it[songId] }
+ suspend fun generateWaveform(
+ mediaId: String,
+ durationMs: Int,
+ ): ByteArray =
+ withContext(Dispatchers.IO) {
+ val dataSourceFactory =
+ if (mediaId.isLocalMediaId()) {
+ DefaultDataSource.Factory(context)
+ } else {
+ CacheDataSource.Factory()
+ .setCache(downloadCache)
+ .setUpstreamDataSourceFactory(invalidatingNetworkDataSourceFactory)
+ .setCacheWriteDataSinkFactory(null)
+ }
+ val dataSpec =
+ DataSpec
+ .Builder()
+ .setUri(mediaId.toUri())
+ .apply {
+ if (!mediaId.isLocalMediaId()) setKey(mediaId)
+ }.build()
+ AudioWaveformAnalyzer.generate(
+ dataSourceFactory = dataSourceFactory,
+ dataSpec = dataSpec,
+ fallbackDurationMs = durationMs,
+ )
+ }
+
private fun resolveDownloadAudioQuality(lowDataModeActive: Boolean): AudioQuality =
if (lowDataModeActive) AudioQuality.LOW else audioQuality
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/playback/MediaLibrarySessionCallback.kt b/app/src/main/kotlin/moe/rukamori/archivetune/playback/MediaLibrarySessionCallback.kt
index 495bb387f2..29b169622a 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/playback/MediaLibrarySessionCallback.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/playback/MediaLibrarySessionCallback.kt
@@ -93,6 +93,7 @@ class MediaLibrarySessionCallback
var toggleLike: () -> Unit = {}
var toggleStartRadio: () -> Unit = {}
var toggleLibrary: () -> Unit = {}
+ var dismissPlayback: () -> Unit = {}
var carMediaButtonPreferences: (AndroidAutoConfiguration) -> List = { emptyList() }
private data class AutoPlaylistSortOption(
@@ -131,29 +132,25 @@ class MediaLibrarySessionCallback
session: MediaSession,
controller: MediaSession.ControllerInfo,
): MediaSession.ConnectionResult {
- val connectionResult = super.onConnect(session, controller)
- val availableSessionCommands = connectionResult.availableSessionCommands
+ val builder = MediaSession.ConnectionResult.AcceptedResultBuilder(session, controller)
+ val defaults = builder.build()
+ val availableSessionCommands = defaults.availableSessionCommands
.buildUpon()
.add(MediaSessionConstants.CommandToggleLike)
.add(MediaSessionConstants.CommandToggleStartRadio)
.add(MediaSessionConstants.CommandToggleLibrary)
.add(MediaSessionConstants.CommandToggleShuffle)
.add(MediaSessionConstants.CommandToggleRepeatMode)
+ .add(MediaSessionConstants.CommandDismissPlayback)
.build()
- return if (session.isAutoCompanionController(controller) || session.isAutomotiveController(controller)) {
+ builder.setAvailableSessionCommands(availableSessionCommands)
+ if (isCarController(session, controller)) {
val carButtons = carMediaButtonPreferences(androidAutoSettings.currentConfiguration())
- MediaSession.ConnectionResult.AcceptedResultBuilder(session)
- .setAvailableSessionCommands(availableSessionCommands)
- .setAvailablePlayerCommands(connectionResult.availablePlayerCommands)
+ builder
.setMediaButtonPreferences(carButtons)
.setCustomLayout(carButtons)
- .build()
- } else {
- MediaSession.ConnectionResult.accept(
- availableSessionCommands,
- connectionResult.availablePlayerCommands,
- )
}
+ return builder.build()
}
fun isCarController(session: MediaSession, controller: MediaSession.ControllerInfo): Boolean =
@@ -248,6 +245,10 @@ class MediaLibrarySessionCallback
session.player.toggleRepeatMode()
true
}
+ MediaSessionConstants.ACTION_DISMISS_PLAYBACK -> {
+ dismissPlayback()
+ true
+ }
else -> false
}
return Futures.immediateFuture(
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/playback/MusicService.kt b/app/src/main/kotlin/moe/rukamori/archivetune/playback/MusicService.kt
index 79df15c943..f7b396e740 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/playback/MusicService.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/playback/MusicService.kt
@@ -112,6 +112,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -171,6 +172,7 @@ import moe.rukamori.archivetune.constants.MediaSessionConstants.CommandToggleLik
import moe.rukamori.archivetune.constants.MediaSessionConstants.CommandToggleRepeatMode
import moe.rukamori.archivetune.constants.MediaSessionConstants.CommandToggleShuffle
import moe.rukamori.archivetune.constants.MediaSessionConstants.CommandToggleStartRadio
+import moe.rukamori.archivetune.constants.MediaSessionConstants
import moe.rukamori.archivetune.constants.PauseListenHistoryKey
import moe.rukamori.archivetune.constants.PauseOnDeviceMuteKey
import moe.rukamori.archivetune.constants.PermanentShuffleKey
@@ -366,8 +368,12 @@ class MusicService :
private var scope = CoroutineScope(Dispatchers.Main + scopeJob)
private var ioScope = CoroutineScope(Dispatchers.IO + scopeJob)
private val binder = MusicBinder(this)
- private var hasBoundClients = false
+ private var boundClientCount = 0
+ private val hasBoundClients: Boolean
+ get() = boundClientCount > 0
+ private var isPlaybackServiceStarted = false
private var idleStopJob: Job? = null
+ private lateinit var mediaNotificationProvider: ArchiveTuneMediaNotificationProvider
private lateinit var connectivityManager: ConnectivityManager
lateinit var connectivityObserver: NetworkConnectivityObserver
@@ -416,10 +422,30 @@ class MusicService :
chain.proceed(
requestBuilder.build(),
)
+ }.addNetworkInterceptor { chain ->
+ val request = chain.request()
+ val response = chain.proceed(request)
+ if (response.code == 403 &&
+ (request.url.host == "googlevideo.com" || request.url.host.endsWith(".googlevideo.com"))
+ ) {
+ Timber.tag("YoutubeiResolver").w(
+ "Media HTTP 403 tokenPresent=%s nPresent=%s nError=%s userAgentPresent=%s " +
+ "rangePresent=%s redirected=%s routeProxy=%s",
+ !request.url.queryParameter("pot").isNullOrBlank(),
+ !request.url.queryParameter("n").isNullOrBlank(),
+ request.url.queryParameter("n")?.startsWith("enhanced_except_") == true,
+ !request.header("User-Agent").isNullOrBlank(),
+ request.header("Range") != null,
+ request.url.host != chain.call().request().url.host,
+ chain.connection()?.route()?.proxy?.type()?.name ?: "UNKNOWN",
+ )
+ }
+ response
}.build()
}
private var currentQueue: Queue = EmptyQueue
+ private var queueLoadJob: Job? = null
var queueTitle: String? = null
private var blockedArtistIds: Set = emptySet()
private var hideMusicVideos = false
@@ -485,10 +511,17 @@ class MusicService :
val queueRestoreCompleted = MutableStateFlow(false)
val infiniteQueueLoading = MutableStateFlow(false)
private val playerInitialized = MutableStateFlow(false)
+ private val preloadedSongs = ConcurrentHashMap()
+ @Volatile private var preloadedNextSongId: String? = null
+ private var nextSongPreloadJob: Job? = null
private val currentSong =
currentMediaMetadata
.flatMapLatest { mediaMetadata ->
- database.song(mediaMetadata?.id)
+ val mediaId = mediaMetadata?.id
+ val preloaded = mediaId?.let(preloadedSongs::remove)
+ database.song(mediaId).onStart {
+ if (preloaded != null) emit(preloaded)
+ }
}.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Lazily, null)
private val currentFormat =
@@ -712,6 +745,7 @@ class MusicService :
private set
lateinit var player: Player
private set
+ private var pendingQueueRemoval: QueueRemoval? = null
private lateinit var castPlaybackRepository: CastPlaybackRepository
private lateinit var mediaSession: MediaLibrarySession
@@ -768,9 +802,6 @@ class MusicService :
val maxSafeGainFactor = MAX_AUDIO_NORMALIZATION_FACTOR
- @Volatile
- private var hasCalledStartForeground = false
-
val togetherSessionState =
MutableStateFlow(
moe.rukamori.archivetune.together.TogetherSessionState.Idle,
@@ -992,54 +1023,65 @@ class MusicService :
return generated
}
- private fun ensureStartedAsForeground() {
- if (hasCalledStartForeground) return
+ private fun buildStartupNotification(): Notification {
+ val contentIntent =
+ PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
- val notification =
- try {
- val contentIntent =
- PendingIntent.getActivity(
- this,
- 0,
- Intent(this, MainActivity::class.java),
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
- )
+ return NotificationCompat
+ .Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.small_icon)
+ .setContentTitle(getString(R.string.music_player))
+ .setContentText(getString(R.string.app_name))
+ .setContentIntent(contentIntent)
+ .setCategory(Notification.CATEGORY_SERVICE)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .build()
+ }
- NotificationCompat
- .Builder(this, CHANNEL_ID)
- .setSmallIcon(R.drawable.small_icon)
- .setContentTitle(getString(R.string.music_player))
- .setContentText(getString(R.string.app_name))
- .setContentIntent(contentIntent)
- .setCategory(Notification.CATEGORY_SERVICE)
- .setPriority(NotificationCompat.PRIORITY_LOW)
- .setOngoing(true)
- .setOnlyAlertOnce(true)
- .build()
- } catch (e: Exception) {
- reportException(e)
- return
- }
+ private fun enterForegroundForStartCommand() {
+ val mediaNotification =
+ if (hasResumablePlaybackNotification()) mediaNotificationProvider.latestNotification else null
+ val notificationId = mediaNotification?.notificationId ?: NOTIFICATION_ID
+ val notification =
+ mediaNotification?.notification
+ ?: runCatching { buildStartupNotification() }
+ .onFailure { reportException(it) }
+ .getOrNull()
+ ?: return
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
- NOTIFICATION_ID,
+ notificationId,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
)
} else {
- startForeground(NOTIFICATION_ID, notification)
+ startForeground(notificationId, notification)
}
- hasCalledStartForeground = true
} catch (e: Exception) {
reportException(e)
}
}
- private fun promoteToStartedService() {
- runCatching { startService(Intent(this, MusicService::class.java)) }
- .onFailure { reportException(it) }
+ private fun ensureForegroundPlaybackService() {
+ if (!isPlaybackServiceStarted) {
+ try {
+ isPlaybackServiceStarted =
+ startService(Intent(this, MusicService::class.java)) != null
+ } catch (exception: Exception) {
+ reportException(exception)
+ return
+ }
+ }
+ enterForegroundForStartCommand()
}
private fun cancelIdleStop() {
@@ -1057,6 +1099,7 @@ class MusicService :
private fun stopForegroundAndSelf() {
cancelIdleStop()
+ isPlaybackServiceStarted = false
runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
@@ -1064,7 +1107,7 @@ class MusicService :
stopForeground(true)
}
}
- hasCalledStartForeground = false
+ mediaNotificationProvider.clearLatestNotification()
stopSelf()
}
@@ -1072,8 +1115,6 @@ class MusicService :
if (hasBoundClients) return
if (hasResumablePlaybackNotification()) {
cancelIdleStop()
- promoteToStartedService()
- ensureStartedAsForeground()
return
}
val togetherIdle = togetherSessionState.value is moe.rukamori.archivetune.together.TogetherSessionState.Idle
@@ -1157,7 +1198,10 @@ class MusicService :
mediaItemResolver =
object : CastMediaItemResolver {
override fun resolveForCast(mediaItem: MediaItem): MediaItem =
- resolveMediaItemForCast(mediaItem)
+ resolveMediaItemForCast(mediaItem, blocking = false)
+
+ override fun resolveForCastBlocking(mediaItem: MediaItem): MediaItem =
+ resolveMediaItemForCast(mediaItem, blocking = true)
override fun mimeTypeForCast(mediaItem: MediaItem): String? =
mediaItem.localConfiguration?.mimeType
@@ -1224,6 +1268,10 @@ class MusicService :
toggleLike = ::toggleLike
toggleStartRadio = ::toggleStartRadio
toggleLibrary = ::toggleLibrary
+ dismissPlayback = {
+ stopAndClearPlayback(clearPersistentState = true)
+ stopForegroundAndSelf()
+ }
carMediaButtonPreferences = ::buildAndroidAutoButtons
}
val mediaSessionBuilder = MediaLibrarySession.Builder(this, player, mediaLibrarySessionCallback)
@@ -1239,12 +1287,12 @@ class MusicService :
)
}
mediaSession = mediaSessionBuilder.build()
- setMediaNotificationProvider(
+ mediaNotificationProvider =
ArchiveTuneMediaNotificationProvider(
context = this,
smallIconResId = R.drawable.small_icon,
- ),
- )
+ )
+ setMediaNotificationProvider(mediaNotificationProvider)
addSession(mediaSession)
updateNotification()
@@ -3805,6 +3853,12 @@ class MusicService :
try {
val customLayout =
listOf(
+ CommandButton
+ .Builder()
+ .setDisplayName(getString(R.string.dismiss_player))
+ .setIconResId(R.drawable.close)
+ .setSessionCommand(MediaSessionConstants.CommandDismissPlayback)
+ .build(),
CommandButton
.Builder()
.setDisplayName(
@@ -3925,11 +3979,6 @@ class MusicService :
}
}
- fun refreshPlaybackNotification() {
- updateNotification()
- onUpdateNotification(mediaSession, hasResumablePlaybackNotification())
- }
-
private suspend fun recoverSong(
mediaId: String,
playbackData: YTPlayerUtils.PlaybackData? = null,
@@ -4050,103 +4099,115 @@ class MusicService :
}
if (playWhenReady) {
cancelIdleStop()
- promoteToStartedService()
- ensureStartedAsForeground()
+ ensureForegroundPlaybackService()
}
cancelRestoredQueueHydration()
ensureScopesActive()
cancelCrossfade(resetVolume = true, resetPauseAtEnd = true)
cancelInfiniteQueueBootstrap()
suppressAutoPlayback = false
- currentQueue = queue
- queueTitle = null
- val permanentShuffle = dataStore.get(PermanentShuffleKey, false)
- if (!permanentShuffle) {
- player.shuffleModeEnabled = false
- }
-
clearAutomix()
autoAddedMediaIds.clear()
- scope.launch(SilentHandler) {
- val hideExplicit = dataStore.get(HideExplicitKey, false)
- val hideVideo = dataStore.get(HideVideoKey, false)
- val autoLoadMoreEnabled = dataStore.get(AutoLoadMoreKey, true)
- val preloadItem =
- queue.preloadItem
- ?.toMediaItem()
- ?.takeUnless { item ->
- item.hasBlockedArtist(loadBlockedArtistIds())
+ queueLoadJob?.cancel()
+ queueLoadJob =
+ scope.launch(SilentHandler) {
+ val hideExplicit = dataStore.get(HideExplicitKey, false)
+ val hideVideo = dataStore.get(HideVideoKey, false)
+ val autoLoadMoreEnabled = dataStore.get(AutoLoadMoreKey, true)
+ val preloadItem =
+ queue.preloadItem
+ ?.toMediaItem()
+ ?.takeUnless { item ->
+ item.hasBlockedArtist(loadBlockedArtistIds())
+ }
+ if (preloadItem != null) {
+ currentQueue = EmptyQueue
+ queueTitle = null
+ player.setMediaItems(listOf(preloadItem), 0, 0L)
+ player.prepare()
+ player.playWhenReady = playWhenReady
+ }
+ var initialStatus =
+ withContext(Dispatchers.IO) {
+ queue
+ .getInitialStatus()
+ .filterPlaybackContent(hideExplicit, hideVideo)
}
- if (preloadItem != null) {
- player.setMediaItem(preloadItem)
- player.prepare()
- player.playWhenReady = playWhenReady
- }
- var initialStatus =
- withContext(Dispatchers.IO) {
- queue
- .getInitialStatus()
- .filterPlaybackContent(hideExplicit, hideVideo)
- }
- if (!autoLoadMoreEnabled && queue.shouldExpandToFullQueueWhenAutoLoadMoreDisabled() && queue.hasNextPage()) {
- val expandedItems = initialStatus.items.toMutableList()
- var pagesLoaded = 0
- while (queue.hasNextPage() && pagesLoaded < 200) {
- pagesLoaded++
- val nextItems =
- withContext(Dispatchers.IO) {
- queue
- .nextPage()
- .filterPlaybackContent(hideExplicit, hideVideo)
+ if (!autoLoadMoreEnabled && queue.shouldExpandToFullQueueWhenAutoLoadMoreDisabled() && queue.hasNextPage()) {
+ val expandedItems = initialStatus.items.toMutableList()
+ var pagesLoaded = 0
+ while (queue.hasNextPage() && pagesLoaded < 200) {
+ pagesLoaded++
+ val nextItems =
+ withContext(Dispatchers.IO) {
+ queue
+ .nextPage()
+ .filterPlaybackContent(hideExplicit, hideVideo)
+ }
+ if (nextItems.isNotEmpty()) {
+ expandedItems += nextItems
}
- if (nextItems.isNotEmpty()) {
- expandedItems += nextItems
}
- }
- initialStatus = initialStatus.copy(items = expandedItems)
- }
- if (initialStatus.title != null) {
+ initialStatus = initialStatus.copy(items = expandedItems)
+ }
+ val items = initialStatus.items.toMutableList()
+ var startIndex = initialStatus.mediaItemIndex
+ if (preloadItem != null) {
+ val preloadMediaId = preloadItem.mediaId.trim()
+ val insertionIndex =
+ initialStatus.mediaItemIndex.coerceIn(0, items.size)
+ val itemsBeforeCurrent =
+ items
+ .subList(0, insertionIndex)
+ .filterNot { preloadMediaId.isNotEmpty() && it.mediaId.trim() == preloadMediaId }
+ val itemsAfterCurrent =
+ items
+ .subList(insertionIndex, items.size)
+ .filterNot { preloadMediaId.isNotEmpty() && it.mediaId.trim() == preloadMediaId }
+ items.clear()
+ items.addAll(itemsBeforeCurrent)
+ startIndex = items.size
+ items.add(preloadItem)
+ items.addAll(itemsAfterCurrent)
+ }
+ if (items.isEmpty()) return@launch
+
+ currentQueue = queue
queueTitle = initialStatus.title
- }
- if (initialStatus.items.isEmpty()) return@launch
- if (preloadItem != null) {
- val preloadMediaId = preloadItem.mediaId.trim()
- val insertionIndex =
- initialStatus.mediaItemIndex.coerceIn(0, initialStatus.items.size)
- val itemsBeforeCurrent =
- initialStatus.items
- .subList(0, insertionIndex)
- .filterNot { preloadMediaId.isNotEmpty() && it.mediaId.trim() == preloadMediaId }
- val itemsAfterCurrent =
- initialStatus.items
- .subList(insertionIndex, initialStatus.items.size)
- .filterNot { preloadMediaId.isNotEmpty() && it.mediaId.trim() == preloadMediaId }
-
- player.addMediaItems(0, itemsBeforeCurrent)
- player.addMediaItems(itemsAfterCurrent)
- if (player.shuffleModeEnabled) {
- applyCurrentFirstShuffleOrder()
+ if (!dataStore.get(PermanentShuffleKey, false)) {
+ player.shuffleModeEnabled = false
+ }
+ val seededItemIsStillCurrent =
+ preloadItem != null &&
+ player.mediaItemCount == 1 &&
+ player.currentMediaItem?.mediaId == preloadItem.mediaId
+ if (seededItemIsStillCurrent) {
+ val safeStartIndex = startIndex.coerceIn(items.indices)
+ if (safeStartIndex > 0) {
+ player.addMediaItems(0, items.subList(0, safeStartIndex))
+ }
+ val currentIndex = player.currentMediaItemIndex
+ val itemsAfterCurrent = items.drop(safeStartIndex + 1)
+ if (itemsAfterCurrent.isNotEmpty()) {
+ player.addMediaItems(currentIndex + 1, itemsAfterCurrent)
+ }
+ } else {
+ player.setMediaItems(items, startIndex.coerceIn(items.indices), initialStatus.position)
+ player.prepare()
+ player.playWhenReady = playWhenReady
}
- } else {
- val items = initialStatus.items
- val index = initialStatus.mediaItemIndex
-
- player.setMediaItems(items, index, initialStatus.position)
- player.prepare()
- player.playWhenReady = playWhenReady
if (player.shuffleModeEnabled) {
applyCurrentFirstShuffleOrder()
}
- }
- if (
- autoLoadMoreEnabled &&
- player.currentMetadata?.isPodcast != true &&
- !queue.hasNextPage() &&
- player.mediaItemCount - player.currentMediaItemIndex <= 3
- ) {
- onInfiniteQueueEnabled()
- }
+ if (
+ autoLoadMoreEnabled &&
+ player.currentMetadata?.isPodcast != true &&
+ !queue.hasNextPage() &&
+ player.mediaItemCount - player.currentMediaItemIndex <= 3
+ ) {
+ onInfiniteQueueEnabled()
+ }
}
}
@@ -4376,6 +4437,8 @@ class MusicService :
cancelInfiniteQueueBootstrap()
suppressAutoPlayback = true
cancelCrossfade(resetVolume = true, resetPauseAtEnd = true)
+ queueLoadJob?.cancel()
+ queueLoadJob = null
clearAutomix()
currentQueue = EmptyQueue
queueTitle = null
@@ -4436,6 +4499,28 @@ class MusicService :
player.prepare()
}
+ internal fun removeQueueItems(windows: List): QueueRemoval? {
+ if (isTogetherGuestSession()) return null
+ return QueueRemoval.remove(queueEditPlayer(), windows).also { pendingQueueRemoval = it }
+ }
+
+ internal fun restoreQueueItems(removal: QueueRemoval): Boolean {
+ if (pendingQueueRemoval !== removal || isTogetherGuestSession()) return false
+ pendingQueueRemoval = null
+ return removal.restore(queueEditPlayer())
+ }
+
+ internal fun discardQueueRemoval(removal: QueueRemoval) {
+ if (pendingQueueRemoval === removal) pendingQueueRemoval = null
+ }
+
+ private fun queueEditPlayer(): Player =
+ if (player.deviceInfo.playbackType == androidx.media3.common.DeviceInfo.PLAYBACK_TYPE_LOCAL) {
+ localPlayer
+ } else {
+ player
+ }
+
fun moveQueueItemToNext(mediaItemIndex: Int) {
val currentIndex = player.currentMediaItemIndex
if (
@@ -4928,7 +5013,7 @@ class MusicService :
if (joined?.role is moe.rukamori.archivetune.together.TogetherRole.Guest) {
togetherPendingGuestControl = null
togetherLastSentControlAction = null
- scope.launch(SilentHandler) { applyRemoteRoomState(joined.roomState, force = true) }
+ restoreTogetherGuestPlayback()
}
}
@@ -5185,7 +5270,7 @@ class MusicService :
if (joined?.role is moe.rukamori.archivetune.together.TogetherRole.Guest) {
togetherPendingGuestControl = null
togetherLastSentControlAction = null
- scope.launch(SilentHandler) { applyRemoteRoomState(joined.roomState, force = true) }
+ restoreTogetherGuestPlayback()
}
}
@@ -5759,11 +5844,20 @@ class MusicService :
}
}
+ private fun restoreTogetherGuestPlayback() {
+ scope.launch(SilentHandler) {
+ val joined = togetherSessionState.value as? moe.rukamori.archivetune.together.TogetherSessionState.Joined
+ if (joined?.role is moe.rukamori.archivetune.together.TogetherRole.Guest && !isTogetherApplyingRemote()) {
+ applyRemoteRoomState(joined.roomState, force = true)
+ }
+ }
+ }
+
private suspend fun applyRemoteRoomState(
state: moe.rukamori.archivetune.together.TogetherRoomState,
force: Boolean = false,
- ) {
- val pid = togetherSelfParticipantId ?: return
+ ) = withContext(Dispatchers.Main) {
+ val pid = togetherSelfParticipantId ?: return@withContext
val now = android.os.SystemClock.elapsedRealtime()
val pending = togetherPendingGuestControl
@@ -5784,7 +5878,7 @@ class MusicService :
}
togetherPendingGuestControl = null
} else {
- if (mismatch) return
+ if (mismatch) return@withContext
togetherPendingGuestControl = null
}
}
@@ -5796,7 +5890,7 @@ class MusicService :
force = force,
)
) {
- return
+ return@withContext
}
val targetPos =
@@ -5807,97 +5901,95 @@ class MusicService :
nowElapsedRealtimeMs = now,
)
- withContext(Dispatchers.Main) {
- togetherApplyingRemote = true
- togetherSuppressEchoUntilElapsedMs =
- TogetherPlaybackSync.echoSuppressionUntil(
- android.os.SystemClock.elapsedRealtime(),
+ togetherApplyingRemote = true
+ togetherSuppressEchoUntilElapsedMs =
+ TogetherPlaybackSync.echoSuppressionUntil(
+ android.os.SystemClock.elapsedRealtime(),
+ )
+ try {
+ val desiredItems = state.queue.map { it.toMediaMetadata().toMediaItem() }
+ val desiredIds = state.queue.map { it.id }
+ val desiredHash = state.queueHash
+ val localIds = player.mediaItems.mapNotNull { it.metadata?.id ?: it.mediaId }.filter { it.isNotBlank() }
+ val localHash =
+ if (localIds.isEmpty()) {
+ ""
+ } else {
+ moe.rukamori.archivetune.utils
+ .md5(localIds.joinToString(separator = "|"))
+ }
+ val needsRebuild =
+ TogetherPlaybackSync.needsQueueRebuild(
+ desiredHash = desiredHash,
+ desiredIds = desiredIds,
+ localHash = localHash,
+ localIds = localIds,
)
- try {
- val desiredItems = state.queue.map { it.toMediaMetadata().toMediaItem() }
- val desiredIds = state.queue.map { it.id }
- val desiredHash = state.queueHash
- val localIds = player.mediaItems.mapNotNull { it.metadata?.id ?: it.mediaId }.filter { it.isNotBlank() }
- val localHash =
- if (localIds.isEmpty()) {
- ""
+
+ if (desiredItems.isNotEmpty() && needsRebuild) {
+ togetherLastAppliedQueueHash = desiredHash.ifBlank { localHash }
+ val startIndex = state.currentIndex.coerceIn(0, desiredItems.lastIndex)
+ suppressAutoPlayback = false
+ currentQueue =
+ moe.rukamori.archivetune.playback.queues.ListQueue(
+ title = getString(R.string.music_player),
+ items = desiredItems,
+ startIndex = startIndex,
+ position = targetPos,
+ )
+ queueTitle = null
+ player.setMediaItems(desiredItems, startIndex, targetPos)
+ player.prepare()
+ player.repeatMode = state.repeatMode
+ player.shuffleModeEnabled = state.shuffleEnabled
+ player.playWhenReady = state.isPlaying
+ togetherLastRemoteAppliedIndex = startIndex
+ } else {
+ val index =
+ if (player.mediaItemCount > 0) {
+ state.currentIndex.coerceIn(0, player.mediaItemCount - 1)
} else {
- moe.rukamori.archivetune.utils
- .md5(localIds.joinToString(separator = "|"))
+ 0
}
- val needsRebuild =
- TogetherPlaybackSync.needsQueueRebuild(
- desiredHash = desiredHash,
- desiredIds = desiredIds,
- localHash = localHash,
- localIds = localIds,
- )
+ val indexChanged = player.mediaItemCount > 0 && index != player.currentMediaItemIndex
- if (desiredItems.isNotEmpty() && needsRebuild) {
- togetherLastAppliedQueueHash = desiredHash.ifBlank { localHash }
- val startIndex = state.currentIndex.coerceIn(0, desiredItems.lastIndex)
- suppressAutoPlayback = false
- currentQueue =
- moe.rukamori.archivetune.playback.queues.ListQueue(
- title = getString(R.string.music_player),
- items = desiredItems,
- startIndex = startIndex,
- position = targetPos,
- )
- queueTitle = null
- player.setMediaItems(desiredItems, startIndex, targetPos)
+ if (indexChanged) {
+ if (player.repeatMode != state.repeatMode) player.repeatMode = state.repeatMode
+ if (player.shuffleModeEnabled != state.shuffleEnabled) player.shuffleModeEnabled = state.shuffleEnabled
+ player.seekTo(index, targetPos)
player.prepare()
- player.repeatMode = state.repeatMode
- player.shuffleModeEnabled = state.shuffleEnabled
player.playWhenReady = state.isPlaying
- togetherLastRemoteAppliedIndex = startIndex
} else {
- val index =
- if (player.mediaItemCount > 0) {
- state.currentIndex.coerceIn(0, player.mediaItemCount - 1)
- } else {
- 0
- }
- val indexChanged = player.mediaItemCount > 0 && index != player.currentMediaItemIndex
-
- if (indexChanged) {
- if (player.repeatMode != state.repeatMode) player.repeatMode = state.repeatMode
- if (player.shuffleModeEnabled != state.shuffleEnabled) player.shuffleModeEnabled = state.shuffleEnabled
- player.seekTo(index, targetPos)
+ val playbackStateChanged = player.playWhenReady != state.isPlaying
+ if (player.repeatMode != state.repeatMode) player.repeatMode = state.repeatMode
+ if (player.shuffleModeEnabled != state.shuffleEnabled) player.shuffleModeEnabled = state.shuffleEnabled
+ if (playbackStateChanged) player.playWhenReady = state.isPlaying
+ val shouldSeekForDrift =
+ TogetherPlaybackSync.shouldSeekForDrift(
+ currentPositionMs = player.currentPosition,
+ targetPositionMs = targetPos,
+ isPlaying = state.isPlaying,
+ isOnlineSession = togetherIsOnlineSession,
+ )
+ if (shouldSeekForDrift || (playbackStateChanged && !state.isPlaying)) {
+ player.seekTo(targetPos)
player.prepare()
- player.playWhenReady = state.isPlaying
- } else {
- val playbackStateChanged = player.playWhenReady != state.isPlaying
- if (player.repeatMode != state.repeatMode) player.repeatMode = state.repeatMode
- if (player.shuffleModeEnabled != state.shuffleEnabled) player.shuffleModeEnabled = state.shuffleEnabled
- if (playbackStateChanged) player.playWhenReady = state.isPlaying
- val shouldSeekForDrift =
- TogetherPlaybackSync.shouldSeekForDrift(
- currentPositionMs = player.currentPosition,
- targetPositionMs = targetPos,
- isPlaying = state.isPlaying,
- isOnlineSession = togetherIsOnlineSession,
- )
- if (shouldSeekForDrift || (playbackStateChanged && !state.isPlaying)) {
- player.seekTo(targetPos)
- player.prepare()
- }
}
- togetherLastRemoteAppliedIndex = index
}
- togetherLastRemoteAppliedPlayWhenReady = state.isPlaying
- togetherLastAppliedRoomStateSentAtElapsedMs = sentAt
-
- togetherSessionState.value =
- moe.rukamori.archivetune.together.TogetherSessionState.Joined(
- role = moe.rukamori.archivetune.together.TogetherRole.Guest,
- sessionId = state.sessionId,
- selfParticipantId = pid,
- roomState = state,
- )
- } finally {
- togetherApplyingRemote = false
+ togetherLastRemoteAppliedIndex = index
}
+ togetherLastRemoteAppliedPlayWhenReady = state.isPlaying
+ togetherLastAppliedRoomStateSentAtElapsedMs = sentAt
+
+ togetherSessionState.value =
+ moe.rukamori.archivetune.together.TogetherSessionState.Joined(
+ role = moe.rukamori.archivetune.together.TogetherRole.Guest,
+ sessionId = state.sessionId,
+ selfParticipantId = pid,
+ roomState = state,
+ )
+ } finally {
+ togetherApplyingRemote = false
}
}
@@ -6712,10 +6804,11 @@ class MusicService :
val joined = togetherSessionState.value as? moe.rukamori.archivetune.together.TogetherSessionState.Joined
if (joined?.role is moe.rukamori.archivetune.together.TogetherRole.Guest &&
- reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK
+ reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK &&
+ !isTogetherApplyingRemote()
) {
if (!joined.roomState.settings.allowGuestsToControlPlayback) {
- scope.launch(SilentHandler) { applyRemoteRoomState(joined.roomState, force = true) }
+ restoreTogetherGuestPlayback()
return
}
val now = android.os.SystemClock.elapsedRealtime()
@@ -6930,48 +7023,79 @@ class MusicService :
return
}
- val nextMediaItemIndex = player.nextMediaItemIndex
- if (nextMediaItemIndex == C.INDEX_UNSET ||
- nextMediaItemIndex == player.currentMediaItemIndex ||
- nextMediaItemIndex !in 0 until player.mediaItemCount
- ) {
- nextStreamPreloader.cancel()
- return
- }
-
- val nextMediaItem = player.getMediaItemAt(nextMediaItemIndex)
- val nextMediaId =
- (nextMediaItem.localConfiguration?.customCacheKey ?: nextMediaItem.mediaId)
- .trim()
- .takeIf(String::isNotEmpty)
+ val timeline = player.currentTimeline
+ val currentIndex = player.currentMediaItemIndex
val currentMediaId =
player.currentMediaItem
?.let { mediaItem -> mediaItem.localConfiguration?.customCacheKey ?: mediaItem.mediaId }
?.trim()
?.takeIf(String::isNotEmpty)
- val nextUri = nextMediaItem.localConfiguration?.uri
- if (nextMediaId == null ||
- nextMediaId == currentMediaId ||
- nextMediaId.isLocalMediaId() ||
- nextUri == null ||
- nextUri.shouldBypassYouTubeResolver()
- ) {
- nextStreamPreloader.cancel()
+ if (currentIndex !in 0 until timeline.windowCount || currentMediaId == null) {
+ nextStreamPreloader.updateTargets(emptyList())
return
}
- nextStreamPreloader.updateTarget(
- request =
+ val previousIndex =
+ timeline.getPreviousWindowIndex(
+ currentIndex,
+ player.repeatMode,
+ player.shuffleModeEnabled,
+ )
+ val candidateIndexes = listOf(previousIndex, player.nextMediaItemIndex).distinct()
+ val requests =
+ candidateIndexes.mapNotNull { index ->
+ if (index !in 0 until player.mediaItemCount || index == currentIndex) return@mapNotNull null
+
+ val mediaItem = player.getMediaItemAt(index)
+ val mediaId =
+ (mediaItem.localConfiguration?.customCacheKey ?: mediaItem.mediaId)
+ .trim()
+ .takeIf(String::isNotEmpty)
+ val uri = mediaItem.localConfiguration?.uri
+ if (mediaId == null ||
+ mediaId == currentMediaId ||
+ mediaId.isLocalMediaId() ||
+ uri == null ||
+ uri.shouldBypassYouTubeResolver()
+ ) {
+ return@mapNotNull null
+ }
+
AudioStreamRequest(
- mediaId = nextMediaId,
+ mediaId = mediaId,
playlistId = null,
quality = configuration.quality,
networkMetered = false,
purpose = StreamPurpose.PLAYBACK,
authState = configuration.authState,
pinnedFormatId = null,
- ),
- )
+ )
+ }
+ nextStreamPreloader.updateTargets(requests)
+ }
+
+ private fun preloadNextSongSnapshot() {
+ val nextIndex = player.nextMediaItemIndex
+ if (nextIndex !in 0 until player.mediaItemCount) {
+ nextSongPreloadJob?.cancel()
+ nextSongPreloadJob = null
+ preloadedNextSongId?.let(preloadedSongs::remove)
+ preloadedNextSongId = null
+ return
+ }
+
+ val nextSongId = player.getMediaItemAt(nextIndex).mediaId
+ if (nextSongId == preloadedNextSongId) return
+
+ nextSongPreloadJob?.cancel()
+ preloadedNextSongId?.let(preloadedSongs::remove)
+ preloadedNextSongId = nextSongId
+ nextSongPreloadJob = scope.launch(Dispatchers.IO) {
+ val song = database.song(nextSongId).first() ?: return@launch
+ if (preloadedNextSongId == nextSongId) {
+ preloadedSongs[nextSongId] = song
+ }
+ }
}
override fun onEvents(
@@ -6987,6 +7111,7 @@ class MusicService :
)
) {
updateNextStreamPreload()
+ preloadNextSongSnapshot()
}
val currentMediaId = player.currentMediaItem?.mediaId
if (currentMediaId == null && currentHistoryMediaId != null) {
@@ -7033,10 +7158,11 @@ class MusicService :
}
val joined = togetherSessionState.value as? moe.rukamori.archivetune.together.TogetherSessionState.Joined
if (joined?.role is moe.rukamori.archivetune.together.TogetherRole.Guest &&
- events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)
+ events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED) &&
+ !isTogetherApplyingRemote()
) {
if (!joined.roomState.settings.allowGuestsToControlPlayback) {
- scope.launch(SilentHandler) { applyRemoteRoomState(joined.roomState, force = true) }
+ restoreTogetherGuestPlayback()
} else {
val now = android.os.SystemClock.elapsedRealtime()
val playWhenReady = this.player.playWhenReady
@@ -7086,10 +7212,11 @@ class MusicService :
ensureAudioFocusForActivePlayback()
}
updateWakeLock()
+ if (player.playWhenReady) {
+ ensureForegroundPlaybackService()
+ }
if (hasResumablePlaybackNotification()) {
cancelIdleStop()
- promoteToStartedService()
- ensureStartedAsForeground()
} else {
scheduleStopIfIdle()
}
@@ -7252,7 +7379,7 @@ class MusicService :
if (joined?.role is moe.rukamori.archivetune.together.TogetherRole.Guest) {
if (!isTogetherApplyingRemote()) {
if (!joined.roomState.settings.allowGuestsToControlPlayback) {
- scope.launch(SilentHandler) { applyRemoteRoomState(joined.roomState, force = true) }
+ restoreTogetherGuestPlayback()
return
}
requestTogetherControl(
@@ -7284,7 +7411,7 @@ class MusicService :
if (joined?.role is moe.rukamori.archivetune.together.TogetherRole.Guest) {
if (!isTogetherApplyingRemote()) {
if (!joined.roomState.settings.allowGuestsToControlPlayback) {
- scope.launch(SilentHandler) { applyRemoteRoomState(joined.roomState, force = true) }
+ restoreTogetherGuestPlayback()
return
}
requestTogetherControl(
@@ -7396,14 +7523,8 @@ class MusicService :
}
if (!isLocalMedia && !isFullyDownloadedMedia && YTPlayerUtils.isBotDetectionException(error)) {
- val recoverySnapshot = capturePlaybackRecoverySnapshot()
resolveAudioStream.invalidate(currentMediaId)
YTPlayerUtils.invalidateCachedStreamUrls(currentMediaId)
- YTPlayerUtils.clearPlaybackAuthCaches()
- if (playbackStreamRecoveryTracker.registerRetryAttempt(currentMediaId)) {
- Timber.tag("MusicService").i("Retrying playback for %s after bot-detection source error", currentMediaId)
- if (preparePlaybackFromSnapshot(recoverySnapshot)) return
- }
}
if (!isLocalMedia && !isFullyDownloadedMedia && YTPlayerUtils.isBadStreamPlayerResponseException(error)) {
@@ -7447,6 +7568,12 @@ class MusicService :
}
}
+ if (isLocalMedia && error.hasMissingLocalMediaCause()) {
+ Timber.tag("MusicService").w(error, "Skipping unavailable local media item %s", currentMediaId)
+ skipOnError()
+ return
+ }
+
if (dataStore.get(AutoSkipNextOnErrorKey, false)) {
skipOnError()
} else {
@@ -7454,6 +7581,10 @@ class MusicService :
}
}
+ private fun PlaybackException.hasMissingLocalMediaCause(): Boolean =
+ generateSequence(this) { throwable -> throwable.cause }
+ .any { throwable -> throwable is java.io.FileNotFoundException }
+
private suspend fun trimPlayerCacheToBytes(limitBytes: Long) {
if (limitBytes <= 0L) return
@@ -7572,25 +7703,33 @@ class MusicService :
}
}
- private fun resolveMediaItemForCast(mediaItem: MediaItem): MediaItem {
+ private fun resolveMediaItemForCast(
+ mediaItem: MediaItem,
+ blocking: Boolean,
+ ): MediaItem {
val localConfiguration = mediaItem.localConfiguration ?: return mediaItem
val uri = localConfiguration.uri
if (uri.shouldBypassYouTubeResolver()) return mediaItem
val mediaId = localConfiguration.customCacheKey ?: mediaItem.mediaId
val lowDataModeActive = isLowDataModeActive()
+ val request =
+ AudioStreamRequest(
+ mediaId = mediaId,
+ quality = if (lowDataModeActive) AudioQuality.LOW else audioQuality,
+ networkMetered = lowDataModeActive,
+ purpose = StreamPurpose.PLAYBACK,
+ authState = YouTube.currentPlaybackAuthState(),
+ )
val resolved =
- resolveAudioStream.peek(
- AudioStreamRequest(
- mediaId = mediaId,
- quality = if (lowDataModeActive) AudioQuality.LOW else audioQuality,
- networkMetered = lowDataModeActive,
- purpose = StreamPurpose.PLAYBACK,
- authState = YouTube.currentPlaybackAuthState(),
- ),
- ) ?: return mediaItem
+ if (blocking) {
+ resolveAudioStream.resolveBlocking(request)
+ } else {
+ resolveAudioStream.peek(request) ?: return mediaItem
+ }
val resolvedMimeType =
localConfiguration.mimeType.toCastMimeType()
?: castMimeTypeCache.get(mediaId)
+ ?: resolved.castMimeType()
return mediaItem
.buildUpon()
.setUri(resolved.url)
@@ -7598,6 +7737,9 @@ class MusicService :
.build()
}
+ private fun ResolvedAudioStream.castMimeType(): String? =
+ if (codecs.isBlank()) mimeType.toCastMimeType() else "$mimeType; codecs=\"$codecs\"".toCastMimeType()
+
private fun resolvePlaybackDataSpec(dataSpec: DataSpec): DataSpec {
if (dataSpec.uri.shouldBypassYouTubeResolver()) {
return dataSpec
@@ -7991,12 +8133,16 @@ class MusicService :
if (eventId != null) {
runCatching {
database.updateEventPlayTime(eventId, playbackStats.totalPlayTimeMs)
+ if (database.song(mediaId).first() != null) {
+ database.incrementPlayCount(mediaId)
+ }
}.onFailure(::reportException)
}
try {
database.withTransaction {
incrementTotalPlayTime(mediaId, playbackStats.totalPlayTimeMs)
+
}
} catch (_: SQLException) {
} catch (throwable: Throwable) {
@@ -8407,7 +8553,7 @@ class MusicService :
}
override fun onBind(intent: Intent?): android.os.IBinder? {
- hasBoundClients = true
+ boundClientCount++
cancelIdleStop()
val result = super.onBind(intent) ?: binder
if (player.mediaItemCount > 0 && player.currentMediaItem != null) {
@@ -8421,13 +8567,13 @@ class MusicService :
}
override fun onUnbind(intent: Intent?): Boolean {
- hasBoundClients = false
+ boundClientCount = (boundClientCount - 1).coerceAtLeast(0)
scheduleStopIfIdle()
return super.onUnbind(intent)
}
override fun onRebind(intent: Intent?) {
- hasBoundClients = true
+ boundClientCount++
cancelIdleStop()
super.onRebind(intent)
}
@@ -8519,12 +8665,16 @@ class MusicService :
flags: Int,
startId: Int,
): Int {
+ isPlaybackServiceStarted = true
if (intent?.action == ACTION_MEDIA_NOTIFICATION_DISMISSED) {
handleMediaNotificationDismissed(intent)
return START_NOT_STICKY
}
- ensureStartedAsForeground()
+ enterForegroundForStartCommand()
+ if (!hasResumablePlaybackNotification()) {
+ scheduleStopIfIdle()
+ }
when (intent?.action) {
"moe.rukamori.archivetune.WIDGET_PLAY_PAUSE" -> {
if (player.isPlaying) player.pause() else player.play()
@@ -8550,22 +8700,6 @@ class MusicService :
return START_NOT_STICKY
}
- override fun onUpdateNotification(
- session: MediaSession,
- startInForegroundRequired: Boolean,
- ) {
- val shouldShowNotification =
- shouldShowPlaybackNotification(
- startInForegroundRequired = startInForegroundRequired,
- hasResumablePlayback = hasResumablePlaybackNotification(),
- )
- if (!shouldShowNotification) return
-
- ensureStartedAsForeground()
- runCatching { super.onUpdateNotification(session, true) }
- .onFailure { reportException(it) }
- }
-
// ── Widget Support ────────────────────────────────────────────────────────────
fun updateWidget() {
@@ -8592,11 +8726,6 @@ class MusicService :
isPlaybackInactive: Boolean,
): Boolean = (isHostSessionActive && isPlaybackInactive) || stopMusicOnTaskClearEnabled
- internal fun shouldShowPlaybackNotification(
- startInForegroundRequired: Boolean,
- hasResumablePlayback: Boolean,
- ): Boolean = startInForegroundRequired || hasResumablePlayback
-
const val ROOT = "root"
const val HOME = "home"
const val LIBRARY = "library"
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/playback/PlayerConnection.kt b/app/src/main/kotlin/moe/rukamori/archivetune/playback/PlayerConnection.kt
index c03defc712..93b943b68d 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/playback/PlayerConnection.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/playback/PlayerConnection.kt
@@ -246,6 +246,13 @@ class PlayerConnection(
service.moveQueueItemToNext(mediaItemIndex)
}
+ internal fun removeQueueItems(windows: List): QueueRemoval? =
+ service.removeQueueItems(windows)
+
+ internal fun restoreQueueItems(removal: QueueRemoval): Boolean = service.restoreQueueItems(removal)
+
+ internal fun discardQueueRemoval(removal: QueueRemoval) = service.discardQueueRemoval(removal)
+
fun addToQueue(item: MediaItem) = addToQueue(listOf(item))
fun addToQueue(items: List) {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/playback/QueueRemoval.kt b/app/src/main/kotlin/moe/rukamori/archivetune/playback/QueueRemoval.kt
new file mode 100644
index 0000000000..73caa60f34
--- /dev/null
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/playback/QueueRemoval.kt
@@ -0,0 +1,125 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.playback
+
+import androidx.media3.common.C
+import androidx.media3.common.Player
+import androidx.media3.common.Timeline
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder
+import moe.rukamori.archivetune.extensions.buildRestoredShuffleOrderIndices
+
+internal class QueueRemoval private constructor(
+ private val source: Player,
+ private val originalWindows: List,
+ private val originalShuffleUids: List,
+ private val removedUids: Set,
+) {
+ val removedItems = originalWindows.filter { it.uid in removedUids }.map { it.mediaItem }
+
+ fun restore(player: Player): Boolean {
+ if (player !== source) return false
+
+ val currentWindows = player.currentTimeline.windows()
+ val currentUids = currentWindows.map { it.uid }
+ val originalUids = originalWindows.map { it.uid }
+ val survivingUids = originalUids.filterNot { it in removedUids }.toHashSet()
+ if (survivingUids.isEmpty()) {
+ if (currentUids.isNotEmpty()) return false
+ } else if (currentUids.none { it in survivingUids }) {
+ return false
+ }
+
+ val restoredUids = restoreOrder(originalUids, currentUids)
+ val restoredShuffleUids =
+ restoreOrder(originalShuffleUids, player.currentTimeline.shuffleUids())
+ val removedWindows = originalWindows.filter { it.uid in removedUids }.associateBy { it.uid }
+ var index = 0
+ while (index < restoredUids.size) {
+ if (restoredUids[index] !in removedUids) {
+ index++
+ continue
+ }
+ val insertionIndex = index
+ val items = buildList {
+ while (index < restoredUids.size && restoredUids[index] in removedUids) {
+ add(removedWindows.getValue(restoredUids[index]).mediaItem)
+ index++
+ }
+ }
+ player.addMediaItems(insertionIndex, items)
+ }
+
+ if (player is ExoPlayer) {
+ val restoredIndices = restoredUids.withIndex().associate { it.value to it.index }
+ player.setShuffleOrder(
+ DefaultShuffleOrder(
+ buildRestoredShuffleOrderIndices(restoredShuffleUids.map(restoredIndices::getValue)),
+ System.currentTimeMillis(),
+ ),
+ )
+ }
+ return true
+ }
+
+ private fun restoreOrder(originalOrder: List, currentOrder: List): List {
+ val currentUids = currentOrder.toHashSet()
+ val before = mutableMapOf>()
+ var pending = mutableListOf()
+ var lastSurvivingUid: Any? = null
+ originalOrder.forEach { uid ->
+ if (uid in removedUids) {
+ pending.add(uid)
+ } else if (uid in currentUids) {
+ if (pending.isNotEmpty()) {
+ before[uid] = pending
+ pending = mutableListOf()
+ }
+ lastSurvivingUid = uid
+ }
+ }
+ return buildList(currentOrder.size + removedUids.size) {
+ if (lastSurvivingUid == null) addAll(pending)
+ currentOrder.forEach { uid ->
+ before[uid]?.let { addAll(it) }
+ add(uid)
+ if (uid == lastSurvivingUid) addAll(pending)
+ }
+ }
+ }
+
+ companion object {
+ fun remove(player: Player, windows: List): QueueRemoval? {
+ val originalWindows = player.currentTimeline.windows()
+ val requestedUids = windows.mapTo(HashSet()) { it.uid }
+ val removedIndices = originalWindows.indices.filter { originalWindows[it].uid in requestedUids }
+ if (removedIndices.isEmpty()) return null
+
+ val removal = QueueRemoval(
+ source = player,
+ originalWindows = originalWindows,
+ originalShuffleUids = player.currentTimeline.shuffleUids(),
+ removedUids = removedIndices.mapTo(HashSet()) { originalWindows[it].uid },
+ )
+ removedIndices.asReversed().forEach(player::removeMediaItem)
+ return removal
+ }
+
+ private fun Timeline.windows(): List =
+ List(windowCount) { index -> getWindow(index, Timeline.Window()) }
+
+ private fun Timeline.shuffleUids(): List = buildList(windowCount) {
+ val window = Timeline.Window()
+ var index = getFirstWindowIndex(true)
+ while (index != C.INDEX_UNSET) {
+ add(getWindow(index, window).uid)
+ index = getNextWindowIndex(index, Player.REPEAT_MODE_OFF, true)
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/playback/WaveformRepository.kt b/app/src/main/kotlin/moe/rukamori/archivetune/playback/WaveformRepository.kt
new file mode 100644
index 0000000000..5208f727fe
--- /dev/null
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/playback/WaveformRepository.kt
@@ -0,0 +1,65 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.playback
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.IOException
+import java.security.MessageDigest
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class WaveformRepository
+ @Inject
+ constructor(
+ @ApplicationContext private val context: Context,
+ private val downloadUtil: DownloadUtil,
+ ) {
+ suspend fun getOrGenerate(
+ mediaId: String,
+ durationMs: Int,
+ ): ByteArray =
+ withContext(Dispatchers.IO) {
+ val directory = context.cacheDir.resolve(WaveformCacheDirectory)
+ if (!directory.exists() && !directory.mkdirs()) {
+ throw IOException("Unable to create waveform cache directory")
+ }
+ val cacheFile = directory.resolve(mediaId.toWaveformCacheName())
+ if (cacheFile.isFile && cacheFile.length() == AudioWaveformAnalyzer.BinCount.toLong()) {
+ return@withContext cacheFile.readBytes()
+ }
+
+ val waveform = downloadUtil.generateWaveform(mediaId, durationMs)
+ val temporaryFile = directory.resolve("${cacheFile.name}.tmp")
+ try {
+ temporaryFile.writeBytes(waveform)
+ if (!temporaryFile.renameTo(cacheFile)) {
+ throw IOException("Unable to save generated waveform")
+ }
+ } finally {
+ if (temporaryFile.exists() && !temporaryFile.delete()) {
+ Timber.w("Unable to remove temporary waveform data for %s", mediaId)
+ }
+ }
+ waveform
+ }
+
+ private companion object {
+ const val WaveformCacheDirectory = "song-waveforms-v3"
+ }
+ }
+
+private fun String.toWaveformCacheName(): String {
+ val digest = MessageDigest.getInstance("SHA-256").digest(toByteArray())
+ return digest.joinToString(separator = "") { byte -> "%02x".format(byte) }
+}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/playback/preload/NextStreamPreloader.kt b/app/src/main/kotlin/moe/rukamori/archivetune/playback/preload/NextStreamPreloader.kt
index 3a701ce42e..8b18d81044 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/playback/preload/NextStreamPreloader.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/playback/preload/NextStreamPreloader.kt
@@ -21,7 +21,7 @@ import moe.rukamori.archivetune.di.PlayerCache
import moe.rukamori.archivetune.playback.stream.AudioStreamRequest
import moe.rukamori.archivetune.playback.stream.ResolveAudioStreamUseCase
import timber.log.Timber
-import java.util.concurrent.atomic.AtomicReference
+import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import javax.inject.Singleton
@@ -38,53 +38,66 @@ class NextStreamPreloader
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val lock = Any()
- private var target: Target? = null
- private var job: Job? = null
+ private val jobs = mutableMapOf()
+ private val completedTargets = mutableSetOf()
- fun updateTarget(request: AudioStreamRequest) {
- val nextTarget = Target(request)
- lateinit var preloadFailure: AtomicReference
- val jobToStart =
+ fun updateTargets(requests: List) {
+ val targets = requests.map(::Target).toSet()
+ val jobsToStart =
synchronized(lock) {
- if (target == nextTarget) return
-
- val previousJob = job
- preloadFailure = AtomicReference()
- val nextJob =
- scope.launch(start = CoroutineStart.LAZY) {
- try {
- preload(nextTarget)
- } catch (cancellation: CancellationException) {
- throw cancellation
- } catch (throwable: Throwable) {
- Timber.tag(LOG_TAG).w(
- throwable,
- "Failed to preload resolved stream for %s",
- request.mediaId,
- )
- preloadFailure.set(throwable)
+ jobs.keys.filterNot(targets::contains).toList().forEach { target ->
+ jobs.remove(target)?.cancel()
+ }
+ completedTargets.retainAll(targets)
+
+ targets.mapNotNull { target ->
+ if (target in jobs || target in completedTargets) {
+ null
+ } else {
+ val preloadSucceeded = AtomicBoolean()
+ val job =
+ scope.launch(start = CoroutineStart.LAZY) {
+ try {
+ preload(target)
+ preloadSucceeded.set(true)
+ } catch (cancellation: CancellationException) {
+ throw cancellation
+ } catch (throwable: Throwable) {
+ Timber.tag(LOG_TAG).w(
+ throwable,
+ "Failed to preload resolved stream for %s",
+ target.request.mediaId,
+ )
+ }
+ }
+ jobs[target] = job
+ job.invokeOnCompletion { cause ->
+ synchronized(lock) {
+ if (jobs[target] === job) {
+ jobs.remove(target)
+ if (cause == null && preloadSucceeded.get()) {
+ completedTargets.add(target)
+ }
+ }
+ }
}
+ job
}
-
- target = nextTarget
- job = nextJob
- previousJob?.cancel()
- nextJob
+ }
}
- jobToStart.invokeOnCompletion { cause ->
- complete(nextTarget, jobToStart, preloadFailure.get() ?: cause)
- }
- jobToStart.start()
+ jobsToStart.forEach(Job::start)
}
fun cancel() {
- val jobToCancel =
+ val jobsToCancel =
synchronized(lock) {
- target = null
- job.also { job = null }
+ jobs.values.toList().also {
+ jobs.clear()
+ completedTargets.clear()
+ }
}
- jobToCancel?.cancel()
+ jobsToCancel.forEach(Job::cancel)
}
private suspend fun preload(target: Target) {
@@ -96,21 +109,6 @@ class NextStreamPreloader
resolveAudioStream.preload(target.request)
}
- private fun complete(
- completedTarget: Target,
- completedJob: Job,
- cause: Throwable?,
- ) {
- synchronized(lock) {
- if (target != completedTarget || job !== completedJob) return
-
- job = null
- if (cause != null && cause !is CancellationException) {
- target = null
- }
- }
- }
-
private fun Cache.isFullyCached(key: String): Boolean {
val contentLength = getContentMetadata(key).get(ContentMetadata.KEY_CONTENT_LENGTH, -1L)
return contentLength > 0L && isCached(key, 0L, contentLength)
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/playback/queues/ListQueue.kt b/app/src/main/kotlin/moe/rukamori/archivetune/playback/queues/ListQueue.kt
index d943461b13..0dec9aea11 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/playback/queues/ListQueue.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/playback/queues/ListQueue.kt
@@ -8,7 +8,10 @@
package moe.rukamori.archivetune.playback.queues
import androidx.media3.common.MediaItem
+import moe.rukamori.archivetune.db.LocalMusicIdentity
+import moe.rukamori.archivetune.extensions.metadata
import moe.rukamori.archivetune.models.MediaMetadata
+import moe.rukamori.archivetune.utils.isLocalMediaId
class ListQueue(
val title: String? = null,
@@ -19,17 +22,43 @@ class ListQueue(
override val preloadItem: MediaMetadata? = null
override suspend fun getInitialStatus(): Queue.Status {
+ val uniqueItems = mutableListOf()
+ val localTrackIndexes = mutableMapOf()
+ val requestedStartIndex = startIndex.takeIf { items.isNotEmpty() }?.coerceIn(items.indices) ?: 0
+ var uniqueStartIndex = 0
+ items.forEachIndexed { index, item ->
+ val identity = item.localTrackIdentity()
+ val existingIndex = identity?.let(localTrackIndexes::get)
+ if (existingIndex == null) {
+ if (identity != null) localTrackIndexes[identity] = uniqueItems.size
+ if (index == requestedStartIndex) uniqueStartIndex = uniqueItems.size
+ uniqueItems += item
+ } else if (index == requestedStartIndex) {
+ uniqueStartIndex = existingIndex
+ }
+ }
val safeStartIndex =
- if (items.isEmpty()) {
+ if (uniqueItems.isEmpty()) {
0
} else {
- startIndex.coerceIn(items.indices)
+ uniqueStartIndex
}
- return Queue.Status(title, items, safeStartIndex, position)
+ return Queue.Status(title, uniqueItems, safeStartIndex, position)
}
override fun hasNextPage(): Boolean = false
override suspend fun nextPage() = throw UnsupportedOperationException()
+
+ private fun MediaItem.localTrackIdentity(): String? {
+ val metadata = metadata
+ if (metadata?.id?.isLocalMediaId() != true) return null
+ return LocalMusicIdentity.trackKey(
+ title = metadata.title,
+ artists = metadata.artists.map { it.name },
+ durationSeconds = metadata.duration,
+ fallbackId = metadata.id,
+ )
+ }
}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/playback/stream/ResolveAudioStreamUseCase.kt b/app/src/main/kotlin/moe/rukamori/archivetune/playback/stream/ResolveAudioStreamUseCase.kt
index 8120647427..7251d0a218 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/playback/stream/ResolveAudioStreamUseCase.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/playback/stream/ResolveAudioStreamUseCase.kt
@@ -316,14 +316,10 @@ class ResolveAudioStreamUseCase
priority: StreamResolutionPriority,
): ResolvedAudioStream {
val resolvedAuthState =
- if (request.authState.hasLoginCookie) {
- YTPlayerUtils.ensureYoutubeiPoTokensForPlayback(
- videoId = request.mediaId,
- authState = request.authState,
- )
- } else {
- request.authState
- }
+ YTPlayerUtils.ensureYoutubeiPoTokensForPlayback(
+ videoId = request.mediaId,
+ authState = request.authState,
+ )
return youtubeiRepository.resolve(
request = request.copy(authState = resolvedAuthState),
priority = priority,
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/playback/stream/YoutubeiStreamRepository.kt b/app/src/main/kotlin/moe/rukamori/archivetune/playback/stream/YoutubeiStreamRepository.kt
index a6c2f9066c..44737e31b2 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/playback/stream/YoutubeiStreamRepository.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/playback/stream/YoutubeiStreamRepository.kt
@@ -22,6 +22,7 @@ import moe.rukamori.archivetune.morideobfuscator.youtubei.YoutubeiResolutionPrio
import moe.rukamori.archivetune.morideobfuscator.youtubei.YoutubeiResolver
import moe.rukamori.archivetune.morideobfuscator.youtubei.YoutubeiStreamRequest
import moe.rukamori.archivetune.utils.YTPlayerUtils
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import timber.log.Timber
import java.util.TimeZone
import javax.inject.Inject
@@ -85,10 +86,10 @@ class YoutubeiStreamRepository
cookie = authState.cookie.takeIf { authState.hasLoginCookie },
visitorData = authState.visitorData,
dataSyncId = authState.dataSyncId.takeIf { authState.hasLoginCookie },
- sessionPoToken = authState.poTokenGvsSession.takeIf { authState.hasLoginCookie },
+ sessionPoToken = authState.poTokenGvsSession,
videoPoToken =
authState.poTokenGvs?.takeIf {
- authState.hasLoginCookie && authState.poTokenGvsVideoId == request.mediaId
+ authState.poTokenGvsVideoId == request.mediaId
},
language = locale.hl,
location = locale.gl,
@@ -115,7 +116,7 @@ class YoutubeiStreamRepository
) {
throw YTPlayerUtils.BotDetectionPlaybackException(
videoId = request.mediaId,
- clients = setOf(if (authState.hasLoginCookie) "WEB_CREATOR" else "VISIONOS"),
+ clients = setOf(if (authState.hasLoginCookie) "WEB_CREATOR" else "MWEB"),
cause = failure,
)
}
@@ -126,7 +127,7 @@ class YoutubeiStreamRepository
if (!authState.hasLoginCookie) {
throw YTPlayerUtils.BadStreamPlayerResponseException(
videoId = request.mediaId,
- failedClients = setOf("VISIONOS"),
+ failedClients = setOf("MWEB"),
cause = failure,
)
}
@@ -146,6 +147,29 @@ class YoutubeiStreamRepository
throw failure
}
+ val streamUrl = resolved.url.toHttpUrlOrNull()
+ val streamToken = streamUrl?.queryParameter("pot")
+ val tokenBinding =
+ when {
+ streamToken.isNullOrBlank() -> "missing"
+ streamToken == authState.poTokenGvs && authState.poTokenGvsVideoId == request.mediaId -> "video"
+ streamToken == authState.poTokenGvsSession -> "session"
+ else -> "other"
+ }
+ Timber.tag("YoutubeiResolver").d(
+ "Stream contract signedIn=%s format=%d tokenBinding=%s nPresent=%s nError=%s " +
+ "signaturePresent=%s userAgentPresent=%s extractionProxy=%s streamProxy=%s",
+ authState.hasLoginCookie,
+ resolved.formatId,
+ tokenBinding,
+ !streamUrl?.queryParameter("n").isNullOrBlank(),
+ streamUrl?.queryParameter("n")?.startsWith("enhanced_except_") == true,
+ !streamUrl?.queryParameter("sig").isNullOrBlank() ||
+ !streamUrl?.queryParameter("signature").isNullOrBlank(),
+ resolved.requestHeaders.any { (name, value) -> name.equals("User-Agent", ignoreCase = true) && value.isNotBlank() },
+ YouTube.proxy?.type()?.name ?: "DIRECT",
+ YouTube.streamOkHttpProxy.type().name,
+ )
return ResolvedAudioStream(
url = resolved.url,
requestHeaders = resolved.requestHeaders,
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/podcast/PodcastUseCases.kt b/app/src/main/kotlin/moe/rukamori/archivetune/podcast/PodcastUseCases.kt
index d0573b1a43..bc0f6ca904 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/podcast/PodcastUseCases.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/podcast/PodcastUseCases.kt
@@ -42,8 +42,8 @@ class LoadPodcastUseCase
.distinctBy(EpisodeItem::id)
.map { episode ->
episode.toUiModel(
- fallbackPodcastTitle = page.podcast.title,
- fallbackPodcastBrowseId = page.podcast.browseId,
+ podcastTitle = page.podcast.title,
+ podcastBrowseId = page.podcast.browseId,
)
}.toList()
PodcastLoadResult(
@@ -85,8 +85,8 @@ class LoadPodcastContinuationUseCase
.distinctBy(EpisodeItem::id)
.map { episode ->
episode.toUiModel(
- fallbackPodcastTitle = podcastTitle,
- fallbackPodcastBrowseId = podcastBrowseId,
+ podcastTitle = podcastTitle,
+ podcastBrowseId = podcastBrowseId,
)
}.toList()
PodcastContinuationResult(
@@ -98,25 +98,20 @@ class LoadPodcastContinuationUseCase
}
private fun EpisodeItem.toUiModel(
- fallbackPodcastTitle: String,
- fallbackPodcastBrowseId: String,
+ podcastTitle: String,
+ podcastBrowseId: String,
): PodcastEpisodeUiModel {
- val resolvedPodcastTitle = podcast?.name?.takeIf(String::isNotBlank) ?: fallbackPodcastTitle
- val resolvedPodcastId = podcast?.id?.takeIf(String::isNotBlank) ?: fallbackPodcastBrowseId
val metadata =
toMediaMetadata().let { current ->
current.copy(
- artists =
- current.artists.ifEmpty {
- listOf(MediaMetadata.Artist(id = resolvedPodcastId, name = resolvedPodcastTitle))
- },
- album = current.album ?: MediaMetadata.Album(id = fallbackPodcastBrowseId, title = fallbackPodcastTitle),
+ artists = listOf(MediaMetadata.Artist(id = podcastBrowseId, name = podcastTitle)),
+ album = MediaMetadata.Album(id = podcastBrowseId, title = podcastTitle),
)
}
return PodcastEpisodeUiModel(
id = id,
title = title,
- podcastTitle = resolvedPodcastTitle,
+ podcastTitle = podcastTitle,
description = description,
dateText = dateText,
durationText = durationText,
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/spotify/GetSpotifyLikedSongsUseCase.kt b/app/src/main/kotlin/moe/rukamori/archivetune/spotify/GetSpotifyLikedSongsUseCase.kt
new file mode 100644
index 0000000000..d346e6f79b
--- /dev/null
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/spotify/GetSpotifyLikedSongsUseCase.kt
@@ -0,0 +1,58 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.spotify
+
+import com.google.common.collect.ImmutableList
+import moe.rukamori.archivetune.spotify.models.SpotifyImage
+import moe.rukamori.archivetune.spotify.models.SpotifySimpleAlbum
+import moe.rukamori.archivetune.spotify.models.SpotifySimpleArtist
+import moe.rukamori.archivetune.spotify.models.SpotifyTrack
+import javax.inject.Inject
+
+class GetSpotifyLikedSongsUseCase
+ @Inject
+ constructor(
+ private val repository: SpotifyLibraryRepository,
+ ) {
+ suspend operator fun invoke(): List =
+ repository
+ .likedSongs()
+ .map(SpotifyTrack::toSpotifyLikedSong)
+ }
+
+data class SpotifyLikedSong(
+ val id: String,
+ val title: String,
+ val artistNames: ImmutableList,
+ val thumbnailUrl: String?,
+ val durationMs: Int,
+ val isExplicit: Boolean,
+) {
+ fun toPlaybackTrack(): SpotifyTrack =
+ SpotifyTrack(
+ id = id,
+ name = title,
+ artists = artistNames.map { artistName -> SpotifySimpleArtist(name = artistName) },
+ album =
+ thumbnailUrl?.let { url ->
+ SpotifySimpleAlbum(images = listOf(SpotifyImage(url = url)))
+ },
+ durationMs = durationMs,
+ explicit = isExplicit,
+ )
+}
+
+private fun SpotifyTrack.toSpotifyLikedSong(): SpotifyLikedSong =
+ SpotifyLikedSong(
+ id = id,
+ title = name,
+ artistNames = ImmutableList.copyOf(artists.map { artist -> artist.name }),
+ thumbnailUrl = SpotifyMapper.getTrackThumbnail(this),
+ durationMs = durationMs,
+ isExplicit = explicit,
+ )
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/spotify/SpotifyLibraryRepository.kt b/app/src/main/kotlin/moe/rukamori/archivetune/spotify/SpotifyLibraryRepository.kt
index 7266d99b3b..860c9a0d40 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/spotify/SpotifyLibraryRepository.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/spotify/SpotifyLibraryRepository.kt
@@ -232,6 +232,27 @@ class SpotifyLibraryRepository
tracks
}
+ suspend fun likedSongs(): List =
+ withContext(Dispatchers.IO) {
+ ensureAuthenticated()
+ val tracks = ArrayList()
+ var offset = 0
+ val limit = 50
+
+ while (true) {
+ val page =
+ spotifyCallWithTokenRetry {
+ Spotify.likedSongs(limit = limit, offset = offset).getOrThrow()
+ }
+ if (page.items.isEmpty()) break
+ tracks += page.items.map { savedTrack -> savedTrack.track }.filterNot(SpotifyTrack::isLocal)
+ offset += page.items.size
+ if (offset >= page.total || page.items.size < limit) break
+ }
+
+ tracks
+ }
+
private suspend fun ensureAuthenticated() {
val prefs = context.dataStore.data.first()
val token = prefs[SpotifyAccessTokenKey].orEmpty()
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/spotify/SpotifyLikedSongsViewModel.kt b/app/src/main/kotlin/moe/rukamori/archivetune/spotify/SpotifyLikedSongsViewModel.kt
new file mode 100644
index 0000000000..442593db9e
--- /dev/null
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/spotify/SpotifyLikedSongsViewModel.kt
@@ -0,0 +1,141 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.spotify
+
+import android.content.Context
+import androidx.compose.runtime.Immutable
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.common.collect.ImmutableList
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+import moe.rukamori.archivetune.R
+import moe.rukamori.archivetune.playback.queues.Queue
+import moe.rukamori.archivetune.utils.reportException
+import javax.inject.Inject
+
+@HiltViewModel
+class SpotifyLikedSongsViewModel
+ @Inject
+ constructor(
+ @ApplicationContext private val context: Context,
+ private val getSpotifyLikedSongs: GetSpotifyLikedSongsUseCase,
+ ) : ViewModel() {
+ private val _uiState = MutableStateFlow(SpotifyLikedSongsScreenState.Loading)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val eventChannel = Channel(Channel.BUFFERED)
+ val events = eventChannel.receiveAsFlow()
+
+ private var reloadJob: Job? = null
+ private var loadedSongs: ImmutableList = ImmutableList.of()
+
+ init {
+ reload()
+ }
+
+ fun reload() {
+ reloadJob?.cancel()
+ _uiState.value = SpotifyLikedSongsScreenState.Loading
+ reloadJob =
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val songs = ImmutableList.copyOf(getSpotifyLikedSongs())
+ loadedSongs = songs
+ _uiState.value =
+ if (songs.isEmpty()) {
+ SpotifyLikedSongsScreenState.Empty
+ } else {
+ SpotifyLikedSongsScreenState.Success(
+ songs = ImmutableList.copyOf(songs.map(SpotifyLikedSong::toUiModel)),
+ )
+ }
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: Throwable) {
+ reportException(error)
+ _uiState.value = SpotifyLikedSongsScreenState.Error
+ }
+ }
+ }
+
+ fun playAll(shuffled: Boolean) {
+ if (loadedSongs.isEmpty()) return
+ val songs = if (shuffled) loadedSongs.shuffled() else loadedSongs
+ requestPlayback(songs = songs, startIndex = 0)
+ }
+
+ fun playSong(index: Int) {
+ if (index !in loadedSongs.indices) return
+ requestPlayback(songs = loadedSongs, startIndex = index)
+ }
+
+ private fun requestPlayback(
+ songs: List,
+ startIndex: Int,
+ ) {
+ eventChannel.trySend(
+ SpotifyLikedSongsEvent.PlaybackRequested(
+ queue =
+ SpotifyPlaylistQueue(
+ title = context.getString(R.string.liked_songs),
+ initialTracks = songs.map(SpotifyLikedSong::toPlaybackTrack),
+ startIndex = startIndex,
+ ),
+ ),
+ )
+ }
+ }
+
+sealed interface SpotifyLikedSongsScreenState {
+ data object Loading : SpotifyLikedSongsScreenState
+
+ @Immutable
+ data class Success(
+ val songs: ImmutableList,
+ ) : SpotifyLikedSongsScreenState
+
+ data object Empty : SpotifyLikedSongsScreenState
+
+ data object Error : SpotifyLikedSongsScreenState
+}
+
+sealed interface SpotifyLikedSongsEvent {
+ data class PlaybackRequested(
+ val queue: Queue,
+ ) : SpotifyLikedSongsEvent
+}
+
+@Immutable
+data class SpotifyLikedSongUiModel(
+ val id: String,
+ val title: String,
+ val artistNames: ImmutableList,
+ val thumbnailUrl: String?,
+ val durationMs: Int,
+ val isExplicit: Boolean,
+)
+
+private fun SpotifyLikedSong.toUiModel(): SpotifyLikedSongUiModel =
+ SpotifyLikedSongUiModel(
+ id = id,
+ title = title,
+ artistNames = ImmutableList.copyOf(artistNames),
+ thumbnailUrl = thumbnailUrl,
+ durationMs = durationMs,
+ isExplicit = isExplicit,
+ )
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/spotify/SpotifyPlaylistQueue.kt b/app/src/main/kotlin/moe/rukamori/archivetune/spotify/SpotifyPlaylistQueue.kt
index ad6ec9b0a5..40bda027fa 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/spotify/SpotifyPlaylistQueue.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/spotify/SpotifyPlaylistQueue.kt
@@ -18,7 +18,7 @@ import moe.rukamori.archivetune.playback.queues.Queue
import moe.rukamori.archivetune.spotify.models.SpotifyTrack
class SpotifyPlaylistQueue(
- private val playlistId: String,
+ private val playlistId: String? = null,
private val title: String? = null,
private val initialTracks: List = emptyList(),
private val startIndex: Int = 0,
@@ -111,7 +111,7 @@ class SpotifyPlaylistQueue(
val result =
Spotify
.playlistTracks(
- playlistId = playlistId,
+ playlistId = checkNotNull(playlistId),
limit = SPOTIFY_PAGE_SIZE,
offset = apiFetchOffset,
).getOrThrow()
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/BigSeekBar.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/BigSeekBar.kt
index e20c114d20..57c63bbf71 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/BigSeekBar.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/BigSeekBar.kt
@@ -30,11 +30,18 @@ fun BigSeekBar(
color: Color = MaterialTheme.colorScheme.primary,
steps: Int = 19,
) {
+ val sliderState =
+ rememberControlledSliderState(
+ value = progressProvider(),
+ valueRange = 0f..1f,
+ steps = steps,
+ )
Slider(
- value = progressProvider(),
- onValueChange = onProgressChange,
- valueRange = 0f..1f,
- steps = steps,
+ state = sliderState,
+ onValueChange = { value ->
+ sliderState.value = value
+ onProgressChange(value)
+ },
colors =
SliderDefaults.colors(
activeTrackColor = color,
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/BottomSheet.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/BottomSheet.kt
index b59ded4e12..941016da99 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/BottomSheet.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/BottomSheet.kt
@@ -17,6 +17,7 @@ import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.snap
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.DraggableState
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
@@ -36,6 +37,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -87,6 +89,7 @@ fun BottomSheet(
backgroundColor: Color,
onDismiss: (() -> Unit)? = null,
onCollapsedContentClick: (() -> Unit)? = null,
+ onCollapsedContentLongClick: (() -> Unit)? = null,
backHandlerEnabled: Boolean = true,
collapsedContent: @Composable BoxScope.() -> Unit,
content: @Composable BoxScope.() -> Unit,
@@ -135,10 +138,11 @@ fun BottomSheet(
Modifier
.graphicsLayer {
alpha = 1f - (state.progress * 4).coerceAtMost(1f)
- }.clickable(
+ }.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onCollapsedContentClick ?: state::expandSoft,
+ onLongClick = onCollapsedContentLongClick,
).fillMaxWidth()
.height(state.collapsedBound),
content = collapsedContent,
@@ -422,24 +426,18 @@ fun rememberBottomSheetState(
var previousAnchor by rememberSaveable {
mutableIntStateOf(initialAnchor)
}
- val animatable =
- remember {
- Animatable(0.dp, Dp.VectorConverter)
- }
-
val state =
- remember(dismissedBound, expandedBound, coroutineScope, animationsDisabled) {
+ remember(dismissedBound, expandedBound, density, coroutineScope, animationsDisabled) {
+ val lowerBound = dismissedBound.coerceAtMost(expandedBound)
val initialValue =
when (previousAnchor) {
EXPANDED_ANCHOR -> expandedBound
COLLAPSED_ANCHOR -> collapsedBound
DISMISSED_ANCHOR -> dismissedBound
else -> error("Unknown BottomSheet anchor")
- }
-
- animatable.updateBounds(dismissedBound.coerceAtMost(expandedBound), expandedBound)
- coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
- animatable.animateTo(initialValue, if (animationsDisabled) snap() else BottomSheetAnimationSpec)
+ }.coerceIn(lowerBound, expandedBound)
+ val animatable = Animatable(initialValue, Dp.VectorConverter).apply {
+ updateBounds(lowerBound, expandedBound)
}
BottomSheetState(
@@ -453,7 +451,7 @@ fun rememberBottomSheetState(
coroutineScope = coroutineScope,
animatable = animatable,
animationsDisabled = animationsDisabled,
- collapsedBound = collapsedBound,
+ collapsedBound = collapsedBound.coerceIn(lowerBound, expandedBound),
initialAnchor = previousAnchor,
)
}
@@ -462,16 +460,18 @@ fun rememberBottomSheetState(
state.updateTargetCollapsedBound(collapsedBound)
}
- val animationSpec = if (animationsDisabled) snap() else NavigationBarAnimationSpec
- val animatedCollapsedBound by
- animateDpAsState(
- targetValue = collapsedBound,
- animationSpec = animationSpec,
- label = "SheetCollapsedBound",
- )
- LaunchedEffect(state) {
- snapshotFlow { animatedCollapsedBound }.collect {
- state.reanchorTo(it)
+ key(state) {
+ val animationSpec = if (animationsDisabled) snap() else NavigationBarAnimationSpec
+ val animatedCollapsedBound by
+ animateDpAsState(
+ targetValue = collapsedBound.coerceIn(state.dismissedBound, state.expandedBound),
+ animationSpec = animationSpec,
+ label = "SheetCollapsedBound",
+ )
+ LaunchedEffect(state) {
+ snapshotFlow { animatedCollapsedBound }.collect {
+ state.reanchorTo(it)
+ }
}
}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/FloatingBarBlurBackground.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/FloatingBarBlurBackground.kt
new file mode 100644
index 0000000000..8b62d85d2c
--- /dev/null
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/FloatingBarBlurBackground.kt
@@ -0,0 +1,52 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.ui.component
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import dev.chrisbanes.haze.HazeInput
+import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.blur.HazeBlurStyle
+import dev.chrisbanes.haze.blur.HazeColorEffect
+import dev.chrisbanes.haze.blur.hazeBlur
+
+@Composable
+fun FloatingBarBlurBackground(
+ hazeState: HazeState,
+ isAcrylic: Boolean,
+ pureBlack: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ val surfaceColor = if (pureBlack) Color.Black else MaterialTheme.colorScheme.surfaceContainerHigh
+ val hazeInput = remember(hazeState) { HazeInput.Sources(hazeState) }
+ val blurStyle = remember(isAcrylic, surfaceColor) {
+ HazeBlurStyle {
+ backgroundColor(surfaceColor)
+ blurRadius(if (isAcrylic) 32.dp else 8.dp)
+ noiseFactor(if (isAcrylic) 0.12f else 0f)
+ colorEffects(
+ listOf(
+ HazeColorEffect.tint(surfaceColor.copy(alpha = if (isAcrylic) 0.72f else 0.62f)),
+ ),
+ )
+ fallbackColorEffect(HazeColorEffect.tint(surfaceColor))
+ }
+ }
+ Box(
+ modifier = modifier.hazeBlur(
+ input = hazeInput,
+ style = blurStyle,
+ expandLayerBounds = true,
+ ),
+ )
+}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/FloatingNavigationToolbar.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/FloatingNavigationToolbar.kt
index fe23525de8..0ff7850d9f 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/FloatingNavigationToolbar.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/FloatingNavigationToolbar.kt
@@ -36,7 +36,6 @@ import androidx.compose.material3.ShortNavigationBarItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -46,11 +45,14 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
+import dev.chrisbanes.haze.HazeState
import moe.rukamori.archivetune.constants.FloatingBarJunctionCornerRadius
import moe.rukamori.archivetune.constants.FloatingBarOuterCornerRadius
import moe.rukamori.archivetune.constants.FloatingBarStandaloneCornerRadius
+import moe.rukamori.archivetune.constants.NavigationBarBackgroundStyle
import moe.rukamori.archivetune.constants.NavigationBarHeight
import moe.rukamori.archivetune.constants.NavigationBarMaxWidth
+import moe.rukamori.archivetune.constants.NavigationBarMode
import moe.rukamori.archivetune.ui.screens.Screens
private val NavigationItemsMaxWidth = 360.dp
@@ -60,22 +62,32 @@ private val NavigationItemVerticalPadding = 8.dp
fun FloatingNavigationToolbar(
items: List,
pureBlack: Boolean,
+ backgroundStyle: NavigationBarBackgroundStyle,
+ hazeState: HazeState,
modifier: Modifier = Modifier,
miniPlayerProximityProvider: () -> Float = { 0f },
+ navigationBarMode: NavigationBarMode = NavigationBarMode.CONNECTED,
isSelected: (Screens) -> Boolean,
onItemClick: (Screens, Boolean) -> Unit,
onSearchItemDoubleClick: (() -> Unit)? = null,
) {
- val miniPlayerProximity = miniPlayerProximityProvider()
+ val miniPlayerProximity =
+ if (navigationBarMode == NavigationBarMode.CONNECTED) miniPlayerProximityProvider() else 0f
val navigationShape =
- RoundedCornerShape(
- topStart = lerp(FloatingBarStandaloneCornerRadius.value, FloatingBarJunctionCornerRadius.value, miniPlayerProximity).dp,
- topEnd = lerp(FloatingBarStandaloneCornerRadius.value, FloatingBarJunctionCornerRadius.value, miniPlayerProximity).dp,
- bottomStart = lerp(FloatingBarStandaloneCornerRadius.value, FloatingBarOuterCornerRadius.value, miniPlayerProximity).dp,
- bottomEnd = lerp(FloatingBarStandaloneCornerRadius.value, FloatingBarOuterCornerRadius.value, miniPlayerProximity).dp,
- )
+ remember(miniPlayerProximity) {
+ RoundedCornerShape(
+ topStart = lerp(FloatingBarStandaloneCornerRadius.value, FloatingBarJunctionCornerRadius.value, miniPlayerProximity).dp,
+ topEnd = lerp(FloatingBarStandaloneCornerRadius.value, FloatingBarJunctionCornerRadius.value, miniPlayerProximity).dp,
+ bottomStart = lerp(FloatingBarStandaloneCornerRadius.value, FloatingBarOuterCornerRadius.value, miniPlayerProximity).dp,
+ bottomEnd = lerp(FloatingBarStandaloneCornerRadius.value, FloatingBarOuterCornerRadius.value, miniPlayerProximity).dp,
+ )
+ }
val navigationContainerColor =
- if (pureBlack) Color.Black else MaterialTheme.colorScheme.surfaceContainer
+ if (backgroundStyle == NavigationBarBackgroundStyle.THEME) {
+ if (pureBlack) Color.Black else MaterialTheme.colorScheme.surfaceContainer
+ } else {
+ Color.Transparent
+ }
val motionScheme = MaterialTheme.motionScheme
Box(
modifier =
@@ -106,6 +118,14 @@ fun FloatingNavigationToolbar(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
+ if (backgroundStyle != NavigationBarBackgroundStyle.THEME) {
+ FloatingBarBlurBackground(
+ hazeState = hazeState,
+ isAcrylic = backgroundStyle == NavigationBarBackgroundStyle.ACRYLIC,
+ pureBlack = pureBlack,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
Row(
modifier =
Modifier
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/Library.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/Library.kt
index 8ae2823407..447d672842 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/Library.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/Library.kt
@@ -32,6 +32,8 @@ import moe.rukamori.archivetune.ui.menu.AlbumMenu
import moe.rukamori.archivetune.ui.menu.ArtistMenu
import moe.rukamori.archivetune.ui.menu.PlaylistMenu
import moe.rukamori.archivetune.ui.menu.YouTubePlaylistMenu
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
@Composable
fun LibraryArtistListItem(
@@ -64,7 +66,7 @@ fun LibraryArtistListItem(
modifier
.fillMaxWidth()
.clickable {
- navController.navigate("artist/${artist.id}")
+ navController.navigateToArtist(artist.id)
},
)
@@ -84,7 +86,7 @@ fun LibraryArtistGridItem(
.fillMaxWidth()
.combinedClickable(
onClick = {
- navController.navigate("artist/${artist.id}")
+ navController.navigateToArtist(artist.id)
},
onLongClick = {
menuState.show {
@@ -132,7 +134,7 @@ fun LibraryAlbumListItem(
modifier
.fillMaxWidth()
.clickable {
- navController.navigate("album/${album.id}")
+ navController.navigateToAlbum(album.id)
},
)
@@ -157,7 +159,7 @@ fun LibraryAlbumGridItem(
.fillMaxWidth()
.combinedClickable(
onClick = {
- navController.navigate("album/${album.id}")
+ navController.navigateToAlbum(album.id)
},
onLongClick = {
menuState.show {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/LibraryChipOrderDialog.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/LibraryChipOrderDialog.kt
index 7448facbff..1419a5075f 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/LibraryChipOrderDialog.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/LibraryChipOrderDialog.kt
@@ -140,6 +140,7 @@ fun LibraryChipOrderDialog(
private fun LibraryFilter.labelResId(): Int =
when (this) {
LibraryFilter.LIBRARY -> R.string.filter_library
+ LibraryFilter.LOCAL -> R.string.local_songs_tab
LibraryFilter.PLAYLISTS -> R.string.playlists
LibraryFilter.PODCASTS -> R.string.podcast
LibraryFilter.SPOTIFY -> R.string.spotify_playlists
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/LyricsEnhanced.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/LyricsEnhanced.kt
index a15e1d331c..f3d69ec7d6 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/LyricsEnhanced.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/LyricsEnhanced.kt
@@ -14,6 +14,7 @@
package moe.rukamori.archivetune.ui.component
import android.app.Activity
+import android.os.SystemClock
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.compose.BackHandler
@@ -58,10 +59,8 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
-import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -84,16 +83,22 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.media3.common.Player
import com.mocharealm.accompanist.lyrics.core.model.ISyncedLine
import com.mocharealm.accompanist.lyrics.core.model.SyncedLyrics
import com.mocharealm.accompanist.lyrics.core.model.karaoke.KaraokeAlignment
import com.mocharealm.accompanist.lyrics.core.model.karaoke.KaraokeLine
import com.mocharealm.accompanist.lyrics.core.model.karaoke.KaraokeSyllable
import com.mocharealm.accompanist.lyrics.core.model.synced.SyncedLine
+import com.mocharealm.accompanist.lyrics.ui.composable.list.rememberLyricsLazyListState
import com.mocharealm.accompanist.lyrics.ui.composable.lyrics.KaraokeLyricsView
-import kotlinx.coroutines.delay
+import com.mocharealm.accompanist.lyrics.ui.composable.lyrics.LyricsAnchor
import kotlinx.coroutines.isActive
-import moe.rukamori.archivetune.LocalAnimationsDisabled
import moe.rukamori.archivetune.LocalPlayerConnection
import moe.rukamori.archivetune.R
import moe.rukamori.archivetune.constants.PlayerBackgroundStyle
@@ -110,15 +115,10 @@ import moe.rukamori.archivetune.ui.component.shimmer.TextPlaceholder
import moe.rukamori.archivetune.ui.theme.rememberArchiveTuneLyricsFontFamily
import moe.rukamori.archivetune.utils.rememberEnumPreference
import moe.rukamori.archivetune.viewmodels.LyricsRenderScreenState
+import moe.rukamori.archivetune.viewmodels.LyricsRenderViewModel
import kotlin.math.roundToInt
-import kotlin.math.roundToLong
private const val LRC_LEAD_MS = 300L
-private const val WORD_SYNC_LEAD_MS = 0L
-private const val SMOOTH_PLAYBACK_MAX_FORWARD_DRIFT_MS = 250.0
-private const val SMOOTH_PLAYBACK_MAX_BACKWARD_DRIFT_MS = 250.0
-private const val SMOOTH_PLAYBACK_DRIFT_CORRECTION = 0.08
-private const val SMOOTH_PLAYBACK_MAX_CORRECTION_PER_FRAME_MS = 2.0
private const val MIN_KARAOKE_SYLLABLE_DURATION_MS = 1
@Composable
@@ -129,14 +129,14 @@ fun LyricsEnhanced(
modifier: Modifier = Modifier,
textColorOverride: Color? = null,
lyricsLineBlurOverride: Boolean? = null,
+ lyricsRenderViewModel: LyricsRenderViewModel = hiltViewModel(),
) {
val playerConnection = LocalPlayerConnection.current ?: return
val player = playerConnection.player
val context = LocalContext.current
- val animationsDisabled = LocalAnimationsDisabled.current
+ val lifecycleOwner = LocalLifecycleOwner.current
- val mediaMetadata by playerConnection.mediaMetadata.collectAsState()
- val playbackParameters by playerConnection.playbackParameters.collectAsState()
+ val mediaMetadata by playerConnection.mediaMetadata.collectAsStateWithLifecycle()
val preparedLyrics = (lyricsState as? LyricsRenderScreenState.Success)?.lyrics
val preferences = preparedLyrics?.preferences
@@ -164,7 +164,7 @@ fun LyricsEnhanced(
var showShareImageDialog by remember { mutableStateOf(false) }
val showTranslations =
- preparedLyrics?.lines?.any { line -> line.translation != null } == true
+ preparedLyrics?.hasTranslations == true && preferences?.showTranslations == true
val showPhonetics =
preparedLyrics?.lines?.any { line -> line.romanizedText != null || line.phonetics.isNotEmpty() } == true
val baseLayoutDirection = LocalLayoutDirection.current
@@ -210,88 +210,65 @@ fun LyricsEnhanced(
}
val leadMs =
- when {
- preparedLyrics?.sourceFormat == LyricsSourceFormat.TTML -> WORD_SYNC_LEAD_MS
- isWordSyncedFormat -> WORD_SYNC_LEAD_MS
- else -> LRC_LEAD_MS
- }
+ if (isWordSyncedFormat || preparedLyrics?.sourceFormat == LyricsSourceFormat.TTML) 0L else LRC_LEAD_MS
val latestSliderPositionProvider = rememberUpdatedState(sliderPositionProvider)
val latestLyricsSyncOffset = rememberUpdatedState(lyricsSyncOffset)
val latestLeadMs = rememberUpdatedState(leadMs)
- val latestPlaybackSpeed = rememberUpdatedState(playbackParameters.speed)
- val playbackPositionMs =
- remember(player) {
- mutableLongStateOf(player.currentPosition.coerceAtLeast(0L))
- }
- val listState = key(lyricsSessionKey) { rememberLazyListState() }
+ val playbackPositionMs = lyricsRenderViewModel.playbackPositionMs.collectAsStateWithLifecycle()
+ val plainLyricsListState = key(lyricsSessionKey) { rememberLazyListState() }
+ val karaokeLyricsListState = key(lyricsSessionKey) { rememberLyricsLazyListState() }
LaunchedEffect(lyricsSessionKey) {
- playbackPositionMs.longValue = player.currentPosition.coerceAtLeast(0L)
isSelectionModeActive = false
selectedLineKeys.clear()
}
- LaunchedEffect(player, lyricsSessionKey, animationsDisabled, playbackParameters.speed) {
- var smoothedPositionMs = player.currentPosition.coerceAtLeast(0L).toDouble()
- var previousFrameNanos = 0L
- while (isActive) {
- val sliderPosition = latestSliderPositionProvider.value()
- val rawPosition = (sliderPosition ?: player.currentPosition).coerceAtLeast(0L)
- if (sliderPosition != null || !player.isPlaying || animationsDisabled) {
- smoothedPositionMs = rawPosition.toDouble()
- previousFrameNanos = 0L
- if (playbackPositionMs.longValue != rawPosition) {
- playbackPositionMs.longValue = rawPosition
- }
- if (sliderPosition == null) {
- delay(100L)
- } else {
- withFrameNanos { }
- }
- } else {
- val frameNanos = withFrameNanos { frameTimeNanos -> frameTimeNanos }
- if (previousFrameNanos == 0L) {
- previousFrameNanos = frameNanos
- smoothedPositionMs = rawPosition.toDouble()
- } else {
- val elapsedMs =
- ((frameNanos - previousFrameNanos).coerceAtLeast(0L) / 1_000_000.0) *
- latestPlaybackSpeed.value.toDouble()
- val projectedPositionMs = smoothedPositionMs + elapsedMs
- val driftMs = rawPosition.toDouble() - projectedPositionMs
-
- smoothedPositionMs =
- if (
- driftMs > SMOOTH_PLAYBACK_MAX_FORWARD_DRIFT_MS ||
- driftMs < -SMOOTH_PLAYBACK_MAX_BACKWARD_DRIFT_MS
- ) {
- rawPosition.toDouble()
- } else {
- val correctionMs =
- (driftMs * SMOOTH_PLAYBACK_DRIFT_CORRECTION).coerceIn(
- -SMOOTH_PLAYBACK_MAX_CORRECTION_PER_FRAME_MS,
- SMOOTH_PLAYBACK_MAX_CORRECTION_PER_FRAME_MS,
- )
- projectedPositionMs + correctionMs
- }
- previousFrameNanos = frameNanos
+ DisposableEffect(player, lyricsRenderViewModel) {
+ val listener =
+ object : Player.Listener {
+ override fun onEvents(player: Player, events: Player.Events) {
+ if (
+ events.contains(Player.EVENT_POSITION_DISCONTINUITY) ||
+ events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION) ||
+ events.contains(Player.EVENT_IS_PLAYING_CHANGED) ||
+ events.contains(Player.EVENT_PLAYBACK_PARAMETERS_CHANGED)
+ ) {
+ lyricsRenderViewModel.onPlaybackPositionChanged(
+ latestSliderPositionProvider.value() ?: player.currentPosition,
+ )
+ }
}
+ }
+ player.addListener(listener)
+ onDispose { player.removeListener(listener) }
+ }
- val nextPosition = smoothedPositionMs.roundToLong().coerceAtLeast(0L)
-
- if (playbackPositionMs.longValue != nextPosition) {
- playbackPositionMs.longValue = nextPosition
+ LaunchedEffect(player, lifecycleOwner, lyricsRenderViewModel) {
+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ lyricsRenderViewModel.onPlaybackPositionChanged(
+ latestSliderPositionProvider.value() ?: player.currentPosition,
+ )
+ while (isActive) {
+ withFrameNanos {
+ val sliderPosition = latestSliderPositionProvider.value()
+ lyricsRenderViewModel.onPlaybackFrame(
+ positionMs = sliderPosition ?: player.currentPosition,
+ sampleTimeNanos = SystemClock.elapsedRealtimeNanos(),
+ playbackSpeed = player.playbackParameters.speed,
+ isAdvancing = player.isPlaying && sliderPosition == null,
+ durationMs = player.duration,
+ )
}
}
}
}
val playbackSyncPosition: () -> Int =
- remember {
+ remember(playbackPositionMs) {
{
(
- playbackPositionMs.longValue +
+ playbackPositionMs.value +
latestLyricsSyncOffset.value.toLong() +
latestLeadMs.value
).coerceIn(0L, Int.MAX_VALUE.toLong())
@@ -474,7 +451,7 @@ fun LyricsEnhanced(
!isSynced -> {
PlainLyricsView(
lines = plainLyrics,
- listState = listState,
+ listState = plainLyricsListState,
selectedLineKeys = selectedLineKeySet,
textColor = textColor,
textStyle = normalTextStyle,
@@ -499,12 +476,12 @@ fun LyricsEnhanced(
BoxWithConstraints(
modifier = Modifier.fillMaxSize(),
) {
- val lyricsViewportOffset = remember(maxHeight) { maxHeight * 0.38f }
+ val lyricsViewportAnchor = remember(maxHeight) { LyricsAnchor.Fixed(maxHeight * 0.38f) }
CompositionLocalProvider(LocalLayoutDirection provides lyricsLayoutDirection) {
key(lyricsSessionKey, syncedLyrics) {
KaraokeLyricsView(
- listState = listState,
+ listState = karaokeLyricsListState,
lyrics = syncedLyrics,
currentPosition = playbackSyncPosition,
onLineClicked = { line ->
@@ -533,7 +510,7 @@ fun LyricsEnhanced(
useBlurEffect = lyricsLineBlur,
showTranslation = showTranslations,
showPhonetic = showPhonetics,
- offset = lyricsViewportOffset,
+ anchor = lyricsViewportAnchor,
keepAliveZone = 72.dp,
modifier = Modifier.fillMaxSize(),
)
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/LyricsV2.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/LyricsV2.kt
index 57f8dc753c..6fd50597d2 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/LyricsV2.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/LyricsV2.kt
@@ -211,7 +211,7 @@ fun LyricsV2(
var shareDialogData by remember { mutableStateOf?>(null) }
var showShareImageDialog by remember { mutableStateOf(false) }
- val showTranslations = preparedLyrics?.lines?.any { line -> line.translation != null } == true
+ val showTranslations = preparedLyrics?.hasTranslations == true && preferences?.showTranslations == true
val isSynced = preparedLyrics?.syncType != null && preparedLyrics.syncType != LyricsSyncType.PLAIN
val isWordSyncedFormat = preparedLyrics?.syncType == LyricsSyncType.WORD
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/PlayerSlider.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/PlayerSlider.kt
index 63b14afd0d..68f9257af4 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/PlayerSlider.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/PlayerSlider.kt
@@ -15,6 +15,8 @@ import androidx.compose.material3.SliderColors
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.SliderState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.lerp
@@ -25,6 +27,27 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun rememberControlledSliderState(
+ value: Float,
+ valueRange: ClosedFloatingPointRange,
+ steps: Int = 0,
+): SliderState {
+ val sliderState =
+ remember(valueRange, steps) {
+ SliderState(
+ value = value.coerceIn(valueRange),
+ steps = steps,
+ trackRange = valueRange,
+ )
+ }
+ LaunchedEffect(sliderState, value) {
+ sliderState.value = value.coerceIn(valueRange)
+ }
+ return sliderState
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlayerSliderTrack(
@@ -37,7 +60,7 @@ fun PlayerSliderTrack(
val activeTrackColor = colors.activeTrackColor
val inactiveTickColor = colors.inactiveTickColor
val activeTickColor = colors.activeTickColor
- val valueRange = sliderState.valueRange
+ val valueRange = sliderState.trackRange
Canvas(
modifier
.fillMaxWidth()
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/Preference.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/Preference.kt
index 1cf342192a..17be542b00 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/Preference.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/Preference.kt
@@ -65,7 +65,6 @@ import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
-import androidx.compose.material3.rememberSliderState
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -1027,16 +1026,17 @@ fun SliderPreference(
Spacer(Modifier.height(16.dp))
val sliderState =
- rememberSliderState(
+ rememberControlledSliderState(
value = sliderValue,
valueRange = HISTORY_DURATION_RANGE,
- onValueChangeFinished = {},
)
- sliderState.onValueChange = { sliderValue = it }
- sliderState.value = sliderValue
Slider(
state = sliderState,
+ onValueChange = { value ->
+ sliderState.value = value
+ sliderValue = value
+ },
modifier = Modifier.fillMaxWidth(),
track = {
SliderDefaults.Track(
@@ -1139,17 +1139,18 @@ fun CrossfadeSliderPreference(
Spacer(Modifier.height(16.dp))
val crossfadeSliderState =
- rememberSliderState(
+ rememberControlledSliderState(
value = sliderValue,
steps = 19,
valueRange = 0f..10f,
- onValueChangeFinished = {},
)
- crossfadeSliderState.onValueChange = { sliderValue = it.coerceIn(0f, 10f) }
- crossfadeSliderState.value = sliderValue
Slider(
state = crossfadeSliderState,
+ onValueChange = { value ->
+ crossfadeSliderState.value = value
+ sliderValue = value.coerceIn(0f, 10f)
+ },
modifier = Modifier.fillMaxWidth(),
track = {
SliderDefaults.Track(
@@ -1244,19 +1245,18 @@ fun NumberPickerPreference(
Spacer(Modifier.height(16.dp))
val pickerSliderState =
- rememberSliderState(
+ rememberControlledSliderState(
value = sliderValue,
steps = maxValue - minValue - 1,
valueRange = minValue.toFloat()..maxValue.toFloat(),
- onValueChangeFinished = {},
)
- pickerSliderState.onValueChange = {
- sliderValue = it.coerceIn(minValue.toFloat(), maxValue.toFloat())
- }
- pickerSliderState.value = sliderValue
Slider(
state = pickerSliderState,
+ onValueChange = { value ->
+ pickerSliderState.value = value
+ sliderValue = value.coerceIn(minValue.toFloat(), maxValue.toFloat())
+ },
modifier = Modifier.fillMaxWidth(),
track = {
SliderDefaults.Track(
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/SortHeader.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/SortHeader.kt
index 6cebff9b6d..9cbe0459ae 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/SortHeader.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/SortHeader.kt
@@ -9,6 +9,8 @@ package moe.rukamori.archivetune.ui.component
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -17,17 +19,20 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.IconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SplitButtonDefaults
import androidx.compose.material3.SplitButtonLayout
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -47,6 +52,7 @@ inline fun > SortHeader(
crossinline sortTypeText: (T) -> Int,
modifier: Modifier = Modifier,
showDescending: Boolean? = true,
+ compact: Boolean = false,
) {
var menuExpanded by remember { mutableStateOf(false) }
val allowDescending =
@@ -62,7 +68,37 @@ inline fun > SortHeader(
)
Box(modifier = modifier.padding(vertical = 8.dp)) {
- if (showSortDirection) {
+ if (compact) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ TextButton(
+ onClick = { menuExpanded = true },
+ contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
+ modifier = Modifier.heightIn(min = 36.dp),
+ ) {
+ Text(
+ text = stringResource(sortTypeText(sortType)),
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ if (showSortDirection) {
+ IconButton(
+ onClick = { onSortDescendingChange(!sortDescending) },
+ modifier = Modifier.size(36.dp),
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.arrow_downward),
+ contentDescription =
+ stringResource(
+ if (sortDescending) R.string.sort_order_descending else R.string.sort_order_ascending,
+ ),
+ modifier = Modifier.size(18.dp).rotate(sortDirectionRotation),
+ )
+ }
+ }
+ }
+ } else if (showSortDirection) {
SplitButtonLayout(
leadingButton = {
SplitButtonDefaults.TonalLeadingButton(
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/SpotifyLibraryItems.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/SpotifyLibraryItems.kt
index 1499b2d833..dbfc33cd25 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/SpotifyLibraryItems.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/SpotifyLibraryItems.kt
@@ -67,6 +67,40 @@ fun SpotifyLibraryPlaylistListItem(
)
}
+@Composable
+fun SpotifyLikedSongsListItem(
+ navController: NavController,
+ modifier: Modifier = Modifier,
+) {
+ val openLikedSongs = remember(navController) { { navController.navigate("spotify_liked_songs") } }
+
+ ListItem(
+ title = stringResource(R.string.liked_songs),
+ subtitle = stringResource(R.string.spotify_account),
+ thumbnailContent = {
+ Icon(
+ painter = painterResource(R.drawable.favorite),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(ListThumbnailSize),
+ )
+ },
+ trailingContent = {
+ Icon(
+ painter = painterResource(R.drawable.spotify_icon),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(20.dp),
+ )
+ },
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .focusable()
+ .clickable(onClick = openLikedSongs),
+ )
+}
+
@Composable
fun SpotifyTrackListItem(
track: SpotifyTrack,
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/StarDialog.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/StarDialog.kt
index 61b77ff05f..caf929a60e 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/StarDialog.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/StarDialog.kt
@@ -68,7 +68,7 @@ fun StarDialog(
Button(
onClick = {
- uriHandler.openUri("https://koiiverse.cloud/donate")
+ uriHandler.openUri("https://miuka.moe/donate")
onSupport()
},
modifier = Modifier.fillMaxWidth(),
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/TopAppBarOverflowMenu.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/TopAppBarOverflowMenu.kt
new file mode 100644
index 0000000000..c136b32788
--- /dev/null
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/TopAppBarOverflowMenu.kt
@@ -0,0 +1,238 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class)
+
+package moe.rukamori.archivetune.ui.component
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Badge
+import androidx.compose.material3.BadgedBox
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.DropdownMenuPopup
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MenuDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.paneTitle
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import coil3.request.ImageRequest
+import dev.chrisbanes.haze.HazeInput
+import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.blur.HazeBlurStyle
+import dev.chrisbanes.haze.blur.hazeBlur
+import moe.rukamori.archivetune.R
+import moe.rukamori.archivetune.viewmodels.MainMenuDestination
+import moe.rukamori.archivetune.viewmodels.MenuProfileState
+
+@Composable
+fun TopAppBarOverflowMenu(
+ expanded: Boolean,
+ profileState: MenuProfileState,
+ accountImageUrl: String?,
+ hazeState: HazeState,
+ pureBlack: Boolean,
+ hasUnreadNews: Boolean,
+ hasUpdateAvailable: Boolean,
+ onDismissRequest: () -> Unit,
+ onSelect: (MainMenuDestination) -> Unit,
+) {
+ val surfaceColor = if (pureBlack) Color.Black else MaterialTheme.colorScheme.surfaceContainerHigh
+ val contentColor = if (pureBlack) Color.White else MaterialTheme.colorScheme.onSurface
+ val shape = MaterialTheme.shapes.large
+ val hazeInput = remember(hazeState) { HazeInput.Sources(hazeState) }
+ val blurStyle = remember(surfaceColor) {
+ HazeBlurStyle {
+ backgroundColor(surfaceColor)
+ blurRadius(24.dp)
+ noiseFactor(0.12f)
+ }
+ }
+ val menuTitle = stringResource(R.string.more_options)
+ DropdownMenuPopup(
+ expanded = expanded,
+ onDismissRequest = onDismissRequest,
+ ) {
+ Column(
+ modifier = Modifier
+ .width(264.dp)
+ .shadow(6.dp, shape)
+ .clip(shape)
+ .hazeBlur(input = hazeInput, style = blurStyle)
+ .background(surfaceColor.copy(alpha = 0.72f))
+ .border(1.dp, contentColor.copy(alpha = 0.08f), shape)
+ .semantics { paneTitle = menuTitle }
+ .heightIn(max = 560.dp)
+ .verticalScroll(rememberScrollState())
+ .padding(vertical = 8.dp),
+ ) {
+ MenuProfileHeader(
+ state = profileState,
+ accountImageUrl = accountImageUrl,
+ contentColor = contentColor,
+ onClick = remember(onSelect) { { onSelect(MainMenuDestination.Profile) } },
+ )
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ color = contentColor.copy(alpha = 0.12f),
+ )
+ OverflowAction(
+ label = stringResource(R.string.new_releases),
+ iconRes = R.drawable.new_release,
+ contentColor = contentColor,
+ onClick = remember(onSelect) { { onSelect(MainMenuDestination.NewRelease) } },
+ )
+ OverflowAction(
+ label = stringResource(R.string.news),
+ iconRes = R.drawable.newspaper,
+ contentColor = contentColor,
+ hasBadge = hasUnreadNews,
+ badgeDescription = if (hasUnreadNews) stringResource(R.string.news_tooltip_title) else null,
+ onClick = remember(onSelect) { { onSelect(MainMenuDestination.News) } },
+ )
+ OverflowAction(
+ label = stringResource(R.string.history),
+ iconRes = R.drawable.history,
+ contentColor = contentColor,
+ onClick = remember(onSelect) { { onSelect(MainMenuDestination.History) } },
+ )
+ OverflowAction(
+ label = stringResource(R.string.settings),
+ iconRes = R.drawable.settings,
+ contentColor = contentColor,
+ hasBadge = hasUpdateAvailable,
+ badgeDescription = if (hasUpdateAvailable) stringResource(R.string.new_update_available) else null,
+ onClick = remember(onSelect) { { onSelect(MainMenuDestination.Settings) } },
+ )
+ }
+ }
+}
+
+@Composable
+private fun MenuProfileHeader(
+ state: MenuProfileState,
+ accountImageUrl: String?,
+ contentColor: Color,
+ onClick: () -> Unit,
+) {
+ val profile = (state as? MenuProfileState.Success)?.profile
+ val subtitle = when (state) {
+ MenuProfileState.Loading -> stringResource(R.string.loading)
+ MenuProfileState.Empty -> stringResource(R.string.login)
+ is MenuProfileState.Error -> stringResource(state.messageResId)
+ is MenuProfileState.Success -> state.profile.name.ifBlank { stringResource(R.string.account) }
+ }
+ val context = LocalContext.current
+ val avatarSize = with(LocalDensity.current) { 48.dp.roundToPx() }
+ val avatarRequest = remember(context, accountImageUrl, avatarSize, profile != null) {
+ if (profile != null && !accountImageUrl.isNullOrBlank()) {
+ ImageRequest.Builder(context).data(accountImageUrl).size(avatarSize).build()
+ } else {
+ null
+ }
+ }
+ DropdownMenuItem(
+ modifier = Modifier.fillMaxWidth().heightIn(min = 80.dp),
+ text = {
+ Column {
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (profile != null && profile.email.isNotBlank()) {
+ Text(
+ text = profile.email,
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ },
+ leadingIcon = {
+ Box(
+ modifier = Modifier.size(48.dp).clip(CircleShape)
+ .background(contentColor.copy(alpha = 0.08f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(painterResource(R.drawable.account), null, modifier = Modifier.size(28.dp))
+ if (avatarRequest != null) {
+ AsyncImage(
+ model = avatarRequest,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.size(48.dp).clip(CircleShape),
+ )
+ }
+ }
+ },
+ trailingIcon = {
+ Icon(painterResource(R.drawable.navigate_next), null, modifier = Modifier.size(20.dp))
+ },
+ colors = MenuDefaults.itemColors(
+ textColor = contentColor,
+ leadingIconColor = contentColor,
+ trailingIconColor = contentColor,
+ ),
+ onClick = onClick,
+ )
+}
+
+@Composable
+private fun OverflowAction(
+ label: String,
+ @DrawableRes iconRes: Int,
+ contentColor: Color,
+ onClick: () -> Unit,
+ hasBadge: Boolean = false,
+ badgeDescription: String? = null,
+) {
+ DropdownMenuItem(
+ modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).semantics {
+ if (badgeDescription != null) stateDescription = badgeDescription
+ },
+ text = { Text(label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
+ leadingIcon = {
+ BadgedBox(badge = { if (hasBadge) Badge() }) {
+ Icon(painterResource(iconRes), null, modifier = Modifier.size(24.dp))
+ }
+ },
+ colors = MenuDefaults.itemColors(textColor = contentColor, leadingIconColor = contentColor),
+ onClick = onClick,
+ )
+}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/WaveformSliderTrack.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/WaveformSliderTrack.kt
new file mode 100644
index 0000000000..465e73a652
--- /dev/null
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/component/WaveformSliderTrack.kt
@@ -0,0 +1,86 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.ui.component
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.SliderState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import com.google.common.collect.ImmutableList
+import kotlin.math.max
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun WaveformSliderTrack(
+ sliderState: SliderState,
+ amplitudes: ImmutableList,
+ activeColor: Color,
+ modifier: Modifier = Modifier,
+) {
+ val inactiveColor = activeColor.copy(alpha = 0.26f)
+ Canvas(
+ modifier
+ .fillMaxWidth()
+ .height(WaveformTrackHeight),
+ ) {
+ if (amplitudes.isEmpty()) return@Canvas
+ val fraction =
+ if (sliderState.valueRange.endInclusive == sliderState.valueRange.start) {
+ 0f
+ } else {
+ ((sliderState.value - sliderState.valueRange.start) /
+ (sliderState.valueRange.endInclusive - sliderState.valueRange.start))
+ .coerceIn(0f, 1f)
+ }
+ val progress =
+ if (layoutDirection == LayoutDirection.Rtl) {
+ 1f - fraction
+ } else {
+ fraction
+ }
+ val binWidth = size.width / amplitudes.size
+ val barWidth = minOf(WaveformBarWidth.toPx(), binWidth * 0.72f)
+ val maxBarHeight = size.height * 0.9f
+ val minBarHeight = WaveformMinBarHeight.toPx()
+
+ amplitudes.forEachIndexed { index, amplitude ->
+ val centerX = (index + 0.5f) * binWidth
+ val height = max(minBarHeight, amplitude.coerceIn(0f, 1f) * maxBarHeight)
+ drawRoundRect(
+ color = if (centerX <= size.width * progress) activeColor else inactiveColor,
+ topLeft = Offset(centerX - barWidth / 2f, (size.height - height) / 2f),
+ size = Size(barWidth, height),
+ cornerRadius = CornerRadius(barWidth / 2f),
+ )
+ }
+
+ val playheadX = size.width * progress
+ drawLine(
+ color = activeColor,
+ start = Offset(playheadX, size.height * 0.08f),
+ end = Offset(playheadX, size.height * 0.92f),
+ strokeWidth = WaveformPlayheadWidth.toPx(),
+ cap = StrokeCap.Round,
+ )
+ }
+}
+
+private val WaveformTrackHeight = 40.dp
+private val WaveformBarWidth = 5.dp
+private val WaveformMinBarHeight = 3.dp
+private val WaveformPlayheadWidth = 2.dp
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/AlbumMenu.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/AlbumMenu.kt
index e4f40a0a59..5d62dfea8c 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/AlbumMenu.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/AlbumMenu.kt
@@ -98,6 +98,7 @@ import moe.rukamori.archivetune.ui.component.NewActionGrid
import moe.rukamori.archivetune.ui.component.SongListItem
import moe.rukamori.archivetune.ui.utils.HeaderDownloadItem
import moe.rukamori.archivetune.ui.utils.YtimgResizePolicy
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.ui.utils.resize
import moe.rukamori.archivetune.ui.utils.sendAddMissingDownloads
import moe.rukamori.archivetune.utils.SpeedDialPin
@@ -302,7 +303,7 @@ fun AlbumMenu(
.height(ListItemHeight)
.clickable {
splitArtist.originalArtist?.let { artist ->
- navController.navigate("artist/${artist.id}")
+ navController.navigateToArtist(artist.id)
showSelectArtistDialog = false
onDismiss()
}
@@ -679,7 +680,7 @@ fun AlbumMenu(
modifier =
Modifier.clickable {
if (splitArtists.size == 1 && splitArtists[0].originalArtist != null) {
- navController.navigate("artist/${splitArtists[0].originalArtist!!.id}")
+ navController.navigateToArtist(splitArtists[0].originalArtist!!.id)
onDismiss()
} else {
showSelectArtistDialog = true
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/LyricsMenu.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/LyricsMenu.kt
index 69ff3aa811..7f69f0e1c7 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/LyricsMenu.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/LyricsMenu.kt
@@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
@@ -47,7 +48,9 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.ExposedDropdownMenu
import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
@@ -79,6 +82,7 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
@@ -111,6 +115,8 @@ import moe.rukamori.archivetune.utils.TranslatorLanguages
import moe.rukamori.archivetune.utils.rememberEnumPreference
import moe.rukamori.archivetune.utils.rememberPreference
import moe.rukamori.archivetune.viewmodels.LyricsMenuViewModel
+import moe.rukamori.archivetune.viewmodels.LyricsRenderScreenState
+import moe.rukamori.archivetune.viewmodels.LyricsRenderViewModel
import moe.rukamori.archivetune.viewmodels.LyricsSearchResultUiModel
import moe.rukamori.archivetune.viewmodels.LyricsSearchScreenState
import moe.rukamori.archivetune.viewmodels.LyricsTranslationError
@@ -133,10 +139,28 @@ fun LyricsMenu(
showPlayerControlsState: State,
onShowPlayerControlsChange: (Boolean) -> Unit,
onDismiss: () -> Unit,
+ lyricsRenderViewModel: LyricsRenderViewModel,
viewModel: LyricsMenuViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val showPlayerControls by showPlayerControlsState
+ val renderState by lyricsRenderViewModel.state.collectAsStateWithLifecycle()
+ val preparedLyrics = (renderState as? LyricsRenderScreenState.Success)?.lyrics
+ val hasTranslations = preparedLyrics?.hasTranslations == true
+ val showTranslations = hasTranslations && preparedLyrics?.preferences?.showTranslations == true
+ val onShowTranslationsChange = remember(lyricsRenderViewModel) {
+ lyricsRenderViewModel::onShowTranslationsChanged
+ }
+ val translationSwitchModifier = remember(hasTranslations, showTranslations, onShowTranslationsChange) {
+ Modifier
+ .padding(horizontal = 8.dp)
+ .toggleable(
+ value = showTranslations,
+ enabled = hasTranslations,
+ role = Role.Switch,
+ onValueChange = onShowTranslationsChange,
+ )
+ }
var showEditDialog by rememberSaveable {
mutableStateOf(false)
@@ -333,11 +357,11 @@ fun LyricsMenu(
}
if (showLyricsSyncOffsetDialog) {
- var tempLyricsSyncOffset by remember { mutableFloatStateOf(lyricsSyncOffset.toFloat()) }
+ var tempLyricsSyncOffset by remember { mutableFloatStateOf(lyricsSyncOffset.toFloat().coerceIn(-5000f, 5000f)) }
DefaultDialog(
onDismiss = {
- tempLyricsSyncOffset = lyricsSyncOffset.toFloat()
+ tempLyricsSyncOffset = lyricsSyncOffset.toFloat().coerceIn(-5000f, 5000f)
showLyricsSyncOffsetDialog = false
},
icon = {
@@ -354,7 +378,7 @@ fun LyricsMenu(
Spacer(modifier = Modifier.weight(1f))
TextButton(
onClick = {
- tempLyricsSyncOffset = lyricsSyncOffset.toFloat()
+ tempLyricsSyncOffset = lyricsSyncOffset.toFloat().coerceIn(-5000f, 5000f)
showLyricsSyncOffsetDialog = false
},
shapes = ButtonDefaults.shapes(),
@@ -363,7 +387,7 @@ fun LyricsMenu(
}
TextButton(
onClick = {
- onLyricsSyncOffsetChange(tempLyricsSyncOffset.roundToInt())
+ onLyricsSyncOffsetChange(tempLyricsSyncOffset.roundToInt().coerceIn(-5000, 5000))
showLyricsSyncOffsetDialog = false
onDismiss()
},
@@ -386,8 +410,8 @@ fun LyricsMenu(
Slider(
value = tempLyricsSyncOffset,
onValueChange = { tempLyricsSyncOffset = it },
- valueRange = -1000f..1000f,
- steps = 79,
+ valueRange = -5000f..5000f,
+ steps = 199,
modifier = Modifier.fillMaxWidth(),
)
}
@@ -528,7 +552,10 @@ fun LyricsMenu(
},
modifier =
Modifier
- .menuAnchor()
+ .menuAnchor(
+ type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
+ enabled = !isTranslationInProgress,
+ )
.fillMaxWidth(),
)
@@ -581,7 +608,10 @@ fun LyricsMenu(
},
modifier =
Modifier
- .menuAnchor()
+ .menuAnchor(
+ type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
+ enabled = !isTranslationInProgress,
+ )
.fillMaxWidth(),
)
@@ -746,6 +776,18 @@ fun LyricsMenu(
),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
)
+ NewMenuItem(
+ headlineContent = { Text(stringResource(R.string.show_lyrics_translation)) },
+ trailingContent = {
+ Switch(
+ checked = showTranslations,
+ onCheckedChange = null,
+ enabled = hasTranslations,
+ )
+ },
+ enabled = hasTranslations,
+ modifier = translationSwitchModifier,
+ )
NewMenuItem(
headlineContent = {
Text(stringResource(R.string.show_lyrics_player_controls))
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/PlayerMenu.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/PlayerMenu.kt
index 169bfd11a2..ef03e9bfcb 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/PlayerMenu.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/PlayerMenu.kt
@@ -98,6 +98,7 @@ import moe.rukamori.archivetune.constants.PlayerDesignStyle
import moe.rukamori.archivetune.constants.PlayerDesignStyleKey
import moe.rukamori.archivetune.constants.SpeedDialSongIdsKey
import moe.rukamori.archivetune.models.MediaMetadata
+import moe.rukamori.archivetune.ui.component.rememberControlledSliderState
import moe.rukamori.archivetune.playback.CanvasArtworkRefetchResult
import moe.rukamori.archivetune.playback.ExoDownloadService
import moe.rukamori.archivetune.ui.component.BottomSheetState
@@ -106,6 +107,8 @@ import moe.rukamori.archivetune.ui.component.MenuSurfaceSection
import moe.rukamori.archivetune.ui.component.NewAction
import moe.rukamori.archivetune.ui.component.NewActionGrid
import moe.rukamori.archivetune.ui.player.rememberDeviceMusicVolumeController
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.utils.ExternalDownloaderLaunchResult
import moe.rukamori.archivetune.utils.SpeedDialPin
import moe.rukamori.archivetune.utils.SpeedDialPinType
@@ -289,7 +292,7 @@ fun PlayerMenu(
.fillMaxWidth()
.clickable {
splitArtist.originalArtist?.let { artist ->
- navController.navigate("artist/${artist.id}")
+ navController.navigateToArtist(artist.id)
showSelectArtistDialog = false
playerBottomSheetState.collapseSoft()
onDismiss()
@@ -709,7 +712,7 @@ fun PlayerMenu(
if (splitArtists.size == 1 && splitArtists[0].originalArtist != null) {
onDismiss()
playerBottomSheetState.collapseSoft()
- navController.navigate("artist/${splitArtists[0].originalArtist!!.id}")
+ navController.navigateToArtist(splitArtists[0].originalArtist!!.id)
} else {
showSelectArtistDialog = true
}
@@ -738,7 +741,7 @@ fun PlayerMenu(
Modifier.clickable {
onDismiss()
playerBottomSheetState.collapseSoft()
- navController.navigate("album/${mediaMetadata.album.id}")
+ navController.navigateToAlbum(mediaMetadata.album.id)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
@@ -1087,17 +1090,22 @@ private fun VolumeSliderL(
LaunchedEffect(safeValue) {
if (!isDragging) sliderValue = safeValue
}
+ val sliderState =
+ rememberControlledSliderState(
+ value = sliderValue,
+ valueRange = 0f..1f,
+ )
Slider(
- value = sliderValue,
+ state = sliderState,
onValueChange = { updated ->
isDragging = true
val coerced = updated.coerceIn(0f, 1f)
+ sliderState.value = coerced
sliderValue = coerced
onValueChange(coerced)
},
onValueChangeFinished = { isDragging = false },
- valueRange = 0f..1f,
modifier = modifier.height(36.dp),
thumb = {
Box(
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/SongMenu.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/SongMenu.kt
index 43983234c2..daafbcbb68 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/SongMenu.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/SongMenu.kt
@@ -10,7 +10,14 @@
package moe.rukamori.archivetune.ui.menu
import android.content.Intent
+import android.app.Activity
+import android.app.RecoverableSecurityException
+import android.os.Build
+import android.provider.MediaStore
import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.IntentSenderRequest
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
@@ -30,6 +37,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularWavyProgressIndicator
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -40,6 +48,7 @@ import androidx.compose.material3.LocalContentColor
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.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -103,6 +112,8 @@ import moe.rukamori.archivetune.ui.component.SongListItem
import moe.rukamori.archivetune.ui.component.TextFieldDialog
import moe.rukamori.archivetune.ui.utils.ShowMediaInfo
import moe.rukamori.archivetune.ui.utils.YtimgResizePolicy
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.ui.utils.resize
import moe.rukamori.archivetune.utils.ExternalDownloaderLaunchResult
import moe.rukamori.archivetune.utils.SpeedDialPin
@@ -136,6 +147,14 @@ fun SongMenu(
.getDownload(originalSong.id)
.collectAsState(initial = null)
val coroutineScope = rememberCoroutineScope()
+ var showDeleteLocalConfirm by rememberSaveable { mutableStateOf(false) }
+ val deleteLocalFileLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ database.query { deleteSongsByIds(listOf(song.id)) }
+ onDismiss()
+ }
+ }
val syncUtils = LocalSyncUtils.current
var refetchIconDegree by remember { mutableFloatStateOf(0f) }
@@ -270,6 +289,51 @@ fun SongMenu(
)
}
+ if (showDeleteLocalConfirm) {
+ AlertDialog(
+ onDismissRequest = { showDeleteLocalConfirm = false },
+ title = { Text(stringResource(R.string.delete_local_file)) },
+ text = { Text(stringResource(R.string.delete_local_file_confirm, song.song.title)) },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ showDeleteLocalConfirm = false
+ val uri = song.id.toUri()
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ val request = MediaStore.createDeleteRequest(context.contentResolver, listOf(uri))
+ deleteLocalFileLauncher.launch(IntentSenderRequest.Builder(request.intentSender).build())
+ } else {
+ val deleted = context.contentResolver.delete(uri, null, null)
+ if (deleted > 0) {
+ database.query { deleteSongsByIds(listOf(song.id)) }
+ onDismiss()
+ } else {
+ Toast.makeText(context, R.string.error_unknown, Toast.LENGTH_SHORT).show()
+ }
+ }
+ } catch (error: SecurityException) {
+ if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q && error is RecoverableSecurityException) {
+ deleteLocalFileLauncher.launch(
+ IntentSenderRequest.Builder(error.userAction.actionIntent.intentSender).build(),
+ )
+ } else {
+ Toast.makeText(context, R.string.error_unknown, Toast.LENGTH_SHORT).show()
+ }
+ }
+ },
+ ) {
+ Text(stringResource(R.string.delete))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showDeleteLocalConfirm = false }) {
+ Text(stringResource(R.string.cancel_button))
+ }
+ },
+ )
+ }
+
var showChoosePlaylistDialog by rememberSaveable {
mutableStateOf(false)
}
@@ -365,7 +429,7 @@ fun SongMenu(
.fillMaxWidth()
.clickable {
splitArtist.originalArtist?.let { artist ->
- navController.navigate("artist/${artist.id}")
+ navController.navigateToArtist(artist.id)
showSelectArtistDialog = false
onDismiss()
}
@@ -439,6 +503,7 @@ fun SongMenu(
val addToPlaylistText = stringResource(R.string.add_to_playlist)
val shareText = stringResource(R.string.share)
val editText = stringResource(R.string.edit)
+ val deleteLocalFileText = stringResource(R.string.delete_local_file)
val primaryActions =
remember(
@@ -449,6 +514,7 @@ fun SongMenu(
addToPlaylistText,
shareText,
editText,
+ deleteLocalFileText,
isLocalSong,
onDismiss,
playerConnection,
@@ -562,6 +628,22 @@ fun SongMenu(
onClick = { showEditDialog = true },
),
)
+ if (isLocalSong) {
+ add(
+ NewAction(
+ icon = {
+ Icon(
+ painter = painterResource(R.drawable.delete),
+ contentDescription = null,
+ modifier = Modifier.size(28.dp),
+ tint = MaterialTheme.colorScheme.error,
+ )
+ },
+ text = deleteLocalFileText,
+ onClick = { showDeleteLocalConfirm = true },
+ ),
+ )
+ }
}
}
@@ -974,7 +1056,7 @@ fun SongMenu(
modifier =
Modifier.clickable {
if (splitArtists.size == 1 && splitArtists[0].originalArtist != null) {
- navController.navigate("artist/${splitArtists[0].originalArtist!!.id}")
+ navController.navigateToArtist(splitArtists[0].originalArtist!!.id)
onDismiss()
} else {
showSelectArtistDialog = true
@@ -1000,7 +1082,7 @@ fun SongMenu(
modifier =
Modifier.clickable {
onDismiss()
- navController.navigate("album/${song.song.albumId}")
+ navController.navigateToAlbum(song.song.albumId)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/YouTubeAlbumMenu.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/YouTubeAlbumMenu.kt
index d389dbb20c..d678263f8c 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/YouTubeAlbumMenu.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/YouTubeAlbumMenu.kt
@@ -88,6 +88,7 @@ import moe.rukamori.archivetune.ui.component.NewActionGrid
import moe.rukamori.archivetune.ui.component.SongListItem
import moe.rukamori.archivetune.ui.component.YouTubeListItem
import moe.rukamori.archivetune.ui.utils.HeaderDownloadItem
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.ui.utils.sendAddMissingDownloads
import moe.rukamori.archivetune.utils.SpeedDialPin
import moe.rukamori.archivetune.utils.SpeedDialPinType
@@ -282,7 +283,7 @@ fun YouTubeAlbumMenu(
.height(ListItemHeight)
.clickable {
splitArtist.originalArtist?.let { artist ->
- navController.navigate("artist/${artist.id}")
+ navController.navigateToArtist(artist.id)
showSelectArtistDialog = false
onDismiss()
}
@@ -657,7 +658,7 @@ fun YouTubeAlbumMenu(
modifier =
Modifier.clickable {
if (splitArtists.size == 1 && splitArtists[0].originalArtist != null) {
- navController.navigate("artist/${splitArtists[0].originalArtist!!.id}")
+ navController.navigateToArtist(splitArtists[0].originalArtist!!.id)
onDismiss()
} else {
showSelectArtistDialog = true
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/YouTubeSongMenu.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/YouTubeSongMenu.kt
index 4df50207de..a8dba119ef 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/YouTubeSongMenu.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/menu/YouTubeSongMenu.kt
@@ -95,6 +95,8 @@ import moe.rukamori.archivetune.ui.component.MenuSurfaceSection
import moe.rukamori.archivetune.ui.component.NewAction
import moe.rukamori.archivetune.ui.component.NewActionGrid
import moe.rukamori.archivetune.ui.utils.ShowMediaInfo
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.utils.ExternalDownloaderLaunchResult
import moe.rukamori.archivetune.utils.SpeedDialPin
import moe.rukamori.archivetune.utils.SpeedDialPinType
@@ -211,7 +213,7 @@ fun YouTubeSongMenu(
.height(ListItemHeight)
.clickable {
splitArtist.originalArtist?.let { artist ->
- navController.navigate("artist/${artist.id}")
+ navController.navigateToArtist(artist.id)
showSelectArtistDialog = false
onDismiss()
}
@@ -734,7 +736,7 @@ fun YouTubeSongMenu(
modifier =
Modifier.clickable {
if (splitArtists.size == 1 && splitArtists[0].originalArtist != null) {
- navController.navigate("artist/${splitArtists[0].originalArtist!!.id}")
+ navController.navigateToArtist(splitArtists[0].originalArtist!!.id)
onDismiss()
} else {
showSelectArtistDialog = true
@@ -762,7 +764,7 @@ fun YouTubeSongMenu(
},
modifier =
Modifier.clickable {
- navController.navigate("album/${album.id}")
+ navController.navigateToAlbum(album.id)
onDismiss()
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/LandscapePlayerLayout.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/LandscapePlayerLayout.kt
new file mode 100644
index 0000000000..8e9edc0933
--- /dev/null
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/LandscapePlayerLayout.kt
@@ -0,0 +1,71 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.ui.player
+
+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.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+internal fun LandscapePlayerLayout(
+ artwork: @Composable () -> Unit,
+ controls: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ BoxWithConstraints(modifier = modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp)) {
+ val artworkWidth = minOf(maxHeight, maxWidth * 0.4f, 360.dp)
+ Row(
+ modifier = Modifier.fillMaxSize(),
+ horizontalArrangement = Arrangement.spacedBy(24.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier.width(artworkWidth).fillMaxHeight(),
+ contentAlignment = Alignment.Center,
+ ) {
+ artwork()
+ }
+ Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
+ controls()
+ }
+ }
+ }
+}
+
+@Composable
+internal fun LandscapePlayerControls(
+ playbackContent: @Composable ColumnScope.() -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Column(modifier = modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
+ content = content,
+ )
+ Column(modifier = Modifier.fillMaxWidth(), content = playbackContent)
+ }
+}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/LyricsScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/LyricsScreen.kt
index 61f9232550..8a57d30df3 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/LyricsScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/LyricsScreen.kt
@@ -119,6 +119,7 @@ import moe.rukamori.archivetune.ui.component.LocalMenuState
import moe.rukamori.archivetune.ui.component.LyricsEnhanced
import moe.rukamori.archivetune.ui.component.LyricsV2
import moe.rukamori.archivetune.ui.component.PlayerSliderTrack
+import moe.rukamori.archivetune.ui.component.rememberControlledSliderState
import moe.rukamori.archivetune.ui.menu.LyricsMenu
import moe.rukamori.archivetune.ui.theme.PlayerColorExtractor
import moe.rukamori.archivetune.utils.makeTimeString
@@ -338,6 +339,7 @@ fun LyricsScreen(
showPlayerControlsState = showPlayerControlsState,
onShowPlayerControlsChange = onShowPlayerControlsChange,
onDismiss = menuState::dismiss,
+ lyricsRenderViewModel = lyricsRenderViewModel,
)
}
}
@@ -987,11 +989,18 @@ private fun AppleMusicSlider(
thumbColor = Color.Transparent,
inactiveTrackColor = inactiveColor,
)
+ val sliderState =
+ rememberControlledSliderState(
+ value = value.coerceIn(safeRange),
+ valueRange = safeRange,
+ )
Slider(
- value = value.coerceIn(safeRange),
- valueRange = safeRange,
- onValueChange = onValueChange,
+ state = sliderState,
+ onValueChange = { updated ->
+ sliderState.value = updated
+ onValueChange(updated)
+ },
onValueChangeFinished = onValueChangeFinished,
colors = sliderColors,
thumb = { Spacer(modifier = Modifier.size(0.dp)) },
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/MiniPlayer.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/MiniPlayer.kt
index 364aeeb816..b6a7c44b5e 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/MiniPlayer.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/MiniPlayer.kt
@@ -42,6 +42,8 @@ import coil3.imageLoader
import coil3.request.ImageRequest
import coil3.request.allowHardware
import coil3.toBitmap
+import dev.chrisbanes.haze.HazeState
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import moe.rukamori.archivetune.LocalPlayerConnection
@@ -52,7 +54,9 @@ import moe.rukamori.archivetune.constants.FloatingBarOuterCornerRadius
import moe.rukamori.archivetune.constants.FloatingBarStandaloneCornerRadius
import moe.rukamori.archivetune.constants.MiniPlayerHeight
import moe.rukamori.archivetune.constants.NavigationBarMaxWidth
+import moe.rukamori.archivetune.constants.NavigationBarMode
import moe.rukamori.archivetune.constants.SwipeSensitivityKey
+import moe.rukamori.archivetune.ui.component.FloatingBarBlurBackground
import moe.rukamori.archivetune.ui.theme.PlayerColorExtractor
import moe.rukamori.archivetune.utils.rememberEnumPreference
import moe.rukamori.archivetune.utils.rememberPreference
@@ -64,16 +68,20 @@ private const val MiniPlayerPaletteCacheSize = 24
fun MiniPlayer(
position: Long,
duration: Long,
+ hazeState: HazeState,
modifier: Modifier = Modifier,
pureBlack: Boolean,
navigationProximityProvider: () -> Float = { 0f },
+ navigationBarMode: NavigationBarMode = NavigationBarMode.CONNECTED,
) {
NewMiniPlayer(
position = position,
duration = duration,
+ hazeState = hazeState,
modifier = modifier,
pureBlack = pureBlack,
navigationProximityProvider = navigationProximityProvider,
+ navigationBarMode = navigationBarMode,
)
}
@@ -81,9 +89,11 @@ fun MiniPlayer(
private fun NewMiniPlayer(
position: Long,
duration: Long,
+ hazeState: HazeState,
modifier: Modifier = Modifier,
pureBlack: Boolean,
navigationProximityProvider: () -> Float,
+ navigationBarMode: NavigationBarMode,
) {
val playerConnection = LocalPlayerConnection.current ?: return
val context = LocalContext.current
@@ -107,7 +117,9 @@ private fun NewMiniPlayer(
}
}
val fallbackColor = MaterialTheme.colorScheme.surface.toArgb()
- val shouldUseArtworkBackground = miniPlayerBackgroundStyle != MiniPlayerBackgroundStyle.THEME
+ val shouldUseArtworkBackground =
+ miniPlayerBackgroundStyle == MiniPlayerBackgroundStyle.GRADIENT ||
+ miniPlayerBackgroundStyle == MiniPlayerBackgroundStyle.GLOW
LaunchedEffect(
mediaMetadata?.id,
@@ -160,7 +172,10 @@ private fun NewMiniPlayer(
palette = palette,
fallbackColor = fallbackColor,
)
- }.getOrDefault(emptyList())
+ }.getOrElse { error ->
+ if (error is CancellationException) throw error
+ emptyList()
+ }
if (extractedColors.isNotEmpty()) {
gradientColorsCache[currentMetadata.id] = extractedColors
@@ -173,24 +188,27 @@ private fun NewMiniPlayer(
MiniPlayerBackgroundPalette.from(gradientColors)
}
val effectiveBackgroundStyle =
- if (shouldUseArtworkBackground && backgroundPalette != null) {
- miniPlayerBackgroundStyle
- } else {
+ if (shouldUseArtworkBackground && backgroundPalette == null) {
MiniPlayerBackgroundStyle.THEME
+ } else {
+ miniPlayerBackgroundStyle
}
val contentColors =
rememberMiniPlayerContentColors(
- useArtworkBackground = effectiveBackgroundStyle != MiniPlayerBackgroundStyle.THEME,
+ useArtworkBackground = shouldUseArtworkBackground && backgroundPalette != null,
)
- val proximity = navigationProximityProvider()
+ val proximity =
+ if (navigationBarMode == NavigationBarMode.CONNECTED) navigationProximityProvider() else 0f
val miniPlayerShape =
- RoundedCornerShape(
- topStart = lerp(FloatingBarStandaloneCornerRadius.value, FloatingBarOuterCornerRadius.value, proximity).dp,
- topEnd = lerp(FloatingBarStandaloneCornerRadius.value, FloatingBarOuterCornerRadius.value, proximity).dp,
- bottomStart = lerp(FloatingBarStandaloneCornerRadius.value, FloatingBarJunctionCornerRadius.value, proximity).dp,
- bottomEnd = lerp(FloatingBarStandaloneCornerRadius.value, FloatingBarJunctionCornerRadius.value, proximity).dp,
- )
+ remember(proximity) {
+ RoundedCornerShape(
+ topStart = lerp(FloatingBarStandaloneCornerRadius.value, FloatingBarOuterCornerRadius.value, proximity).dp,
+ topEnd = lerp(FloatingBarStandaloneCornerRadius.value, FloatingBarOuterCornerRadius.value, proximity).dp,
+ bottomStart = lerp(FloatingBarStandaloneCornerRadius.value, FloatingBarJunctionCornerRadius.value, proximity).dp,
+ bottomEnd = lerp(FloatingBarStandaloneCornerRadius.value, FloatingBarJunctionCornerRadius.value, proximity).dp,
+ )
+ }
val constrainToNavigationWidth by remember {
derivedStateOf { navigationProximityProvider() > 0f }
}
@@ -217,6 +235,8 @@ private fun NewMiniPlayer(
MiniPlayerBackground(
style = effectiveBackgroundStyle,
palette = backgroundPalette,
+ hazeState = hazeState,
+ pureBlack = pureBlack,
modifier = Modifier.fillMaxSize(),
)
NewMiniPlayerContent(
@@ -285,6 +305,8 @@ private fun rememberMiniPlayerContentColors(useArtworkBackground: Boolean): Mini
private fun MiniPlayerBackground(
style: MiniPlayerBackgroundStyle,
palette: MiniPlayerBackgroundPalette?,
+ hazeState: HazeState,
+ pureBlack: Boolean,
modifier: Modifier = Modifier,
) {
when (style) {
@@ -294,9 +316,19 @@ private fun MiniPlayerBackground(
)
}
+ MiniPlayerBackgroundStyle.ACRYLIC,
+ MiniPlayerBackgroundStyle.BLUR -> {
+ FloatingBarBlurBackground(
+ hazeState = hazeState,
+ isAcrylic = style == MiniPlayerBackgroundStyle.ACRYLIC,
+ pureBlack = pureBlack,
+ modifier = modifier,
+ )
+ }
+
MiniPlayerBackgroundStyle.GRADIENT -> {
val colors = requireNotNull(palette)
- Box(modifier = modifier) {
+ Box(modifier = modifier.background(Color.Black)) {
Box(
modifier =
Modifier
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/PlaybackErrorInfo.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/PlaybackErrorInfo.kt
index fc0a3a3fec..80b4305cfe 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/PlaybackErrorInfo.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/PlaybackErrorInfo.kt
@@ -12,6 +12,8 @@ import androidx.media3.datasource.HttpDataSource
import moe.rukamori.archivetune.morideobfuscator.youtubei.YoutubeiException
import moe.rukamori.archivetune.morideobfuscator.youtubei.YoutubeiFailureKind
import moe.rukamori.archivetune.utils.YTPlayerUtils
+import moe.rukamori.archivetune.utils.potoken.BrokenWebViewException
+import moe.rukamori.archivetune.utils.potoken.PoTokenException
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
@@ -50,6 +52,8 @@ internal fun PlaybackException.toPlaybackErrorInfo(): PlaybackErrorInfo {
resolutionFailure?.kind == YoutubeiFailureKind.TIMEOUT -> PlaybackErrorKind.Timeout
+ findCause() != null || findCause() != null -> PlaybackErrorKind.NoStream
+
resolutionFailure?.kind in
setOf(YoutubeiFailureKind.NO_FORMAT, YoutubeiFailureKind.UNAVAILABLE, YoutubeiFailureKind.PO_TOKEN)
-> PlaybackErrorKind.NoStream
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/Player.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/Player.kt
index 1fb6ee41b5..430555cebf 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/Player.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/Player.kt
@@ -10,7 +10,6 @@
package moe.rukamori.archivetune.ui.player
import android.content.Context
-import android.content.res.Configuration
import android.database.ContentObserver
import android.graphics.Bitmap
import android.media.AudioManager
@@ -19,6 +18,7 @@ import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.provider.Settings
+import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
@@ -33,6 +33,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
@@ -71,6 +72,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
@@ -78,9 +80,11 @@ import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
@@ -110,7 +114,6 @@ import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Layout
-import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
@@ -121,11 +124,13 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.compose.ui.zIndex
import androidx.core.graphics.drawable.toBitmap
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.C
import androidx.media3.common.Player.STATE_BUFFERING
+import androidx.media3.common.Player.STATE_ENDED
import androidx.media3.common.Player.STATE_READY
import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
@@ -139,18 +144,28 @@ import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.request.allowHardware
import coil3.toBitmap
+import dev.chrisbanes.haze.HazeState
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
import moe.rukamori.archivetune.LocalDownloadUtil
import moe.rukamori.archivetune.LocalPlayerConnection
import moe.rukamori.archivetune.R
import moe.rukamori.archivetune.canvas.CanvasPlaybackRequest
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.viewmodels.CanvasPlaybackViewModel
import moe.rukamori.archivetune.viewmodels.CanvasPlaybackState
import moe.rukamori.archivetune.viewmodels.ImmersivePlayerViewModel
+import moe.rukamori.archivetune.viewmodels.PlaybackWaveformState
+import moe.rukamori.archivetune.viewmodels.PlaybackWaveformViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import moe.rukamori.archivetune.constants.BackdropBlurAmountKey
import moe.rukamori.archivetune.constants.BackdropEnabledKey
@@ -158,6 +173,7 @@ import moe.rukamori.archivetune.constants.BlurRadiusKey
import moe.rukamori.archivetune.constants.DarkModeKey
import moe.rukamori.archivetune.constants.DisableBlurKey
import moe.rukamori.archivetune.constants.EnableHapticFeedbackKey
+import moe.rukamori.archivetune.constants.NavigationBarMode
import moe.rukamori.archivetune.constants.PlayerBackgroundStyle
import moe.rukamori.archivetune.constants.PlayerBackgroundStyleKey
import moe.rukamori.archivetune.constants.PlayerButtonsStyle
@@ -175,6 +191,7 @@ import moe.rukamori.archivetune.constants.SliderStyleKey
import moe.rukamori.archivetune.constants.ThumbnailCornerRadiusKey
import moe.rukamori.archivetune.extensions.metadata
import moe.rukamori.archivetune.extensions.togglePlayPause
+import timber.log.Timber
import moe.rukamori.archivetune.models.MediaMetadata
import moe.rukamori.archivetune.ui.component.BottomSheet
import moe.rukamori.archivetune.ui.component.BottomSheetState
@@ -293,11 +310,14 @@ internal fun rememberDeviceMusicVolumeController(): DeviceMusicVolumeController
fun BottomSheetPlayer(
state: BottomSheetState,
navController: NavController,
+ hazeState: HazeState,
modifier: Modifier = Modifier,
pureBlack: Boolean,
navigationProximityProvider: () -> Float = { 0f },
+ navigationBarMode: NavigationBarMode = NavigationBarMode.CONNECTED,
canvasViewModel: CanvasPlaybackViewModel = hiltViewModel(),
immersivePlayerViewModel: ImmersivePlayerViewModel = hiltViewModel(),
+ waveformViewModel: PlaybackWaveformViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val menuState = LocalMenuState.current
@@ -306,6 +326,8 @@ fun BottomSheetPlayer(
val playerConnection = LocalPlayerConnection.current ?: return
val immersivePlayerState by immersivePlayerViewModel.state.collectAsStateWithLifecycle()
+ val showQueueDiagnostics by QueueOpenDiagnostics.dialogVisible.collectAsState()
+ val openQueueDiagnostics: () -> Unit = { QueueOpenDiagnostics.showDialog() }
DisposableEffect(immersivePlayerViewModel, playerConnection) {
immersivePlayerViewModel.bind(playerConnection)
onDispose { immersivePlayerViewModel.unbind(playerConnection) }
@@ -443,6 +465,21 @@ fun BottomSheetPlayer(
isMusicVideo = mediaMetadata?.isMusicVideo ?: false,
)
val sliderStyle by rememberEnumPreference(SliderStyleKey, SliderStyle.Standard)
+ val waveformState by waveformViewModel.state.collectAsStateWithLifecycle()
+ LaunchedEffect(sliderStyle, mediaMetadata?.id, mediaMetadata?.duration) {
+ if (sliderStyle == SliderStyle.Waveform) {
+ mediaMetadata?.let { metadata ->
+ waveformViewModel.load(metadata.id, metadata.duration * 1_000)
+ }
+ } else {
+ waveformViewModel.cancel()
+ }
+ }
+ val waveformAmplitudes =
+ (waveformState as? PlaybackWaveformState.Ready)
+ ?.takeIf { it.mediaId == mediaMetadata?.id }
+ ?.waveform
+ ?.amplitudes
val canvasState by canvasViewModel.state.collectAsStateWithLifecycle()
val canvasRequest = remember(mediaMetadata, playerDesignStyle, aodModeEnabled) {
mediaMetadata?.takeIf {
@@ -829,11 +866,11 @@ fun BottomSheetPlayer(
when (event) {
is ImmersivePlayerEvent.OpenAlbum -> {
state.collapseSoft()
- navController.navigate("album/${event.id}")
+ navController.navigateToAlbum(event.id)
}
is ImmersivePlayerEvent.OpenArtist -> {
state.collapseSoft()
- navController.navigate("artist/${event.id}")
+ navController.navigateToArtist(event.id)
}
ImmersivePlayerEvent.OpenMenu -> {
val metadata = mediaMetadata ?: return@collect
@@ -968,28 +1005,39 @@ fun BottomSheetPlayer(
var isLyricsScreenVisible by rememberSaveable {
mutableStateOf(false)
}
- val openQueue =
- remember(state, queueSheetState) {
+ val queueDiagnosticsScope = rememberCoroutineScope()
+ val openQueue: () -> Unit =
+ remember(state, queueSheetState, playerConnection, mediaMetadata, playerDesignStyle, currentRoute) {
{
+ val attemptId =
+ QueueOpenDiagnostics.begin(
+ "style=$playerDesignStyle, route=${currentRoute ?: "unknown"}, " +
+ "mediaId=${mediaMetadata?.id ?: "none"}",
+ )
+ QueueOpenDiagnostics.record(
+ attemptId,
+ "Before request: playerExpanded=${state.isExpanded}, " +
+ "playerTargetExpanded=${state.isExpandedOrExpanding}, " +
+ "queueExpanded=${queueSheetState.isExpanded}, " +
+ "queueTargetExpanded=${queueSheetState.isExpandedOrExpanding}, " +
+ "queueSize=${playerConnection.player.mediaItemCount}",
+ )
isLyricsScreenVisible = false
- if (!state.isExpandedOrExpanding) {
+ try {
state.expandSoft()
}
queueSheetState.expandSoft()
}
}
- if (!aodModeEnabled) {
- BackHandler(
- enabled =
- queueSheetState.isExpandedOrExpanding ||
- state.isExpandedOrExpanding,
- ) {
- when {
- isLyricsScreenVisible && state.isExpandedOrExpanding -> isLyricsScreenVisible = false
- queueSheetState.isExpandedOrExpanding -> queueSheetState.collapseSoft()
- state.isExpandedOrExpanding -> state.collapseSoft()
- }
+ val isPlayerVisible by remember(state) {
+ derivedStateOf { state.isExpandedOrExpanding || state.progress > 0f }
+ }
+ BackHandler(enabled = !aodModeEnabled && isPlayerVisible) {
+ when {
+ isLyricsScreenVisible -> isLyricsScreenVisible = false
+ queueSheetState.isExpandedOrExpanding -> queueSheetState.collapseSoft()
+ state.isExpandedOrExpanding -> state.collapseSoft()
}
}
@@ -1170,13 +1218,15 @@ fun BottomSheetPlayer(
onDismiss = {
playerConnection.service.stopAndClearPlayback(clearPersistentState = true)
},
- backHandlerEnabled = !aodModeEnabled,
+ backHandlerEnabled = false,
collapsedContent = {
MiniPlayer(
position = position,
duration = duration,
+ hazeState = hazeState,
pureBlack = pureBlack,
navigationProximityProvider = navigationProximityProvider,
+ navigationBarMode = navigationBarMode,
)
},
) {
@@ -1242,11 +1292,12 @@ fun BottomSheetPlayer(
?.video
val artworkCanvas = resolvedCanvas
- val controlsContent: @Composable ColumnScope.(MediaMetadata) -> Unit = { mediaMetadata ->
+ val controlsContent: @Composable ColumnScope.(MediaMetadata, Boolean) -> Unit = { mediaMetadata, landscape ->
PlayerControlsContent(
mediaMetadata = mediaMetadata,
playerDesignStyle = playerDesignStyle,
sliderStyle = sliderStyle,
+ waveformAmplitudes = waveformAmplitudes,
playbackState = playbackState,
isPlaying = isPlaying,
isLoading = isLoading,
@@ -1269,6 +1320,7 @@ fun BottomSheetPlayer(
onSliderValueChange = onSliderValueChange,
onSliderValueChangeFinished = onSliderValueChangeFinished,
currentFormat = if (playerDesignStyle == PlayerDesignStyle.V7) currentFormat else null,
+ landscape = landscape,
)
}
@@ -1295,8 +1347,8 @@ fun BottomSheetPlayer(
// distance
- when (LocalConfiguration.current.orientation) {
- Configuration.ORIENTATION_LANDSCAPE -> {
+ BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
+ if (maxWidth > maxHeight) {
if (playerDesignStyle == PlayerDesignStyle.V5) {
val littleBackground = MaterialTheme.colorScheme.primaryContainer
val littleTextColor = MaterialTheme.colorScheme.onPrimaryContainer
@@ -1356,12 +1408,21 @@ fun BottomSheetPlayer(
onCollapse = state::collapseSoft,
onToggleLike = playerConnection::toggleLike,
onExpandQueue = openQueue,
+ onQueueLongClick = openQueueDiagnostics,
onMenuClick = {
menuState.show {
PlayerMenu(
mediaMetadata = metadata,
navController = navController,
playerBottomSheetState = state,
+ sleepTimerEnabled = sleepTimerEnabled,
+ onSleepTimerClick = {
+ if (sleepTimerEnabled) {
+ playerConnection.service.sleepTimer.clear()
+ } else {
+ showSleepTimerDialog = true
+ }
+ },
onShowDetailsDialog = {
bottomSheetPageState.show {
ShowMediaInfo(metadata.id)
@@ -1439,7 +1500,7 @@ fun BottomSheetPlayer(
.padding(bottom = queueSheetState.collapsedBound)
.windowInsetsPadding(
WindowInsets.systemBars.only(
- WindowInsetsSides.Top + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom,
+ WindowInsetsSides.Top + WindowInsetsSides.Horizontal,
),
).nestedScroll(state.preUpPostDownNestedScrollConnection),
)
@@ -1468,6 +1529,7 @@ fun BottomSheetPlayer(
canvasFallbackUrl = artworkCanvas?.videoUrl,
onCollapseClick = { state.collapseSoft() },
onQueueClick = openQueue,
+ onQueueLongClick = openQueueDiagnostics,
onLyricsClick = { isLyricsScreenVisible = true },
onSliderValueChange = onSliderValueChange,
onSliderValueChangeFinished = onSliderValueChangeFinished,
@@ -1479,7 +1541,7 @@ fun BottomSheetPlayer(
.padding(bottom = queueSheetState.collapsedBound)
.windowInsetsPadding(
WindowInsets.systemBars.only(
- WindowInsetsSides.Top + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom,
+ WindowInsetsSides.Top + WindowInsetsSides.Horizontal,
),
).nestedScroll(state.preUpPostDownNestedScrollConnection),
)
@@ -1504,6 +1566,7 @@ fun BottomSheetPlayer(
iconButtonColor = iconButtonColor,
onCollapseClick = { state.collapseSoft() },
onQueueClick = openQueue,
+ onQueueLongClick = openQueueDiagnostics,
onLyricsClick = { isLyricsScreenVisible = true },
onSliderValueChange = onSliderValueChange,
onSliderValueChangeFinished = onSliderValueChangeFinished,
@@ -1522,6 +1585,14 @@ fun BottomSheetPlayer(
mediaMetadata = metadata,
navController = navController,
playerBottomSheetState = state,
+ sleepTimerEnabled = sleepTimerEnabled,
+ onSleepTimerClick = {
+ if (sleepTimerEnabled) {
+ playerConnection.service.sleepTimer.clear()
+ } else {
+ showSleepTimerDialog = true
+ }
+ },
onShowDetailsDialog = {
bottomSheetPageState.show {
ShowMediaInfo(metadata.id)
@@ -1541,52 +1612,37 @@ fun BottomSheetPlayer(
.padding(bottom = queueSheetState.collapsedBound)
.windowInsetsPadding(
WindowInsets.systemBars.only(
- WindowInsetsSides.Top + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom,
+ WindowInsetsSides.Top + WindowInsetsSides.Horizontal,
),
)
.nestedScroll(state.preUpPostDownNestedScrollConnection),
)
}
} else {
- Row(
- modifier =
- Modifier
- .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal))
- .padding(bottom = queueSheetState.collapsedBound + 48.dp),
- ) {
- Box(
- contentAlignment = Alignment.Center,
- modifier = Modifier.weight(1f),
- ) {
- val screenWidth = LocalConfiguration.current.screenWidthDp
- val thumbnailSize = (screenWidth * 0.4).dp
+ LandscapePlayerLayout(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(bottom = queueSheetState.collapsedBound)
+ .windowInsetsPadding(
+ WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
+ ),
+ artwork = {
Thumbnail(
canvas = artworkCanvas,
sliderPositionProvider = { sliderPosition },
- modifier = Modifier.size(thumbnailSize),
+ modifier = Modifier.fillMaxSize(),
isPlayerExpanded = state.isExpanded,
+ applyStatusBarPadding = false,
)
- }
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- modifier =
- Modifier
- .weight(1f)
- .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)),
- ) {
- Spacer(Modifier.weight(1f))
-
- enrichedMetadata?.let {
- controlsContent(it)
+ },
+ controls = {
+ Column(modifier = Modifier.fillMaxSize()) {
+ enrichedMetadata?.let { controlsContent(it, true) }
}
-
- Spacer(Modifier.weight(1f))
- }
- }
+ },
+ )
}
- }
-
- else -> {
+ } else {
if (playerDesignStyle == PlayerDesignStyle.V5) {
val littleBackground = MaterialTheme.colorScheme.primaryContainer
val littleTextColor = MaterialTheme.colorScheme.onPrimaryContainer
@@ -1648,12 +1704,21 @@ fun BottomSheetPlayer(
onCollapse = state::collapseSoft,
onToggleLike = playerConnection::toggleLike,
onExpandQueue = openQueue,
+ onQueueLongClick = openQueueDiagnostics,
onMenuClick = {
menuState.show {
PlayerMenu(
mediaMetadata = metadata,
navController = navController,
playerBottomSheetState = state,
+ sleepTimerEnabled = sleepTimerEnabled,
+ onSleepTimerClick = {
+ if (sleepTimerEnabled) {
+ playerConnection.service.sleepTimer.clear()
+ } else {
+ showSleepTimerDialog = true
+ }
+ },
onShowDetailsDialog = {
bottomSheetPageState.show {
ShowMediaInfo(metadata.id)
@@ -1760,6 +1825,7 @@ fun BottomSheetPlayer(
canvasFallbackUrl = artworkCanvas?.videoUrl,
onCollapseClick = { state.collapseSoft() },
onQueueClick = openQueue,
+ onQueueLongClick = openQueueDiagnostics,
onLyricsClick = { isLyricsScreenVisible = true },
onSliderValueChange = onSliderValueChange,
onSliderValueChangeFinished = onSliderValueChangeFinished,
@@ -1795,6 +1861,7 @@ fun BottomSheetPlayer(
iconButtonColor = iconButtonColor,
onCollapseClick = { state.collapseSoft() },
onQueueClick = openQueue,
+ onQueueLongClick = openQueueDiagnostics,
onLyricsClick = { isLyricsScreenVisible = true },
onSliderValueChange = onSliderValueChange,
onSliderValueChangeFinished = onSliderValueChangeFinished,
@@ -1813,6 +1880,14 @@ fun BottomSheetPlayer(
mediaMetadata = metadata,
navController = navController,
playerBottomSheetState = state,
+ sleepTimerEnabled = sleepTimerEnabled,
+ onSleepTimerClick = {
+ if (sleepTimerEnabled) {
+ playerConnection.service.sleepTimer.clear()
+ } else {
+ showSleepTimerDialog = true
+ }
+ },
onShowDetailsDialog = {
bottomSheetPageState.show {
ShowMediaInfo(metadata.id)
@@ -1862,7 +1937,7 @@ fun BottomSheetPlayer(
}
enrichedMetadata?.let {
- controlsContent(it)
+ controlsContent(it, false)
}
Spacer(Modifier.height(30.dp))
@@ -1892,6 +1967,7 @@ fun BottomSheetPlayer(
state = queueSheetState,
playerBottomSheetState = state,
navController = navController,
+ modifier = Modifier.fillMaxSize().zIndex(1f),
backgroundColor =
if (useBlackBackground) {
Color.Black
@@ -1903,9 +1979,14 @@ fun BottomSheetPlayer(
textButtonColor = textButtonColor,
iconButtonColor = iconButtonColor,
onShowLyrics = { isLyricsScreenVisible = true },
+ onQueueLongClick = openQueueDiagnostics,
pureBlack = pureBlack,
)
+ if (showQueueDiagnostics) {
+ QueueOpenDiagnosticsDialog(onDismiss = QueueOpenDiagnostics::dismissDialog)
+ }
+
mediaMetadata?.let { metadata ->
MikoLyricsTransition(
visible = isLyricsScreenVisible,
@@ -2223,6 +2304,7 @@ private fun LittlePlayerContent(
onCollapse: () -> Unit,
onToggleLike: () -> Unit,
onExpandQueue: () -> Unit,
+ onQueueLongClick: () -> Unit,
onMenuClick: () -> Unit,
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
@@ -2387,10 +2469,9 @@ private fun LittlePlayerContent(
modifier =
Modifier
.size(iconSize)
- .clickable(
- interactionSource = remember { MutableInteractionSource() },
- indication = null,
+ .combinedClickable(
onClick = onExpandQueue,
+ onLongClick = onQueueLongClick,
),
)
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/PlayerComponents.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/PlayerComponents.kt
index 2ac1afe115..ff2955e204 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/PlayerComponents.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/PlayerComponents.kt
@@ -26,6 +26,8 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
@@ -43,7 +45,6 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -87,6 +88,7 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.CompositionLocalProvider
import moe.rukamori.archivetune.ui.component.LocalMenuState
+import moe.rukamori.archivetune.LocalPlayerConnection
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -141,7 +143,6 @@ import androidx.media3.common.Player.STATE_ENDED
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.navigation.NavController
import coil3.compose.AsyncImage
-import me.saket.squiggles.SquigglySlider
import moe.rukamori.archivetune.canvas.CanvasSource
import moe.rukamori.archivetune.R
import moe.rukamori.archivetune.constants.EnableHapticFeedbackKey
@@ -159,10 +160,13 @@ import moe.rukamori.archivetune.ui.component.BottomSheetPageState
import moe.rukamori.archivetune.ui.component.BottomSheetState
import moe.rukamori.archivetune.ui.component.MenuState
import moe.rukamori.archivetune.ui.component.PlayerSliderTrack
+import moe.rukamori.archivetune.ui.component.WaveformSliderTrack
+import moe.rukamori.archivetune.ui.component.rememberControlledSliderState
import moe.rukamori.archivetune.ui.component.ResizableIconButton
import moe.rukamori.archivetune.ui.menu.PlayerMenu
import moe.rukamori.archivetune.ui.theme.PlayerBackgroundColorUtils
import moe.rukamori.archivetune.ui.theme.PlayerSliderColors
+import com.google.common.collect.ImmutableList
import moe.rukamori.archivetune.ui.utils.ShowMediaInfo
import moe.rukamori.archivetune.ui.utils.highRes
import moe.rukamori.archivetune.utils.makeTimeString
@@ -753,6 +757,7 @@ fun PlayerTopActions(
@Composable
fun PlayerSlider(
sliderStyle: SliderStyle,
+ waveformAmplitudes: ImmutableList?,
sliderPosition: Long?,
position: Long,
duration: Long,
@@ -766,6 +771,7 @@ fun PlayerSlider(
StyledPlaybackSlider(
sliderStyle = sliderStyle,
+ waveformAmplitudes = waveformAmplitudes,
value = safeValue,
valueRange = 0f..maxOf(1f, safeDuration),
onValueChange = { onValueChange(it.toLong()) },
@@ -776,10 +782,23 @@ fun PlayerSlider(
)
}
+private fun Modifier.playPauseClickable(
+ player: Player,
+ onClick: () -> Unit,
+): Modifier =
+ combinedClickable(
+ onClick = onClick,
+ onLongClick = {
+ val currentIndex = player.currentMediaItemIndex
+ if (currentIndex >= 0) player.seekTo(currentIndex, 0L)
+ },
+ )
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StyledPlaybackSlider(
sliderStyle: SliderStyle,
+ waveformAmplitudes: ImmutableList? = null,
value: Float,
valueRange: ClosedFloatingPointRange,
onValueChange: (Float) -> Unit,
@@ -801,26 +820,43 @@ fun StyledPlaybackSlider(
}
SliderStyle.Wavy -> {
- SquigglySlider(
- value = value,
- valueRange = valueRange,
- onValueChange = onValueChange,
+ val sliderState =
+ rememberControlledSliderState(
+ value = value,
+ valueRange = valueRange,
+ )
+ Slider(
+ state = sliderState,
+ onValueChange = { updated ->
+ sliderState.value = updated
+ onValueChange(updated)
+ },
onValueChangeFinished = onValueChangeFinished,
colors = PlayerSliderColors.wavySliderColors(activeColor),
+ thumb = { Spacer(modifier = Modifier.size(0.dp)) },
+ track = { sliderState ->
+ PlayerSliderTrack(
+ sliderState = sliderState,
+ colors = PlayerSliderColors.wavySliderColors(activeColor),
+ trackHeight = if (isPlaying) 6.dp else 3.dp,
+ )
+ },
modifier = modifier,
- squigglesSpec =
- SquigglySlider.SquigglesSpec(
- amplitude = if (isPlaying) 2.dp else 0.dp,
- strokeWidth = 6.dp,
- ),
)
}
SliderStyle.Thick -> {
+ val sliderState =
+ rememberControlledSliderState(
+ value = value,
+ valueRange = valueRange,
+ )
Slider(
- value = value,
- valueRange = valueRange,
- onValueChange = onValueChange,
+ state = sliderState,
+ onValueChange = { updated ->
+ sliderState.value = updated
+ onValueChange(updated)
+ },
onValueChangeFinished = onValueChangeFinished,
colors = PlayerSliderColors.thickSliderColors(activeColor),
thumb = { Spacer(modifier = Modifier.size(0.dp)) },
@@ -836,26 +872,35 @@ fun StyledPlaybackSlider(
}
SliderStyle.Circular -> {
- SquigglySlider(
- value = value,
- valueRange = valueRange,
- onValueChange = onValueChange,
+ val sliderState =
+ rememberControlledSliderState(
+ value = value,
+ valueRange = valueRange,
+ )
+ Slider(
+ state = sliderState,
+ onValueChange = { updated ->
+ sliderState.value = updated
+ onValueChange(updated)
+ },
onValueChangeFinished = onValueChangeFinished,
colors = PlayerSliderColors.circularSliderColors(activeColor),
modifier = modifier,
- squigglesSpec =
- SquigglySlider.SquigglesSpec(
- amplitude = if (isPlaying) 2.dp else 0.dp,
- strokeWidth = 6.dp,
- ),
)
}
SliderStyle.Simple -> {
+ val sliderState =
+ rememberControlledSliderState(
+ value = value,
+ valueRange = valueRange,
+ )
Slider(
- value = value,
- valueRange = valueRange,
- onValueChange = onValueChange,
+ state = sliderState,
+ onValueChange = { updated ->
+ sliderState.value = updated
+ onValueChange(updated)
+ },
onValueChangeFinished = onValueChangeFinished,
colors = PlayerSliderColors.simpleSliderColors(activeColor),
thumb = { Spacer(modifier = Modifier.size(0.dp)) },
@@ -869,6 +914,36 @@ fun StyledPlaybackSlider(
modifier = modifier,
)
}
+
+ SliderStyle.Waveform -> {
+ if (waveformAmplitudes == null) {
+ Slider(
+ value = value,
+ valueRange = valueRange,
+ onValueChange = onValueChange,
+ onValueChangeFinished = onValueChangeFinished,
+ colors = PlayerSliderColors.standardSliderColors(activeColor),
+ modifier = modifier,
+ )
+ } else {
+ Slider(
+ value = value,
+ valueRange = valueRange,
+ onValueChange = onValueChange,
+ onValueChangeFinished = onValueChangeFinished,
+ colors = PlayerSliderColors.standardSliderColors(activeColor),
+ thumb = { Spacer(modifier = Modifier.size(0.dp)) },
+ track = { sliderState ->
+ WaveformSliderTrack(
+ sliderState = sliderState,
+ amplitudes = waveformAmplitudes,
+ activeColor = activeColor,
+ )
+ },
+ modifier = modifier,
+ )
+ }
+ }
}
}
@@ -999,25 +1074,22 @@ fun PlayerPlaybackControls(
Spacer(modifier = Modifier.width(16.dp))
- FilledIconButton(
- onClick = {
- haptic.performHapticFeedback(HapticFeedbackType.LongPress)
- if (playbackState == STATE_ENDED) {
- playerConnection.player.seekTo(0, 0)
- playerConnection.player.playWhenReady = true
- } else {
- playerConnection.player.togglePlayPause()
- }
- },
- colors =
- IconButtonDefaults.filledIconButtonColors(
- containerColor = textButtonColor,
- contentColor = iconButtonColor,
- ),
+ Box(
modifier =
Modifier
.size(width = playButtonWidth, height = playButtonHeight)
- .clip(RoundedCornerShape(32.dp)),
+ .clip(RoundedCornerShape(32.dp))
+ .background(textButtonColor)
+ .playPauseClickable(playerConnection.player) {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ if (playbackState == STATE_ENDED) {
+ playerConnection.player.seekTo(0, 0)
+ playerConnection.player.playWhenReady = true
+ } else {
+ playerConnection.player.togglePlayPause()
+ }
+ },
+ contentAlignment = Alignment.Center,
) {
if (isLoading) {
CircularWavyProgressIndicator(
@@ -1129,7 +1201,7 @@ fun PlayerPlaybackControls(
.size(70.dp)
.clip(RoundedCornerShape(50))
.background(textBackgroundColor)
- .clickable {
+ .playPauseClickable(playerConnection.player) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (playbackState == STATE_ENDED) {
playerConnection.player.seekTo(0, 0)
@@ -1318,7 +1390,13 @@ fun PlayerPlaybackControls(
}
Surface(
- onClick = {
+ shape = RoundedCornerShape(cinematicPlayPauseCorner),
+ color = textButtonColor,
+ modifier =
+ Modifier
+ .padding(horizontal = 20.dp)
+ .size(88.dp)
+ .playPauseClickable(playerConnection.player) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (playbackState == STATE_ENDED) {
playerConnection.player.seekTo(0, 0)
@@ -1326,13 +1404,7 @@ fun PlayerPlaybackControls(
} else {
playerConnection.player.togglePlayPause()
}
- },
- shape = RoundedCornerShape(cinematicPlayPauseCorner),
- color = textButtonColor,
- modifier =
- Modifier
- .padding(horizontal = 20.dp)
- .size(88.dp),
+ },
) {
Box(
modifier = Modifier.fillMaxSize(),
@@ -1495,7 +1567,7 @@ fun PlayerPlaybackControls(
.size(72.dp)
.clip(RoundedCornerShape(playPauseRoundness))
.background(textButtonColor)
- .clickable {
+ .playPauseClickable(playerConnection.player) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (playbackState == STATE_ENDED) {
playerConnection.player.seekTo(0, 0)
@@ -1629,7 +1701,12 @@ fun PlayerPlaybackControls(
Spacer(modifier = Modifier.width(6.dp))
Surface(
- onClick = {
+ shape = RoundedCornerShape(28.dp),
+ color = textButtonColor,
+ modifier =
+ Modifier
+ .size(width = 88.dp, height = 80.dp)
+ .playPauseClickable(playerConnection.player) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (playbackState == STATE_ENDED) {
playerConnection.player.seekTo(0, 0)
@@ -1637,12 +1714,7 @@ fun PlayerPlaybackControls(
} else {
playerConnection.player.togglePlayPause()
}
- },
- shape = RoundedCornerShape(28.dp),
- color = textButtonColor,
- modifier =
- Modifier
- .size(width = 88.dp, height = 80.dp),
+ },
) {
Box(
modifier = Modifier.fillMaxSize(),
@@ -1818,6 +1890,7 @@ fun PlayerControlsContent(
mediaMetadata: MediaMetadata,
playerDesignStyle: PlayerDesignStyle,
sliderStyle: SliderStyle,
+ waveformAmplitudes: ImmutableList?,
playbackState: Int,
isPlaying: Boolean,
isLoading: Boolean,
@@ -1840,6 +1913,7 @@ fun PlayerControlsContent(
onSliderValueChange: (Long) -> Unit,
onSliderValueChangeFinished: () -> Unit,
currentFormat: FormatEntity? = null,
+ landscape: Boolean = false,
) {
val currentSong by playerConnection.currentSong.collectAsState(initial = null)
val currentSongLiked = currentSong?.song?.liked == true
@@ -1850,45 +1924,67 @@ fun PlayerControlsContent(
label = "playPauseRoundness",
)
- Row(
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(horizontal = PlayerHorizontalPadding),
- ) {
- Column(modifier = Modifier.weight(1f)) {
- PlayerTitleSection(
+ PlayerControlsLayout(
+ landscape = landscape,
+ playbackContent = {
+ PlayerPlaybackControls(
+ playerDesignStyle = playerDesignStyle,
+ playbackState = playbackState,
+ isPlaying = isPlaying,
+ isLoading = isLoading,
+ repeatMode = repeatMode,
+ canSkipPrevious = canSkipPrevious,
+ canSkipNext = canSkipNext,
+ textButtonColor = textButtonColor,
+ iconButtonColor = iconButtonColor,
+ textBackgroundColor = textBackgroundColor,
+ icBackgroundColor = icBackgroundColor,
+ playPauseRoundness = playPauseRoundness,
+ playerConnection = playerConnection,
+ currentSongLiked = currentSongLiked,
+ )
+ },
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = PlayerHorizontalPadding),
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ PlayerTitleSection(
+ mediaMetadata = mediaMetadata,
+ textBackgroundColor = textBackgroundColor,
+ navController = navController,
+ state = state,
+ )
+ }
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ PlayerTopActions(
mediaMetadata = mediaMetadata,
+ playerDesignStyle = playerDesignStyle,
+ textButtonColor = textButtonColor,
+ iconButtonColor = iconButtonColor,
textBackgroundColor = textBackgroundColor,
+ playerConnection = playerConnection,
navController = navController,
+ menuState = menuState,
state = state,
+ bottomSheetPageState = bottomSheetPageState,
+ context = context,
+ currentSongLiked = currentSongLiked,
)
}
- Spacer(modifier = Modifier.width(12.dp))
-
- PlayerTopActions(
- mediaMetadata = mediaMetadata,
- playerDesignStyle = playerDesignStyle,
- textButtonColor = textButtonColor,
- iconButtonColor = iconButtonColor,
- textBackgroundColor = textBackgroundColor,
- playerConnection = playerConnection,
- navController = navController,
- menuState = menuState,
- state = state,
- bottomSheetPageState = bottomSheetPageState,
- context = context,
- currentSongLiked = currentSongLiked,
- )
- }
-
Spacer(Modifier.height(12.dp))
PlayerSlider(
sliderStyle = sliderStyle,
+ waveformAmplitudes = waveformAmplitudes,
sliderPosition = sliderPosition,
position = position,
duration = duration,
@@ -1898,73 +1994,75 @@ fun PlayerControlsContent(
onValueChangeFinished = onSliderValueChangeFinished,
)
- Spacer(Modifier.height(4.dp))
+ Spacer(Modifier.height(4.dp))
- PlayerTimeLabel(
- sliderPosition = sliderPosition,
- position = position,
- duration = duration,
- textBackgroundColor = textBackgroundColor,
- showRemainingTime = playerDesignStyle == PlayerDesignStyle.V7,
- centerContent =
- if (playerDesignStyle == PlayerDesignStyle.V7 && currentFormat != null) {
- {
- val codec = currentFormat.mimeType.substringAfter("/").uppercase()
- val label =
- when {
- codec.contains("FLAC") || codec.contains("ALAC") -> "Lossless"
- codec.contains("OPUS") -> codec
- codec.contains("AAC") -> codec
- codec.contains("MP4A") -> "AAC"
- codec.contains("VORBIS") -> "Vorbis"
- else -> codec
- }
- Surface(
- shape = RoundedCornerShape(4.dp),
- color = textBackgroundColor.copy(alpha = 0.12f),
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
+ PlayerTimeLabel(
+ sliderPosition = sliderPosition,
+ position = position,
+ duration = duration,
+ textBackgroundColor = textBackgroundColor,
+ showRemainingTime = playerDesignStyle == PlayerDesignStyle.V7,
+ centerContent =
+ if (playerDesignStyle == PlayerDesignStyle.V7 && currentFormat != null) {
+ {
+ val codec = currentFormat.mimeType.substringAfter("/").uppercase()
+ val label =
+ when {
+ codec.contains("FLAC") || codec.contains("ALAC") -> "Lossless"
+ codec.contains("OPUS") -> codec
+ codec.contains("AAC") -> codec
+ codec.contains("MP4A") -> "AAC"
+ codec.contains("VORBIS") -> "Vorbis"
+ else -> codec
+ }
+ Surface(
+ shape = RoundedCornerShape(4.dp),
+ color = textBackgroundColor.copy(alpha = 0.12f),
) {
- Icon(
- painter = painterResource(R.drawable.graphic_eq),
- contentDescription = null,
- modifier = Modifier.size(14.dp),
- tint = textBackgroundColor.copy(alpha = 0.8f),
- )
- Spacer(Modifier.width(4.dp))
- Text(
- text = label,
- style = MaterialTheme.typography.labelSmall,
- color = textBackgroundColor.copy(alpha = 0.8f),
- )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.graphic_eq),
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = textBackgroundColor.copy(alpha = 0.8f),
+ )
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelSmall,
+ color = textBackgroundColor.copy(alpha = 0.8f),
+ )
+ }
+ }
}
- }
- }
- } else {
- null
- },
- )
+ } else {
+ null
+ },
+ )
- Spacer(Modifier.height(12.dp))
+ Spacer(Modifier.height(12.dp))
+ }
+}
- PlayerPlaybackControls(
- playerDesignStyle = playerDesignStyle,
- playbackState = playbackState,
- isPlaying = isPlaying,
- isLoading = isLoading,
- repeatMode = repeatMode,
- canSkipPrevious = canSkipPrevious,
- canSkipNext = canSkipNext,
- textButtonColor = textButtonColor,
- iconButtonColor = iconButtonColor,
- textBackgroundColor = textBackgroundColor,
- icBackgroundColor = icBackgroundColor,
- playPauseRoundness = playPauseRoundness,
- playerConnection = playerConnection,
- currentSongLiked = currentSongLiked,
- )
+@Composable
+private fun PlayerControlsLayout(
+ landscape: Boolean,
+ playbackContent: @Composable () -> Unit,
+ content: @Composable () -> Unit,
+) {
+ if (landscape) {
+ LandscapePlayerControls(playbackContent = { playbackContent() }) {
+ content()
+ }
+ } else {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ content()
+ playbackContent()
+ }
+ }
}
@Composable
@@ -2482,10 +2580,10 @@ private fun V8LandscapeContent(
modifier: Modifier = Modifier,
) {
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
- val horizontalPadding = 36.dp
- val contentGap = 36.dp
+ val horizontalPadding = 16.dp
+ val contentGap = 24.dp
val artworkSize =
- (maxHeight - 48.dp)
+ (maxHeight - 16.dp)
.coerceAtMost((maxWidth - horizontalPadding * 2 - contentGap) * 0.44f)
.coerceAtLeast(0.dp)
@@ -2493,7 +2591,7 @@ private fun V8LandscapeContent(
modifier =
Modifier
.fillMaxSize()
- .padding(horizontal = horizontalPadding, vertical = 24.dp),
+ .padding(horizontal = horizontalPadding, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(contentGap),
) {
@@ -2506,13 +2604,21 @@ private fun V8LandscapeContent(
size = artworkSize,
)
- Column(
- modifier =
- Modifier
- .weight(1f)
- .fillMaxHeight()
- .heightIn(min = 320.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
+ LandscapePlayerControls(
+ modifier = Modifier.weight(1f).fillMaxHeight(),
+ playbackContent = {
+ V8TransportControls(
+ playbackState = playbackState,
+ isPlaying = isPlaying,
+ isLoading = isLoading,
+ canSkipPrevious = canSkipPrevious,
+ canSkipNext = canSkipNext,
+ foreground = foreground,
+ onPreviousClick = onPreviousClick,
+ onPlayPauseClick = onPlayPauseClick,
+ onNextClick = onNextClick,
+ )
+ },
) {
V8Header(
title = stringResource(R.string.now_playing),
@@ -2521,7 +2627,7 @@ private fun V8LandscapeContent(
secondaryForeground = secondaryForeground,
)
- Spacer(Modifier.height(22.dp))
+ Spacer(Modifier.height(4.dp))
V8MetadataActions(
title = mediaMetadata.title,
@@ -2535,8 +2641,18 @@ private fun V8LandscapeContent(
onArtistClick = onArtistClick,
)
- Spacer(Modifier.height(18.dp))
+ Spacer(Modifier.height(4.dp))
+ if (showVolumeBar) {
+ Spacer(Modifier.height(4.dp))
+
+ V8VolumeControls(
+ volume = volume,
+ foreground = foreground,
+ secondaryForeground = secondaryForeground,
+ onVolumeChange = onVolumeChange,
+ )
+ }
V8PlaybackProgress(
sliderPosition = sliderPosition,
position = position,
@@ -2547,30 +2663,8 @@ private fun V8LandscapeContent(
onSliderValueChangeFinished = onSliderValueChangeFinished,
)
- Spacer(Modifier.height(18.dp))
+ Spacer(Modifier.height(4.dp))
- V8TransportControls(
- playbackState = playbackState,
- isPlaying = isPlaying,
- isLoading = isLoading,
- canSkipPrevious = canSkipPrevious,
- canSkipNext = canSkipNext,
- foreground = foreground,
- onPreviousClick = onPreviousClick,
- onPlayPauseClick = onPlayPauseClick,
- onNextClick = onNextClick,
- )
-
- if (showVolumeBar) {
- Spacer(Modifier.height(18.dp))
-
- V8VolumeControls(
- volume = volume,
- foreground = foreground,
- secondaryForeground = secondaryForeground,
- onVolumeChange = onVolumeChange,
- )
- }
}
}
}
@@ -2851,7 +2945,7 @@ private fun V8QualityChip(
}
@Composable
-private fun V8TransportControls(
+internal fun V8TransportControls(
playbackState: Int,
isPlaying: Boolean,
isLoading: Boolean,
@@ -2863,6 +2957,7 @@ private fun V8TransportControls(
onNextClick: () -> Unit,
) {
val haptic = LocalHapticFeedback.current
+ val playerConnection = LocalPlayerConnection.current ?: return
Row(
modifier = Modifier.fillMaxWidth(),
@@ -2883,13 +2978,12 @@ private fun V8TransportControls(
)
Surface(
- onClick = {
+ shape = CircleShape,
+ color = Color.Transparent,
+ modifier = Modifier.size(72.dp).playPauseClickable(playerConnection.player) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onPlayPauseClick()
},
- shape = CircleShape,
- color = Color.Transparent,
- modifier = Modifier.size(72.dp),
) {
Box(
modifier = Modifier.fillMaxSize(),
@@ -3030,11 +3124,18 @@ private fun V8FlatSlider(
thumbColor = Color.Transparent,
inactiveTrackColor = inactiveColor,
)
+ val sliderState =
+ rememberControlledSliderState(
+ value = value.coerceIn(safeRange),
+ valueRange = safeRange,
+ )
Slider(
- value = value.coerceIn(safeRange),
- valueRange = safeRange,
- onValueChange = onValueChange,
+ state = sliderState,
+ onValueChange = { updated ->
+ sliderState.value = updated
+ onValueChange(updated)
+ },
onValueChangeFinished = onValueChangeFinished,
enabled = enabled,
colors = colors,
@@ -3078,6 +3179,7 @@ fun V9PlayerContent(
modifier: Modifier = Modifier,
landscape: Boolean = false,
gradientColors: List = emptyList(),
+ onQueueLongClick: () -> Unit = {},
) {
val baseArtworkUrl = mediaMetadata.thumbnailUrl?.highRes()
val thumbnailSwapState =
@@ -3153,6 +3255,7 @@ fun V9PlayerContent(
iconButtonColor = iconButtonColor,
onCollapseClick = onCollapseClick,
onQueueClick = onQueueClick,
+ onQueueLongClick = onQueueLongClick,
onLyricsClick = onLyricsClick,
onTitleClick = onTitleClick,
onArtistClick = onArtistClick,
@@ -3190,6 +3293,7 @@ fun V9PlayerContent(
iconButtonColor = iconButtonColor,
onCollapseClick = onCollapseClick,
onQueueClick = onQueueClick,
+ onQueueLongClick = onQueueLongClick,
onLyricsClick = onLyricsClick,
onTitleClick = onTitleClick,
onArtistClick = onArtistClick,
@@ -3238,6 +3342,7 @@ private fun V9PortraitContent(
onArtistClick: (artistId: String) -> Unit,
liked: Boolean,
onToggleLike: () -> Unit,
+ onQueueLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
@@ -3269,6 +3374,7 @@ private fun V9PortraitContent(
onCollapseClick = onCollapseClick,
onLyricsClick = onLyricsClick,
onQueueClick = onQueueClick,
+ onQueueLongClick = onQueueLongClick,
)
Spacer(Modifier.height(headerGap))
@@ -3463,21 +3569,22 @@ private fun V9LandscapeContent(
onShuffleClick: () -> Unit,
onRepeatClick: () -> Unit,
onMenuClick: () -> Unit,
+ onQueueLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val artworkSize =
- (maxHeight * 0.74f)
+ (maxHeight - 16.dp)
.coerceAtMost(maxWidth * 0.4f)
- .coerceAtLeast(236.dp)
+ .coerceAtLeast(0.dp)
Row(
modifier =
Modifier
.fillMaxSize()
- .padding(horizontal = 28.dp, vertical = 16.dp),
+ .padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(26.dp),
+ horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
V9Artwork(
artworkUrl = artworkUrl,
@@ -3489,12 +3596,28 @@ private fun V9LandscapeContent(
placeholderColor = textBackgroundColor.copy(alpha = 0.08f),
)
- Column(
- modifier =
- Modifier
- .weight(1f)
- .fillMaxHeight(),
- horizontalAlignment = Alignment.CenterHorizontally,
+ LandscapePlayerControls(
+ modifier = Modifier.weight(1f).fillMaxHeight(),
+ playbackContent = {
+ val motionScheme = remember { MotionScheme.standard() }
+ val controlSpatialSpec = remember { motionScheme.fastSpatialSpec() }
+ V9AnimatedPlaybackControls(
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ isPlayingProvider = { isPlaying },
+ onPrevious = onPreviousClick,
+ onPlayPause = onPlayPauseClick,
+ onNext = onNextClick,
+ height = 64.dp,
+ pressAnimationSpec = controlSpatialSpec,
+ releaseDelay = 220L,
+ colorOtherButtons = textBackgroundColor.copy(alpha = 0.08f),
+ colorPlayPause = textBackgroundColor,
+ tintPlayPauseIcon = if ((textBackgroundColor.red + textBackgroundColor.green + textBackgroundColor.blue) > 1.5f) Color.Black else Color.White,
+ tintOtherIcons = textBackgroundColor,
+ )
+
+ Spacer(Modifier.height(4.dp))
+ },
) {
V9Header(
textColor = textBackgroundColor,
@@ -3503,9 +3626,10 @@ private fun V9LandscapeContent(
onCollapseClick = onCollapseClick,
onLyricsClick = onLyricsClick,
onQueueClick = onQueueClick,
+ onQueueLongClick = onQueueLongClick,
)
- Spacer(Modifier.height(14.dp))
+ Spacer(Modifier.height(4.dp))
V9Metadata(
title = title,
@@ -3516,40 +3640,7 @@ private fun V9LandscapeContent(
onArtistClick = onArtistClick,
)
- Spacer(Modifier.height(12.dp))
-
- V9PlaybackProgress(
- sliderPosition = sliderPosition,
- position = position,
- duration = duration,
- isPlaying = isPlaying,
- activeColor = textBackgroundColor,
- inactiveColor = textBackgroundColor.copy(alpha = 0.24f),
- textColor = textBackgroundColor,
- onSliderValueChange = onSliderValueChange,
- onSliderValueChangeFinished = onSliderValueChangeFinished,
- )
-
- Spacer(Modifier.height(12.dp))
-
- val motionScheme = remember { MotionScheme.standard() }
- val controlSpatialSpec = remember { motionScheme.fastSpatialSpec() }
- V9AnimatedPlaybackControls(
- modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
- isPlayingProvider = { isPlaying },
- onPrevious = onPreviousClick,
- onPlayPause = onPlayPauseClick,
- onNext = onNextClick,
- height = 64.dp,
- pressAnimationSpec = controlSpatialSpec,
- releaseDelay = 220L,
- colorOtherButtons = textBackgroundColor.copy(alpha = 0.08f),
- colorPlayPause = textBackgroundColor,
- tintPlayPauseIcon = if ((textBackgroundColor.red + textBackgroundColor.green + textBackgroundColor.blue) > 1.5f) Color.Black else Color.White,
- tintOtherIcons = textBackgroundColor,
- )
-
- Spacer(Modifier.height(10.dp))
+ Spacer(Modifier.height(4.dp))
V9BottomToggleRow(
shuffleModeEnabled = shuffleModeEnabled,
@@ -3565,6 +3656,20 @@ private fun V9LandscapeContent(
.fillMaxWidth()
.height(54.dp),
)
+ V9PlaybackProgress(
+ sliderPosition = sliderPosition,
+ position = position,
+ duration = duration,
+ isPlaying = isPlaying,
+ activeColor = textBackgroundColor,
+ inactiveColor = textBackgroundColor.copy(alpha = 0.24f),
+ textColor = textBackgroundColor,
+ onSliderValueChange = onSliderValueChange,
+ onSliderValueChangeFinished = onSliderValueChangeFinished,
+ )
+
+ Spacer(Modifier.height(4.dp))
+
}
}
}
@@ -3578,6 +3683,7 @@ private fun V9Header(
onCollapseClick: () -> Unit,
onLyricsClick: () -> Unit,
onQueueClick: () -> Unit,
+ onQueueLongClick: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -3625,6 +3731,7 @@ private fun V9Header(
iconColor = iconColor,
shape = RoundedCornerShape(22.dp),
onClick = onQueueClick,
+ onLongClick = onQueueLongClick,
)
}
}
@@ -3638,12 +3745,18 @@ private fun V9HeaderButton(
iconColor: Color,
shape: androidx.compose.ui.graphics.Shape,
onClick: () -> Unit,
+ onLongClick: (() -> Unit)? = null,
) {
Surface(
- onClick = onClick,
shape = shape,
color = containerColor,
- modifier = Modifier.size(56.dp),
+ modifier =
+ Modifier
+ .size(56.dp)
+ .combinedClickable(
+ onClick = onClick,
+ onLongClick = onLongClick,
+ ),
) {
Box(
modifier = Modifier.fillMaxSize(),
@@ -4452,6 +4565,7 @@ fun V10PlayerContent(
onAddToPlaylistClick: () -> Unit,
modifier: Modifier = Modifier,
landscape: Boolean = false,
+ onQueueLongClick: () -> Unit = {},
) {
val baseArtworkUrl = mediaMetadata.thumbnailUrl?.highRes()
val thumbnailSwapState =
@@ -4485,6 +4599,126 @@ fun V10PlayerContent(
val accent = textBackgroundColor
val field = textButtonColor
+ if (landscape) {
+ LandscapePlayerLayout(
+ modifier = modifier,
+ artwork = {
+ EditorialDieCutArt(
+ artworkUrl = artworkUrl,
+ mediaMetadataId = mediaMetadata.id,
+ isPlaying = isPlaying,
+ onTap = onPlayPauseClick,
+ accent = accent,
+ field = field,
+ canSkipPrevious = canSkipPrevious,
+ canSkipNext = canSkipNext,
+ onSkipPrevious = playerConnection::seekToPrevious,
+ onSkipNext = playerConnection::seekToNext,
+ )
+ },
+ controls = {
+ LandscapePlayerControls(
+ playbackContent = {
+ V8TransportControls(
+ playbackState = playbackState,
+ isPlaying = isPlaying,
+ isLoading = isLoading,
+ canSkipPrevious = canSkipPrevious,
+ canSkipNext = canSkipNext,
+ foreground = accent,
+ onPreviousClick = playerConnection::seekToPrevious,
+ onPlayPauseClick = onPlayPauseClick,
+ onNextClick = playerConnection::seekToNext,
+ )
+ },
+ ) {
+ V9Header(
+ textColor = accent,
+ containerColor = accent.copy(alpha = 0.16f),
+ iconColor = accent,
+ onCollapseClick = onCollapseClick,
+ onLyricsClick = onLyricsClick,
+ onQueueClick = onQueueClick,
+ )
+ PlayerTitleText(
+ title = mediaMetadata.title,
+ explicit = mediaMetadata.explicit,
+ color = accent,
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.headlineSmall.copy(
+ fontFamily = FontFamily.Serif,
+ fontStyle = FontStyle.Italic,
+ fontWeight = FontWeight.Bold,
+ ),
+ modifier = Modifier.fillMaxWidth().clickable(onClick = onTitleClick),
+ )
+ ClickableArtists(
+ artists = mediaMetadata.artists,
+ onArtistClick = onArtistClick,
+ style = MaterialTheme.typography.labelLarge,
+ color = accent.copy(alpha = 0.8f),
+ modifier = Modifier.fillMaxWidth().basicMarquee(),
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ V10ToggleButton(
+ checked = liked,
+ onClick = onToggleLike,
+ accent = accent,
+ field = field,
+ iconResId = if (liked) R.drawable.favorite else R.drawable.favorite_border,
+ contentDescription = stringResource(if (liked) R.string.action_remove_like else R.string.action_like),
+ )
+ V10ToggleButton(
+ checked = false,
+ onClick = onAddToPlaylistClick,
+ accent = accent,
+ field = field,
+ iconResId = R.drawable.library_add,
+ contentDescription = stringResource(R.string.add_to_playlist),
+ )
+ IconButton(onClick = onSleepTimerClick) {
+ Icon(
+ painter = painterResource(R.drawable.bedtime),
+ contentDescription = stringResource(R.string.sleep_timer),
+ tint = accent,
+ )
+ }
+ if (sleepTimerEnabled) {
+ Text(
+ text = makeTimeString(sleepTimerTimeLeft.coerceAtLeast(0L)),
+ color = accent,
+ style = MaterialTheme.typography.labelLarge,
+ )
+ }
+ IconButton(onClick = onMenuClick) {
+ Icon(
+ painter = painterResource(R.drawable.more_vert),
+ contentDescription = stringResource(R.string.more_label),
+ tint = accent,
+ )
+ }
+ }
+ V9PlaybackProgress(
+ sliderPosition = sliderPosition,
+ position = position,
+ duration = duration,
+ isPlaying = isPlaying,
+ activeColor = accent,
+ inactiveColor = accent.copy(alpha = 0.24f),
+ textColor = accent,
+ onSliderValueChange = onSliderValueChange,
+ onSliderValueChangeFinished = onSliderValueChangeFinished,
+ )
+ }
+ },
+ )
+ return
+ }
+
// ========== MAIN LAYOUT (EditorialNowPlayingView) ==========
Column(modifier = modifier.fillMaxSize()) {
@@ -4839,7 +5073,17 @@ fun V10PlayerContent(
accent = accent,
field = field,
iconResId = R.drawable.library_add,
- contentDescription = "Add to playlist"
+ contentDescription = stringResource(R.string.add_to_playlist)
+ )
+
+ V10ToggleButton(
+ checked = false,
+ onClick = onQueueClick,
+ onLongClick = onQueueLongClick,
+ accent = accent,
+ field = field,
+ iconResId = R.drawable.queue_music,
+ contentDescription = stringResource(R.string.queue)
)
}
}
@@ -5032,6 +5276,7 @@ private fun V10ToggleButton(
field: Color,
iconResId: Int,
contentDescription: String?,
+ onLongClick: (() -> Unit)? = null,
) {
val containerColor by animateColorAsState(
targetValue = if (checked) accent else accent.copy(alpha = 0.08f),
@@ -5044,7 +5289,10 @@ private fun V10ToggleButton(
.size(48.dp)
.clip(CircleShape)
.background(containerColor)
- .clickable(onClick = onClick),
+ .combinedClickable(
+ onClick = onClick,
+ onLongClick = onLongClick,
+ ),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(LocalContentColor provides contentColor) {
@@ -5106,4 +5354,3 @@ internal fun formatEditorialTime(durationMs: Long): String {
val seconds = totalSeconds % 60
return String.format("%d:%02d", minutes, seconds)
}
-
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/PlayerTitleActions.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/PlayerTitleActions.kt
index 0166d56b1a..6e8938e3e6 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/PlayerTitleActions.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/PlayerTitleActions.kt
@@ -18,6 +18,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavController
import moe.rukamori.archivetune.models.MediaMetadata
import moe.rukamori.archivetune.ui.component.BottomSheetState
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
/**
* Centralized *behavior* for the title/artist block shared by every player design style.
@@ -65,13 +67,13 @@ fun rememberPlayerTitleActions(
// without animation and left the anchor stale, so tapping the title from the
// home screen failed to collapse the player reliably.)
state.collapseSoft()
- navController.navigate("album/${album.id}")
+ navController.navigateToAlbum(album.id)
}
},
onArtistClick = { artistId ->
if (artistId.isNotBlank()) {
state.collapseSoft()
- navController.navigate("artist/$artistId")
+ navController.navigateToArtist(artistId)
}
},
onCopyTitle = {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/Queue.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/Queue.kt
index fd0063f5f8..7b52ef33bc 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/Queue.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/Queue.kt
@@ -77,7 +77,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
-import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
@@ -89,6 +88,7 @@ import androidx.compose.ui.unit.dp
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.Timeline
+import androidx.media3.exoplayer.offline.Download
import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder
import androidx.navigation.NavController
import kotlinx.coroutines.Dispatchers
@@ -98,6 +98,7 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import moe.rukamori.archivetune.LocalDatabase
+import moe.rukamori.archivetune.LocalDownloadUtil
import moe.rukamori.archivetune.LocalPlayerConnection
import moe.rukamori.archivetune.R
import moe.rukamori.archivetune.constants.AutoLoadMoreKey
@@ -121,7 +122,10 @@ import moe.rukamori.archivetune.ui.component.MediaMetadataListItem
import moe.rukamori.archivetune.ui.component.TextFieldDialog
import moe.rukamori.archivetune.ui.menu.AddToPlaylistDialog
import moe.rukamori.archivetune.ui.menu.PlayerMenu
+import moe.rukamori.archivetune.ui.utils.HeaderDownloadItem
import moe.rukamori.archivetune.ui.utils.ShowMediaInfo
+import moe.rukamori.archivetune.ui.utils.sendAddMissingDownloads
+import moe.rukamori.archivetune.utils.isLocalMediaId
import moe.rukamori.archivetune.utils.oem.SystemMediaControlResolver
import moe.rukamori.archivetune.utils.rememberEnumPreference
import moe.rukamori.archivetune.utils.rememberPreference
@@ -143,6 +147,7 @@ fun Queue(
textButtonColor: Color,
iconButtonColor: Color,
onShowLyrics: () -> Unit = {},
+ onQueueLongClick: () -> Unit = {},
pureBlack: Boolean,
) {
val (enableHapticFeedback) = rememberPreference(EnableHapticFeedbackKey, true)
@@ -163,6 +168,7 @@ fun Queue(
val currentFormat by playerConnection.currentFormat.collectAsState(initial = null)
val queueTitle by playerConnection.queueTitle.collectAsState()
+ val downloads by LocalDownloadUtil.current.downloads.collectAsStateWithLifecycle()
val selectedSongs = remember { mutableStateListOf() }
val selectedItems = remember { mutableStateListOf() }
@@ -302,71 +308,46 @@ fun Queue(
queueWindows.getOrNull(currentWindowIndex)
}
- val onRemoveWithUndo: (Timeline.Window) -> Unit = { window ->
- val index = window.firstPeriodIndex
- playerConnection.player.removeMediaItem(index)
- dismissJob?.cancel()
- dismissJob =
- coroutineScope.launch {
- val snackbarResult =
- snackbarHostState.showSnackbar(
- message =
- context.getString(
- R.string.removed_song_from_queue,
- window.mediaItem.metadata?.title,
- ),
- actionLabel = context.getString(R.string.undo),
- duration = SnackbarDuration.Short,
- )
- if (snackbarResult == SnackbarResult.ActionPerformed) {
- playerConnection.player.addMediaItem(window.mediaItem)
- playerConnection.player.moveMediaItem(
- playerConnection.player.mediaItemCount - 1,
- index,
- )
- }
- }
- }
-
- val onRemoveMultipleWithUndo: (List) -> Unit = { windows ->
- if (windows.isNotEmpty()) {
- val sortedWindows = windows.sortedBy { it.firstPeriodIndex }
- var i = 0
- sortedWindows.forEach { window ->
- playerConnection.player.removeMediaItem(window.firstPeriodIndex - i++)
- }
- dismissJob?.cancel()
- dismissJob =
- coroutineScope.launch {
- val snackbarResult =
- snackbarHostState.showSnackbar(
- message =
- if (windows.size == 1) {
- context.getString(
- R.string.removed_song_from_queue,
- windows
- .first()
- .mediaItem.metadata
- ?.title,
- )
- } else {
- context.getString(R.string.removed_n_songs_from_queue, windows.size)
- },
- actionLabel = context.getString(R.string.undo),
- duration = SnackbarDuration.Short,
- )
- if (snackbarResult == SnackbarResult.ActionPerformed) {
- sortedWindows.forEach { window ->
- playerConnection.player.addMediaItem(window.mediaItem)
- playerConnection.player.moveMediaItem(
- playerConnection.player.mediaItemCount - 1,
- window.firstPeriodIndex,
- )
+ val onRemoveMultipleWithUndo: (List) -> Unit =
+ remember(playerConnection, context, coroutineScope, snackbarHostState) {
+ remove@{ windows ->
+ val removal = playerConnection.removeQueueItems(windows) ?: return@remove
+ val removedItems = removal.removedItems
+ dismissJob?.cancel()
+ dismissJob =
+ coroutineScope.launch {
+ try {
+ val snackbarResult =
+ snackbarHostState.showSnackbar(
+ message =
+ if (removedItems.size == 1) {
+ context.getString(
+ R.string.removed_song_from_queue,
+ removedItems.first().metadata?.title,
+ )
+ } else {
+ context.getString(R.string.removed_n_songs_from_queue, removedItems.size)
+ },
+ actionLabel = context.getString(R.string.undo),
+ duration = SnackbarDuration.Short,
+ )
+ if (
+ snackbarResult == SnackbarResult.ActionPerformed &&
+ !playerConnection.restoreQueueItems(removal)
+ ) {
+ snackbarHostState.showSnackbar(context.getString(R.string.queue_undo_unavailable))
+ }
+ } finally {
+ playerConnection.discardQueueRemoval(removal)
}
}
- }
+ }
+ }
+
+ val onRemoveWithUndo: (Timeline.Window) -> Unit =
+ remember(onRemoveMultipleWithUndo) {
+ { window -> onRemoveMultipleWithUndo(listOf(window)) }
}
- }
var showSleepTimerDialog by remember { mutableStateOf(false) }
var sleepTimerValue by remember { mutableStateOf(30f) }
@@ -398,14 +379,19 @@ fun Queue(
}
}
}
+ LaunchedEffect(state.isExpanded, state.isExpandedOrExpanding, currentWindowIndex, queueWindows.size) {
+ QueueOpenDiagnostics.recordCurrent(
+ "Queue composable state: expanded=${state.isExpanded}, " +
+ "targetExpanded=${state.isExpandedOrExpanding}, currentIndex=$currentWindowIndex, " +
+ "itemCount=${queueWindows.size}.",
+ )
+ }
var scrollToCurrentRequested by remember { mutableStateOf(true) }
val openQueue =
- remember(playerBottomSheetState, state) {
+ remember(playerBottomSheetState, state, coroutineScope) {
{
scrollToCurrentRequested = true
- if (!playerBottomSheetState.isExpandedOrExpanding) {
- playerBottomSheetState.expandSoft()
- }
+ playerBottomSheetState.expandSoft()
state.expandSoft()
}
}
@@ -415,6 +401,7 @@ fun Queue(
backgroundColor = Color.Unspecified,
modifier = modifier,
onCollapsedContentClick = openQueue,
+ onCollapsedContentLongClick = onQueueLongClick,
collapsedContent = {
when (playerDesignStyle) {
PlayerDesignStyle.V2 -> {
@@ -444,6 +431,14 @@ fun Queue(
mediaMetadata = mediaMetadata,
navController = navController,
playerBottomSheetState = playerBottomSheetState,
+ sleepTimerEnabled = sleepTimerEnabled,
+ onSleepTimerClick = {
+ if (sleepTimerEnabled) {
+ playerConnection.service.sleepTimer.clear()
+ } else {
+ showSleepTimerDialog = true
+ }
+ },
onShowDetailsDialog = {
mediaMetadata?.id?.let {
bottomSheetPageState.show {
@@ -480,6 +475,14 @@ fun Queue(
mediaMetadata = mediaMetadata,
navController = navController,
playerBottomSheetState = playerBottomSheetState,
+ sleepTimerEnabled = sleepTimerEnabled,
+ onSleepTimerClick = {
+ if (sleepTimerEnabled) {
+ playerConnection.service.sleepTimer.clear()
+ } else {
+ showSleepTimerDialog = true
+ }
+ },
onShowDetailsDialog = {
mediaMetadata?.id?.let {
bottomSheetPageState.show {
@@ -516,6 +519,14 @@ fun Queue(
mediaMetadata = mediaMetadata,
navController = navController,
playerBottomSheetState = playerBottomSheetState,
+ sleepTimerEnabled = sleepTimerEnabled,
+ onSleepTimerClick = {
+ if (sleepTimerEnabled) {
+ playerConnection.service.sleepTimer.clear()
+ } else {
+ showSleepTimerDialog = true
+ }
+ },
onShowDetailsDialog = {
mediaMetadata?.id?.let {
bottomSheetPageState.show {
@@ -613,6 +624,14 @@ fun Queue(
mediaMetadata = mediaMetadata,
navController = navController,
playerBottomSheetState = playerBottomSheetState,
+ sleepTimerEnabled = sleepTimerEnabled,
+ onSleepTimerClick = {
+ if (sleepTimerEnabled) {
+ playerConnection.service.sleepTimer.clear()
+ } else {
+ showSleepTimerDialog = true
+ }
+ },
onShowDetailsDialog = {
mediaMetadata?.id?.let {
bottomSheetPageState.show {
@@ -715,6 +734,28 @@ fun Queue(
},
) {
val queueWindows by playerConnection.queueWindows.collectAsState()
+ val downloadQueueItems =
+ remember(queueWindows) {
+ queueWindows.mapNotNull { window ->
+ window.mediaItem.metadata?.let { metadata ->
+ HeaderDownloadItem(id = metadata.id, title = metadata.title)
+ }
+ }.distinctBy(HeaderDownloadItem::id)
+ }
+ val showDownloadQueue =
+ downloadQueueItems.any { item ->
+ val state = downloads[item.id]?.state
+ !item.id.isLocalMediaId() &&
+ when (state) {
+ Download.STATE_COMPLETED,
+ Download.STATE_QUEUED,
+ Download.STATE_DOWNLOADING,
+ Download.STATE_RESTARTING,
+ -> false
+
+ else -> true
+ }
+ }
val mutableQueueWindows =
remember {
mutableStateListOf().apply {
@@ -881,6 +922,10 @@ fun Queue(
infiniteQueueLoading = infiniteQueueLoading,
backgroundColor = backgroundColor,
onBackgroundColor = onBackgroundColor,
+ showDownloadQueue = showDownloadQueue,
+ onDownloadQueueClick = {
+ sendAddMissingDownloads(context, downloadQueueItems, downloads)
+ },
onToggleLike = {
playerConnection.service.toggleLike()
},
@@ -891,6 +936,14 @@ fun Queue(
navController = navController,
playerBottomSheetState = playerBottomSheetState,
isQueueTrigger = true,
+ sleepTimerEnabled = sleepTimerEnabled,
+ onSleepTimerClick = {
+ if (sleepTimerEnabled) {
+ playerConnection.service.sleepTimer.clear()
+ } else {
+ showSleepTimerDialog = true
+ }
+ },
onRemoveFromQueue = {
currentWindow?.let { onRemoveWithUndo(it) }
},
@@ -961,8 +1014,7 @@ fun Queue(
).asPaddingValues(),
modifier =
Modifier
- .weight(1f)
- .nestedScroll(state.preUpPostDownNestedScrollConnection),
+ .weight(1f),
) {
item(
key = "queue_selection_spacer",
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/QueueComponents.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/QueueComponents.kt
index f1055ceb6d..79ac768bf7 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/QueueComponents.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/QueueComponents.kt
@@ -36,6 +36,7 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ButtonGroupDefaults
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -109,6 +110,8 @@ fun CurrentSongHeader(
infiniteQueueLoading: Boolean,
backgroundColor: Color,
onBackgroundColor: Color,
+ showDownloadQueue: Boolean,
+ onDownloadQueueClick: () -> Unit,
onToggleLike: () -> Unit,
onMenuClick: () -> Unit,
onClearQueueClick: () -> Unit,
@@ -289,6 +292,27 @@ fun CurrentSongHeader(
)
}
+ if (showDownloadQueue) {
+ Spacer(modifier = Modifier.height(10.dp))
+ FilledTonalButton(
+ onClick = onDownloadQueueClick,
+ modifier = Modifier.fillMaxWidth(),
+ colors =
+ ButtonDefaults.filledTonalButtonColors(
+ containerColor = onBackgroundColor.copy(alpha = 0.12f),
+ contentColor = onBackgroundColor,
+ ),
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.download),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(stringResource(R.string.download_all_queue_items))
+ }
+ }
+
Spacer(modifier = Modifier.height(14.dp))
Row(
@@ -297,17 +321,17 @@ fun CurrentSongHeader(
verticalAlignment = Alignment.CenterVertically,
) {
val uncheckedColors =
- ToggleButtonDefaults.toggleButtonColors(
+ ToggleButtonDefaults.colors(
containerColor = onBackgroundColor.copy(alpha = 0.12f),
contentColor = onBackgroundColor,
)
val checkedColors =
- ToggleButtonDefaults.toggleButtonColors(
+ ToggleButtonDefaults.colors(
checkedContainerColor = onBackgroundColor.copy(alpha = 0.22f),
checkedContentColor = onBackgroundColor,
)
val infiniteCheckedColors =
- ToggleButtonDefaults.toggleButtonColors(
+ ToggleButtonDefaults.colors(
checkedContainerColor = MaterialTheme.colorScheme.primary,
checkedContentColor = MaterialTheme.colorScheme.onPrimary,
containerColor = onBackgroundColor.copy(alpha = 0.12f),
@@ -1490,12 +1514,12 @@ fun QueueCollapsedContentV9(
val buttonContainerColor = textBackgroundColor.copy(alpha = 0.08f)
val selectedButtonContainerColor = textBackgroundColor.copy(alpha = 0.18f)
val uncheckedColors =
- ToggleButtonDefaults.toggleButtonColors(
+ ToggleButtonDefaults.colors(
containerColor = buttonContainerColor,
contentColor = textBackgroundColor.copy(alpha = 0.76f),
)
val checkedColors =
- ToggleButtonDefaults.toggleButtonColors(
+ ToggleButtonDefaults.colors(
checkedContainerColor = selectedButtonContainerColor,
checkedContentColor = textBackgroundColor,
containerColor = buttonContainerColor,
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/QueueOpenDiagnostics.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/QueueOpenDiagnostics.kt
new file mode 100644
index 0000000000..e4033f6008
--- /dev/null
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/QueueOpenDiagnostics.kt
@@ -0,0 +1,185 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.ui.player
+
+import android.util.Log
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+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.font.FontFamily
+import androidx.compose.ui.unit.dp
+import android.widget.Toast
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import moe.rukamori.archivetune.R
+import moe.rukamori.archivetune.utils.GlobalLog
+import moe.rukamori.archivetune.utils.LogEntry
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+internal data class QueueOpenAttempt(
+ val id: Long,
+ val startedAt: Long,
+ val steps: List,
+)
+
+internal object QueueOpenDiagnostics {
+ private val lock = Any()
+ private val _attempt = MutableStateFlow(null)
+ val attempt = _attempt.asStateFlow()
+ private val _dialogVisible = MutableStateFlow(false)
+ val dialogVisible = _dialogVisible.asStateFlow()
+ private var nextId = 0L
+
+ fun showDialog(): Unit {
+ _dialogVisible.value = true
+ }
+
+ fun dismissDialog(): Unit {
+ _dialogVisible.value = false
+ }
+
+ fun begin(context: String): Long =
+ synchronized(lock) {
+ val id = ++nextId
+ val startedAt = System.currentTimeMillis()
+ _attempt.value =
+ QueueOpenAttempt(
+ id = id,
+ startedAt = startedAt,
+ steps = listOf("${formatTime(startedAt)} Queue button tapped ($context)"),
+ )
+ id
+ }
+
+ fun record(
+ attemptId: Long,
+ message: String,
+ ) {
+ synchronized(lock) {
+ val current = _attempt.value?.takeIf { it.id == attemptId } ?: return
+ _attempt.value =
+ current.copy(
+ steps = current.steps + "${formatTime(System.currentTimeMillis())} $message",
+ )
+ }
+ }
+
+ fun recordCurrent(message: String) {
+ val current = _attempt.value ?: return
+ record(current.id, message)
+ }
+
+ private fun formatTime(time: Long): String =
+ synchronized(timeFormat) {
+ timeFormat.format(Date(time))
+ }
+
+ private val timeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.ROOT)
+}
+
+@Composable
+internal fun QueueOpenDiagnosticsDialog(
+ onDismiss: () -> Unit,
+) {
+ val attempt by QueueOpenDiagnostics.attempt.collectAsState()
+ val appLogs by GlobalLog.logs.collectAsState()
+ val clipboard = LocalClipboardManager.current
+ val context = LocalContext.current
+ val report = buildQueueOpenReport(attempt, appLogs)
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.queue_open_diagnostics_title)) },
+ text = {
+ SelectionContainer {
+ Text(
+ text = report,
+ modifier =
+ Modifier
+ .heightIn(max = 480.dp)
+ .verticalScroll(rememberScrollState())
+ .padding(end = 8.dp),
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ clipboard.setText(AnnotatedString(report))
+ Toast.makeText(
+ context,
+ context.getString(R.string.copied),
+ Toast.LENGTH_SHORT,
+ ).show()
+ },
+ ) {
+ Text(stringResource(R.string.copy))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.close))
+ }
+ },
+ )
+}
+
+private fun buildQueueOpenReport(
+ attempt: QueueOpenAttempt?,
+ appLogs: List,
+): String =
+ buildString {
+ appendLine("ArchiveTune queue-open diagnostics")
+ if (attempt == null) {
+ appendLine("No queue-open attempt has been recorded in this player session.")
+ appendLine("Tap Queue once, then long-press Queue to capture an attempt.")
+ return@buildString
+ }
+
+ appendLine("Attempt: ${attempt.id}")
+ appendLine("Started: ${attempt.startedAt}")
+ appendLine()
+ appendLine("OPEN SEQUENCE")
+ attempt.steps.forEachIndexed { index, step ->
+ appendLine("${index + 1}. $step")
+ }
+
+ val errors =
+ appLogs.filter { log ->
+ log.time >= attempt.startedAt && log.level >= Log.WARN
+ }
+ appendLine()
+ appendLine("APP WARNINGS / ERRORS SINCE ATTEMPT")
+ if (errors.isEmpty()) {
+ appendLine("No warning/error Timber logs were captured during this attempt.")
+ } else {
+ errors.forEach { entry ->
+ appendLine(GlobalLog.format(entry))
+ appendLine()
+ }
+ }
+ appendLine("Note: warnings/errors are captured from the app's Timber log stream.")
+ }
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/Thumbnail.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/Thumbnail.kt
index 609c413bc0..97ec4d340d 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/Thumbnail.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/Thumbnail.kt
@@ -10,6 +10,8 @@ package moe.rukamori.archivetune.ui.player
import android.graphics.Bitmap
import android.os.Build
import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.togetherWith
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
@@ -20,6 +22,7 @@ import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
+import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
@@ -40,6 +43,7 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
+import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -51,6 +55,8 @@ import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -60,6 +66,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
@@ -76,6 +83,7 @@ import androidx.compose.ui.util.fastForEach
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
+import coil3.compose.AsyncImagePainter
import androidx.media3.ui.AspectRatioFrameLayout
import coil3.compose.AsyncImage
import coil3.imageLoader
@@ -86,6 +94,7 @@ import coil3.request.allowHardware
import coil3.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.withContext
import moe.rukamori.archivetune.LocalPlayerConnection
import moe.rukamori.archivetune.R
@@ -125,7 +134,8 @@ fun Thumbnail(
sliderPositionProvider: () -> Long?,
canvas: CanvasVideo?,
modifier: Modifier = Modifier,
- isPlayerExpanded: Boolean = true, // Add parameter to control swipe based on player state
+ isPlayerExpanded: Boolean = true,
+ applyStatusBarPadding: Boolean = true,
) {
val playerConnection = LocalPlayerConnection.current ?: return
val context = LocalContext.current
@@ -183,48 +193,6 @@ fun Thumbnail(
val timeline = playerConnection.player.currentTimeline
val currentIndex = playerConnection.player.currentMediaItemIndex
val shuffleModeEnabled = playerConnection.player.shuffleModeEnabled
- val previousWindowIndex =
- if (swipeThumbnail && !timeline.isEmpty) {
- timeline.getPreviousWindowIndex(
- currentIndex,
- Player.REPEAT_MODE_OFF,
- shuffleModeEnabled,
- )
- } else {
- C.INDEX_UNSET
- }
- val previousMediaMetadata =
- if (previousWindowIndex != C.INDEX_UNSET) {
- try {
- playerConnection.player.getMediaItemAt(previousWindowIndex)
- } catch (e: Exception) {
- null
- }
- } else {
- null
- }
-
- val nextWindowIndex =
- if (swipeThumbnail && !timeline.isEmpty) {
- timeline.getNextWindowIndex(
- currentIndex,
- Player.REPEAT_MODE_OFF,
- shuffleModeEnabled,
- )
- } else {
- C.INDEX_UNSET
- }
- val nextMediaMetadata =
- if (nextWindowIndex != C.INDEX_UNSET) {
- try {
- playerConnection.player.getMediaItemAt(nextWindowIndex)
- } catch (e: Exception) {
- null
- }
- } else {
- null
- }
-
val currentMediaItem =
remember(mediaMetadata) {
// Fallback to player's current item if mediaMetadata is null,
@@ -243,18 +211,76 @@ fun Thumbnail(
}
val thumbnailPages =
- buildList {
- if (previousMediaMetadata != null) {
- add(ThumbnailPage(slotKey = "previous", windowIndex = previousWindowIndex, mediaItem = previousMediaMetadata))
- }
- if (currentMediaItem != null) {
- add(ThumbnailPage(slotKey = "current", windowIndex = currentIndex, mediaItem = currentMediaItem))
- }
- if (nextMediaMetadata != null) {
- add(ThumbnailPage(slotKey = "next", windowIndex = nextWindowIndex, mediaItem = nextMediaMetadata))
+ remember(timeline, currentIndex, shuffleModeEnabled, currentMediaItem, swipeThumbnail) {
+ if (!swipeThumbnail ||
+ timeline.isEmpty ||
+ currentIndex !in 0 until timeline.windowCount
+ ) {
+ currentMediaItem?.let {
+ listOf(ThumbnailPage(slotKey = "current", windowIndex = currentIndex, mediaItem = it))
+ }.orEmpty()
+ } else {
+ val visitedWindowIndexes = mutableSetOf(currentIndex)
+ val previousWindowIndexes = mutableListOf()
+ var previousIndex =
+ timeline.getPreviousWindowIndex(
+ currentIndex,
+ Player.REPEAT_MODE_OFF,
+ shuffleModeEnabled,
+ )
+ while (previousIndex != C.INDEX_UNSET && visitedWindowIndexes.add(previousIndex)) {
+ previousWindowIndexes += previousIndex
+ previousIndex =
+ timeline.getPreviousWindowIndex(
+ previousIndex,
+ Player.REPEAT_MODE_OFF,
+ shuffleModeEnabled,
+ )
+ }
+
+ val orderedWindowIndexes =
+ buildList {
+ previousWindowIndexes.asReversed().forEach(::add)
+ add(currentIndex)
+ var nextIndex =
+ timeline.getNextWindowIndex(
+ currentIndex,
+ Player.REPEAT_MODE_OFF,
+ shuffleModeEnabled,
+ )
+ while (nextIndex != C.INDEX_UNSET && visitedWindowIndexes.add(nextIndex)) {
+ add(nextIndex)
+ nextIndex =
+ timeline.getNextWindowIndex(
+ nextIndex,
+ Player.REPEAT_MODE_OFF,
+ shuffleModeEnabled,
+ )
+ }
+ }
+
+ orderedWindowIndexes.mapNotNull { windowIndex ->
+ val item =
+ if (windowIndex == currentIndex) {
+ currentMediaItem
+ } else {
+ try {
+ playerConnection.player.getMediaItemAt(windowIndex)
+ } catch (_: Exception) {
+ null
+ }
+ }
+ item?.let {
+ ThumbnailPage(
+ slotKey = if (windowIndex == currentIndex) "current" else "queued",
+ windowIndex = windowIndex,
+ mediaItem = it,
+ )
+ }
+ }
}
}
- val currentMediaIndex = thumbnailPages.indexOfFirst { it.slotKey == "current" }
+ val currentMediaIndex = thumbnailPages.indexOfFirst { it.windowIndex == currentIndex }
// OuterTune Snap behavior
val horizontalLazyGridItemWidthFactor = 1f
@@ -271,31 +297,48 @@ fun Thumbnail(
// Current item tracking
val currentItem by remember { derivedStateOf { thumbnailLazyGridState.firstVisibleItemIndex } }
- val itemScrollOffset by remember { derivedStateOf { thumbnailLazyGridState.firstVisibleItemScrollOffset } }
-
- // Handle swipe to change song
- LaunchedEffect(itemScrollOffset) {
- if (!thumbnailLazyGridState.isScrollInProgress || !swipeThumbnail || itemScrollOffset != 0 ||
- currentMediaIndex < 0
- ) {
- return@LaunchedEffect
- }
-
- if (currentItem > currentMediaIndex && canSkipNext) {
- playerConnection.player.seekToNext()
- } else if (currentItem < currentMediaIndex && canSkipPrevious) {
- playerConnection.player.seekToPreviousMediaItem()
- }
- }
+ val isUserDragging by thumbnailLazyGridState.interactionSource.collectIsDraggedAsState()
+ val currentMediaIndexState by rememberUpdatedState(currentMediaIndex)
+ val thumbnailPagesState by rememberUpdatedState(thumbnailPages)
+ val canSkipPreviousState by rememberUpdatedState(canSkipPrevious)
+ val canSkipNextState by rememberUpdatedState(canSkipNext)
+ val displayedMetadata = thumbnailPages.getOrNull(currentItem)?.mediaItem?.metadata ?: mediaMetadata
+
+ LaunchedEffect(thumbnailLazyGridState, swipeThumbnail) {
+ var swipeOrigin: Int? = null
+ var swipePlayerIndex = C.INDEX_UNSET
+ snapshotFlow {
+ Triple(
+ thumbnailLazyGridState.isScrollInProgress,
+ thumbnailLazyGridState.firstVisibleItemIndex,
+ isUserDragging,
+ )
+ }.collect { (isScrolling, visibleIndex, isDragging) ->
+ if (isDragging && swipeOrigin == null) {
+ swipeOrigin = currentMediaIndexState
+ swipePlayerIndex = playerConnection.player.currentMediaItemIndex
+ }
+ if (!isScrolling && !isDragging) {
+ val origin = swipeOrigin
+ val originPlayerIndex = swipePlayerIndex
+ swipeOrigin = null
+ swipePlayerIndex = C.INDEX_UNSET
+ if (
+ !swipeThumbnail ||
+ origin == null ||
+ playerConnection.player.currentMediaItemIndex != originPlayerIndex
+ ) {
+ return@collect
+ }
- // Update position when song changes
- LaunchedEffect(mediaMetadata, currentMediaItem?.mediaId, canSkipPrevious, canSkipNext) {
- val index = maxOf(0, currentMediaIndex)
- if (index >= 0 && index < thumbnailPages.size) {
- try {
- thumbnailLazyGridState.animateScrollToItem(index)
- } catch (e: Exception) {
- thumbnailLazyGridState.scrollToItem(index)
+ val targetPage = thumbnailPagesState.getOrNull(visibleIndex) ?: return@collect
+ when {
+ targetPage.windowIndex == originPlayerIndex -> Unit
+ visibleIndex > origin && canSkipNextState ->
+ playerConnection.player.seekToDefaultPosition(targetPage.windowIndex)
+ visibleIndex < origin && canSkipPreviousState ->
+ playerConnection.player.seekToDefaultPosition(targetPage.windowIndex)
+ }
}
}
}
@@ -317,7 +360,7 @@ fun Thumbnail(
modifier =
Modifier
.fillMaxSize()
- .statusBarsPadding(),
+ .then(if (applyStatusBarPadding) Modifier.statusBarsPadding() else Modifier),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Now Playing header
@@ -330,8 +373,39 @@ fun Thumbnail(
style = MaterialTheme.typography.titleMedium,
color = textBackgroundColor,
)
+ displayedMetadata?.let { preview ->
+ AnimatedContent(
+ targetState = preview.title,
+ transitionSpec = { fadeIn() togetherWith fadeOut() },
+ label = "swipe_preview_title",
+ ) { title ->
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ color = textBackgroundColor,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ val artistLine = preview.artists.joinToString(", ") { it.name }
+ if (artistLine.isNotBlank()) {
+ AnimatedContent(
+ targetState = artistLine,
+ transitionSpec = { fadeIn() togetherWith fadeOut() },
+ label = "swipe_preview_artists",
+ ) { artists ->
+ Text(
+ text = artists,
+ style = MaterialTheme.typography.bodySmall,
+ color = textBackgroundColor.copy(alpha = 0.75f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
// Show album title or queue title
- val playingFrom = queueTitle ?: mediaMetadata?.album?.title
+ val playingFrom = queueTitle ?: displayedMetadata?.album?.title
if (!playingFrom.isNullOrBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
@@ -350,7 +424,7 @@ fun Thumbnail(
modifier = Modifier.fillMaxSize(),
) {
val horizontalLazyGridItemWidth = maxWidth * horizontalLazyGridItemWidthFactor
- val containerMaxWidth = maxWidth
+ val artworkSize = minOf(maxWidth - PlayerHorizontalPadding * 2, maxHeight).coerceAtLeast(0.dp)
LazyHorizontalGrid(
state = thumbnailLazyGridState,
@@ -427,7 +501,7 @@ fun Thumbnail(
Box(
modifier =
Modifier
- .size(containerMaxWidth - (PlayerHorizontalPadding * 2))
+ .size(artworkSize)
.clip(RoundedCornerShape(thumbnailCornerRadius.dp)),
) {
if (hidePlayerThumbnail) {
@@ -468,6 +542,9 @@ fun Thumbnail(
)
val displayUrl = thumbnailSwapState.displayUrl
+ var artworkLoading by remember(displayUrl) {
+ mutableStateOf(!displayUrl.isNullOrBlank())
+ }
val thumbnailBgRequest = rememberOfflineArtworkImageRequest(displayUrl)
val thumbnailArtworkRequest = rememberOfflineArtworkImageRequest(displayUrl)
@@ -511,11 +588,30 @@ fun Thumbnail(
model = thumbnailArtworkRequest,
contentDescription = null,
contentScale = if (shouldCropArtwork) ContentScale.Crop else ContentScale.Fit,
+ onState = { state ->
+ artworkLoading = state is AsyncImagePainter.State.Loading
+ },
modifier =
Modifier
.fillMaxSize()
.let { if (shouldCropArtwork) it.aspectRatio(1f) else it },
)
+ androidx.compose.animation.AnimatedVisibility(
+ visible = artworkLoading,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularWavyProgressIndicator()
+ }
+ }
if (shouldUseCanvas &&
(!primaryCanvasUrl.isNullOrBlank() || !fallbackCanvasUrl.isNullOrBlank())
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/immersive/ImmersivePlayerScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/immersive/ImmersivePlayerScreen.kt
index 0c0a5af32b..735db3a805 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/immersive/ImmersivePlayerScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/player/immersive/ImmersivePlayerScreen.kt
@@ -47,6 +47,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
@@ -82,12 +83,14 @@ import dev.chrisbanes.haze.rememberHazeState
import moe.rukamori.archivetune.R
import moe.rukamori.archivetune.ui.component.ListDialog
import moe.rukamori.archivetune.canvas.CanvasVideo
+import moe.rukamori.archivetune.ui.player.LandscapePlayerControls
import moe.rukamori.archivetune.ui.player.CanvasArtworkPlayer
import moe.rukamori.archivetune.ui.player.rememberOfflineArtworkImageRequest
import moe.rukamori.archivetune.utils.makeTimeString
private val ImmersiveContentColor = Color.White
private val ImmersiveSecondaryContentColor = Color.White.copy(alpha = 0.7f)
+private const val ImmersiveBackdropBlurRotationDegrees = -90f
@Composable
fun ImmersivePlayerScreen(
@@ -226,6 +229,7 @@ private fun ImmersivePortrait(
isPlaying = model.isPlaying,
artWidth = maxWidth,
artHeight = stageHeight,
+ backdropOffsetY = stageHeight,
disableBlur = disableBlur,
backdropBlurAmount = backdropBlurAmount,
)
@@ -312,26 +316,36 @@ private fun ImmersiveLandscape(
modifier =
Modifier
.align(Alignment.TopStart)
+ .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Start))
.padding(start = 24.dp)
.statusBarsPadding()
.padding(top = 18.dp),
)
- Column(
+ LandscapePlayerControls(
modifier =
Modifier
.fillMaxHeight()
.fillMaxWidth(0.52f)
.align(Alignment.CenterEnd)
- .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.End + WindowInsetsSides.Top))
+ .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top))
.padding(
- start = 30.dp,
- top = 18.dp,
- end = 30.dp,
- bottom = contentBottomPadding + 18.dp,
+ start = 16.dp,
+ top = 8.dp,
+ end = 16.dp,
+ bottom = contentBottomPadding + 8.dp,
),
+ playbackContent = {
+ TransportControls(
+ isPlaying = model.isPlaying,
+ isBuffering = model.isBuffering,
+ canSkipPrevious = model.canSkipPrevious,
+ canSkipNext = model.canSkipNext,
+ onAction = onAction,
+ )
+ },
) {
FormatSeam(if (showCodecOnPlayer) model.formatDetails else "")
- Spacer(modifier = Modifier.height(22.dp))
+ Spacer(modifier = Modifier.height(4.dp))
MetadataRow(
title = model.title,
artists = model.artists,
@@ -339,24 +353,16 @@ private fun ImmersiveLandscape(
albumId = model.albumId,
onAction = onAction,
)
- Spacer(modifier = Modifier.weight(1f))
+ if (showVolumeBar) {
+ Spacer(modifier = Modifier.height(4.dp))
+ VolumeControls(volume = model.volume, onAction = onAction)
+ }
ProgressSection(
model = model,
showCodecOnPlayer = showCodecOnPlayer,
onAction = onAction,
)
- Spacer(modifier = Modifier.height(18.dp))
- TransportControls(
- isPlaying = model.isPlaying,
- isBuffering = model.isBuffering,
- canSkipPrevious = model.canSkipPrevious,
- canSkipNext = model.canSkipNext,
- onAction = onAction,
- )
- if (showVolumeBar) {
- Spacer(modifier = Modifier.height(20.dp))
- VolumeControls(volume = model.volume, onAction = onAction)
- }
+ Spacer(modifier = Modifier.height(4.dp))
}
}
}
@@ -373,6 +379,7 @@ private fun ImmersiveBackdrop(
artHeight: Dp,
disableBlur: Boolean,
backdropBlurAmount: Int,
+ backdropOffsetY: Dp = 0.dp,
) {
val hazeState = rememberHazeState()
val artworkRequest = rememberOfflineArtworkImageRequest(artworkUrl)
@@ -405,7 +412,16 @@ private fun ImmersiveBackdrop(
)
}
- BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ val rotatedBackdropModifier =
+ remember(backdropOffsetY) {
+ Modifier
+ .fillMaxSize()
+ .graphicsLayer {
+ rotationZ = 180f
+ translationY = backdropOffsetY.toPx()
+ }
+ }
val stageFadeMask =
remember {
Brush.verticalGradient(
@@ -422,6 +438,7 @@ private fun ImmersiveBackdrop(
modifier =
Modifier
.fillMaxSize()
+ .clipToBounds()
.hazeSource(hazeState),
) {
if (canvasFrame != null && canvas != null) {
@@ -429,7 +446,16 @@ private fun ImmersiveBackdrop(
bitmap = canvasFrame,
contentDescription = null,
contentScale = ContentScale.Crop,
- modifier = Modifier.fillMaxSize(),
+ modifier = rotatedBackdropModifier,
+ )
+ Image(
+ bitmap = canvasFrame,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier =
+ Modifier
+ .size(width = artWidth, height = artHeight)
+ .align(Alignment.TopStart),
)
} else {
AsyncImage(
@@ -439,7 +465,7 @@ private fun ImmersiveBackdrop(
fallback = artworkPlaceholder,
contentDescription = null,
contentScale = ContentScale.Crop,
- modifier = Modifier.fillMaxSize(),
+ modifier = rotatedBackdropModifier,
)
AsyncImage(
model = artworkRequest,
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/AccountScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/AccountScreen.kt
index 155cfd9d50..388d4efce9 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/AccountScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/AccountScreen.kt
@@ -47,6 +47,8 @@ import moe.rukamori.archivetune.ui.menu.YouTubeAlbumMenu
import moe.rukamori.archivetune.ui.menu.YouTubeArtistMenu
import moe.rukamori.archivetune.ui.menu.YouTubePlaylistMenu
import moe.rukamori.archivetune.ui.utils.backToMain
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.viewmodels.AccountContentType
import moe.rukamori.archivetune.viewmodels.AccountViewModel
@@ -134,7 +136,7 @@ fun AccountScreen(
Modifier
.combinedClickable(
onClick = {
- navController.navigate("album/${item.id}")
+ navController.navigateToAlbum(item.id)
},
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
@@ -171,7 +173,7 @@ fun AccountScreen(
Modifier
.combinedClickable(
onClick = {
- navController.navigate("artist/${item.id}")
+ navController.navigateToArtist(item.id)
},
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/AlbumScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/AlbumScreen.kt
index 50e845c7bb..74a500bdd4 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/AlbumScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/AlbumScreen.kt
@@ -105,6 +105,8 @@ import moe.rukamori.archivetune.ui.utils.HeaderDownloadState
import moe.rukamori.archivetune.ui.utils.ItemWrapper
import moe.rukamori.archivetune.ui.utils.backToMain
import moe.rukamori.archivetune.ui.utils.headerDownloadState
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.ui.utils.sendAddMissingDownloads
import moe.rukamori.archivetune.ui.utils.sendRemoveDownloads
import moe.rukamori.archivetune.utils.makeTimeString
@@ -243,7 +245,7 @@ fun AlbumScreen(
albumWithSongs.artists.fastForEachIndexed { index, artist ->
withLink(
LinkAnnotation.Clickable(artist.id) {
- navController.navigate("artist/${artist.id}")
+ navController.navigateToArtist(artist.id)
},
) {
append(artist.name)
@@ -487,7 +489,7 @@ fun AlbumScreen(
modifier =
Modifier
.combinedClickable(
- onClick = { navController.navigate("album/${item.id}") },
+ onClick = { navController.navigateToAlbum(item.id) },
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
menuState.show {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/BrowseScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/BrowseScreen.kt
index a84cce5a6d..584bfedfa9 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/BrowseScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/BrowseScreen.kt
@@ -68,6 +68,8 @@ import moe.rukamori.archivetune.ui.menu.YouTubeAlbumMenu
import moe.rukamori.archivetune.ui.menu.YouTubeArtistMenu
import moe.rukamori.archivetune.ui.menu.YouTubePlaylistMenu
import moe.rukamori.archivetune.ui.utils.backToMain
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.viewmodels.BrowseViewModel
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@@ -108,9 +110,9 @@ fun BrowseScreen(
) {
viewModel.events.collect { event ->
when (event) {
- is BrowseEvent.OpenAlbum -> navController.navigate("album/${event.browseId}")
+ is BrowseEvent.OpenAlbum -> navController.navigateToAlbum(event.browseId)
is BrowseEvent.OpenPlaylist -> navController.navigate("online_playlist/${event.playlistId}")
- is BrowseEvent.OpenArtist -> navController.navigate("artist/${event.browseId}")
+ is BrowseEvent.OpenArtist -> navController.navigateToArtist(event.browseId)
is BrowseEvent.OpenPodcast -> navController.navigate("podcast/${android.net.Uri.encode(event.browseId)}")
is BrowseEvent.PlayEpisode -> {
playerConnection.playQueue(
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/ExploreScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/ExploreScreen.kt
index bca80023c3..68c7adae8f 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/ExploreScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/ExploreScreen.kt
@@ -57,6 +57,7 @@ import moe.rukamori.archivetune.ui.component.shimmer.TextPlaceholder
import moe.rukamori.archivetune.ui.menu.YouTubeAlbumMenu
import moe.rukamori.archivetune.ui.menu.YouTubeSongMenu
import moe.rukamori.archivetune.ui.utils.SnapLayoutInfoProvider
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
import moe.rukamori.archivetune.viewmodels.ChartsViewModel
import moe.rukamori.archivetune.viewmodels.ExploreViewModel
@@ -345,7 +346,7 @@ fun ExploreScreen(
Modifier
.combinedClickable(
onClick = {
- navController.navigate("album/${album.id}")
+ navController.navigateToAlbum(album.id)
},
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/HistoryScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/HistoryScreen.kt
index f3bce80a24..d65263c459 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/HistoryScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/HistoryScreen.kt
@@ -1136,7 +1136,7 @@ private fun HistorySourceSelector(
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
},
colors =
- ToggleButtonDefaults.toggleButtonColors(
+ ToggleButtonDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
checkedContainerColor = MaterialTheme.colorScheme.primaryContainer,
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/HomeScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/HomeScreen.kt
index 1b8adc4e8a..6c83d0dfaa 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/HomeScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/HomeScreen.kt
@@ -63,6 +63,8 @@ import moe.rukamori.archivetune.home.HomeAction
import moe.rukamori.archivetune.home.HomeEvent
import moe.rukamori.archivetune.home.HomeScreenState
import moe.rukamori.archivetune.home.HomeUiState
+import moe.rukamori.archivetune.home.hasVisibleContent
+import moe.rukamori.archivetune.innertube.pages.HomePage
import moe.rukamori.archivetune.models.MediaMetadata
import moe.rukamori.archivetune.extensions.toMediaItem
import moe.rukamori.archivetune.playback.PlayerConnection
@@ -589,44 +591,48 @@ private fun HomeContent(
}
}
- uiState.homePage?.sections.orEmpty().forEachIndexed { index, section ->
- val sectionKey = "${section.endpoint?.browseId ?: section.title}_$index"
- sectionSpacer("remote_$sectionKey")
- item(
- key = "home_remote_header_$sectionKey",
- contentType = "section_header",
- ) {
- val playAll =
- remember(section.playEndpoint, playerConnection) {
- section.playEndpoint?.let { endpoint ->
- { playerConnection.playQueue(YouTubeQueue.playlist(endpoint)) }
+ uiState.homePage
+ ?.sections
+ .orEmpty()
+ .filter(HomePage.Section::hasVisibleContent)
+ .forEachIndexed { index, section ->
+ val sectionKey = "${section.endpoint?.browseId ?: section.title}_$index"
+ sectionSpacer("remote_$sectionKey")
+ item(
+ key = "home_remote_header_$sectionKey",
+ contentType = "section_header",
+ ) {
+ val playAll =
+ remember(section.playEndpoint, playerConnection) {
+ section.playEndpoint?.let { endpoint ->
+ { playerConnection.playQueue(YouTubeQueue.playlist(endpoint)) }
+ }
}
- }
- HomePageSectionTitle(
- section = section,
- navController = navController,
- onPlayAll = playAll,
- modifier = Modifier.animateItem(),
- )
- }
- item(
- key = "home_remote_$sectionKey",
- contentType = "media_shelf",
- ) {
- HomePageSectionContent(
- section = section,
- mediaMetadata = mediaMetadata,
- isPlaying = isPlaying,
- navController = navController,
- playerConnection = playerConnection,
- menuState = menuState,
- haptic = haptic,
- scope = scope,
- onOpenRemoteItem = { itemId -> onAction(HomeAction.OpenRemoteItem(itemId)) },
- modifier = Modifier.animateItem(),
- )
+ HomePageSectionTitle(
+ section = section,
+ navController = navController,
+ onPlayAll = playAll,
+ modifier = Modifier.animateItem(),
+ )
+ }
+ item(
+ key = "home_remote_$sectionKey",
+ contentType = "media_shelf",
+ ) {
+ HomePageSectionContent(
+ section = section,
+ mediaMetadata = mediaMetadata,
+ isPlaying = isPlaying,
+ navController = navController,
+ playerConnection = playerConnection,
+ menuState = menuState,
+ haptic = haptic,
+ scope = scope,
+ onOpenRemoteItem = { itemId -> onAction(HomeAction.OpenRemoteItem(itemId)) },
+ modifier = Modifier.animateItem(),
+ )
+ }
}
- }
if (uiState.isLoadingMore) {
item(
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/HomeScreenComponents.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/HomeScreenComponents.kt
index e8dc0fdd64..844af3994d 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/HomeScreenComponents.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/HomeScreenComponents.kt
@@ -141,6 +141,8 @@ import moe.rukamori.archivetune.ui.menu.YouTubeArtistMenu
import moe.rukamori.archivetune.ui.menu.YouTubePlaylistMenu
import moe.rukamori.archivetune.ui.menu.YouTubeSongMenu
import moe.rukamori.archivetune.ui.utils.YtimgResizePolicy
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.ui.utils.resize
import moe.rukamori.archivetune.utils.joinByBullet
import moe.rukamori.archivetune.utils.makeTimeString
@@ -302,7 +304,7 @@ fun QuickPicksSection(
HorizontalCenteredHeroCarousel(
state = rememberCarouselState { distinctQuickPicks.size },
- maxItemWidth = heroMaxWidth,
+ preferredItemWidth = heroMaxWidth,
itemSpacing = 10.dp,
contentPadding = PaddingValues(horizontal = 16.dp),
modifier =
@@ -565,7 +567,7 @@ fun RemoteQuickPicksSection(
HorizontalCenteredHeroCarousel(
state = rememberCarouselState { songs.size },
- maxItemWidth = heroMaxWidth,
+ preferredItemWidth = heroMaxWidth,
itemSpacing = 10.dp,
contentPadding = PaddingValues(horizontal = 16.dp),
modifier =
@@ -1034,11 +1036,11 @@ fun SpeedDialSection(
}
is Album -> {
- navController.navigate("album/${localItem.id}")
+ navController.navigateToAlbum(localItem.id)
}
is Artist -> {
- navController.navigate("artist/${localItem.id}")
+ navController.navigateToArtist(localItem.id)
}
is Playlist -> {
@@ -1263,8 +1265,8 @@ private fun KeepListeningResumeCard(
}
}
- is Album -> navController.navigate("album/${item.id}")
- is Artist -> navController.navigate("artist/${item.id}")
+ is Album -> navController.navigateToAlbum(item.id)
+ is Artist -> navController.navigateToArtist(item.id)
is Playlist -> navController.navigate("local_playlist/${item.id}")
}
}
@@ -1676,9 +1678,9 @@ private fun SimilarDiscoveryDeck(
val onHeaderClick: (() -> Unit)? =
remember(source, navController) {
when (source) {
- is Song -> source.album?.id?.let { albumId -> ({ navController.navigate("album/$albumId") }) }
- is Album -> ({ navController.navigate("album/${source.id}") })
- is Artist -> ({ navController.navigate("artist/${source.id}") })
+ is Song -> source.album?.id?.let { albumId -> ({ navController.navigateToAlbum(albumId) }) }
+ is Album -> ({ navController.navigateToAlbum(source.id) })
+ is Artist -> ({ navController.navigateToArtist(source.id) })
is Playlist -> ({ navController.navigate("local_playlist/${source.id}") })
}
}
@@ -2108,11 +2110,11 @@ private fun YouTubeGridItemWrapper(
}
is AlbumItem -> {
- navController.navigate("album/${item.id}")
+ navController.navigateToAlbum(item.id)
}
is ArtistItem -> {
- navController.navigate("artist/${item.id}")
+ navController.navigateToArtist(item.id)
}
is PlaylistItem -> {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/NavigationBuilder.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/NavigationBuilder.kt
index 5d6e5ad5de..4460d2780d 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/NavigationBuilder.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/NavigationBuilder.kt
@@ -41,6 +41,7 @@ import moe.rukamori.archivetune.ui.screens.playlist.AutoPlaylistScreen
import moe.rukamori.archivetune.ui.screens.playlist.CachePlaylistScreen
import moe.rukamori.archivetune.ui.screens.playlist.LocalPlaylistScreen
import moe.rukamori.archivetune.ui.screens.playlist.OnlinePlaylistScreen
+import moe.rukamori.archivetune.ui.screens.playlist.SpotifyLikedSongsScreen
import moe.rukamori.archivetune.ui.screens.playlist.SpotifyPlaylistScreen
import moe.rukamori.archivetune.ui.screens.playlist.TopPlaylistScreen
import moe.rukamori.archivetune.ui.screens.podcast.PodcastRoute
@@ -341,6 +342,9 @@ fun NavGraphBuilder.navigationBuilder(
) {
SpotifyPlaylistScreen(navController, scrollBehavior)
}
+ composable(route = "spotify_liked_songs") {
+ SpotifyLikedSongsScreen(navController, scrollBehavior)
+ }
composable(
route = "auto_playlist/{playlist}?tab={tab}",
arguments =
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/NewReleaseScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/NewReleaseScreen.kt
index cb2d2a3541..d3a0b7531d 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/NewReleaseScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/NewReleaseScreen.kt
@@ -91,6 +91,7 @@ import moe.rukamori.archivetune.ui.component.shimmer.GridItemPlaceHolder
import moe.rukamori.archivetune.ui.component.shimmer.ShimmerHost
import moe.rukamori.archivetune.ui.menu.YouTubeAlbumMenu
import moe.rukamori.archivetune.ui.utils.backToMain
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
import moe.rukamori.archivetune.viewmodels.NewReleaseContent
import moe.rukamori.archivetune.viewmodels.NewReleaseUiState
import moe.rukamori.archivetune.viewmodels.NewReleaseViewModel
@@ -164,7 +165,7 @@ fun NewReleaseScreen(
activeAlbumId = mediaMetadata?.album?.id,
isPlaying = isPlaying,
coroutineScope = coroutineScope,
- onReleaseClick = { album -> navController.navigate("album/${album.id}") },
+ onReleaseClick = { album -> navController.navigateToAlbum(album.id) },
onReleaseLongClick = { album ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
menuState.show {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/NewsScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/NewsScreen.kt
index 1ea4455d86..d2da503fbe 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/NewsScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/NewsScreen.kt
@@ -596,7 +596,7 @@ private fun NewsImageCarousel(
Box(modifier = modifier) {
HorizontalCenteredHeroCarousel(
state = carouselState,
- maxItemWidth = 336.dp,
+ preferredItemWidth = 336.dp,
itemSpacing = 8.dp,
contentPadding = PaddingValues(horizontal = 14.dp),
modifier = Modifier.fillMaxSize(),
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/StatsScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/StatsScreen.kt
index d435e60fdb..c39f8d9faf 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/StatsScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/StatsScreen.kt
@@ -114,6 +114,8 @@ import moe.rukamori.archivetune.ui.menu.AlbumMenu
import moe.rukamori.archivetune.ui.menu.ArtistMenu
import moe.rukamori.archivetune.ui.menu.SongMenu
import moe.rukamori.archivetune.ui.utils.backToMain
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.utils.joinByBullet
import moe.rukamori.archivetune.utils.makeTimeString
import moe.rukamori.archivetune.viewmodels.StatsScreenState
@@ -511,7 +513,7 @@ fun StatsScreen(
Modifier
.width(164.dp)
.combinedClickable(
- onClick = { navController.navigate("artist/${artist.id}") },
+ onClick = { navController.navigateToArtist(artist.id) },
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
menuState.show {
@@ -562,7 +564,7 @@ fun StatsScreen(
Modifier
.width(172.dp)
.combinedClickable(
- onClick = { navController.navigate("album/${album.id}") },
+ onClick = { navController.navigateToAlbum(album.id) },
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
menuState.show {
@@ -1104,7 +1106,7 @@ private fun StatsHighlightsSection(
).lowercase()} • ${makeTimeString(topArtist.timeListened?.toLong())}",
imageUrl = topArtist.artist.thumbnailUrl,
useCircleShape = true,
- onClick = { navController.navigate("artist/${topArtist.id}") },
+ onClick = { navController.navigateToArtist(topArtist.id) },
)
}
if (topSong != null && topSongEntity != null) {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/YouTubeBrowseScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/YouTubeBrowseScreen.kt
index b3cc7a5a86..88dedeb038 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/YouTubeBrowseScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/YouTubeBrowseScreen.kt
@@ -79,6 +79,8 @@ import moe.rukamori.archivetune.ui.menu.YouTubePlaylistMenu
import moe.rukamori.archivetune.ui.menu.YouTubeSongMenu
import moe.rukamori.archivetune.ui.utils.SnapLayoutInfoProvider
import moe.rukamori.archivetune.ui.utils.backToMain
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.viewmodels.YouTubeBrowseViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@@ -223,8 +225,8 @@ fun YouTubeBrowseScreen(
.combinedClickable(
onClick = {
when (item) {
- is AlbumItem -> navController.navigate("album/${item.id}")
- is ArtistItem -> navController.navigate("artist/${item.id}")
+ is AlbumItem -> navController.navigateToAlbum(item.id)
+ is ArtistItem -> navController.navigateToArtist(item.id)
is PlaylistItem -> navController.navigate("online_playlist/${item.id}")
is PodcastItem -> navController.navigate("podcast/${android.net.Uri.encode(item.browseId)}")
is EpisodeItem -> {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/artist/ArtistItemsScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/artist/ArtistItemsScreen.kt
index c3a56a9fb3..590308eb1c 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/artist/ArtistItemsScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/artist/ArtistItemsScreen.kt
@@ -70,6 +70,8 @@ import moe.rukamori.archivetune.ui.menu.YouTubeArtistMenu
import moe.rukamori.archivetune.ui.menu.YouTubePlaylistMenu
import moe.rukamori.archivetune.ui.menu.YouTubeSongMenu
import moe.rukamori.archivetune.ui.utils.backToMain
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.viewmodels.ArtistItemsViewModel
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@@ -210,11 +212,11 @@ fun ArtistItemsScreen(
}
is AlbumItem -> {
- navController.navigate("album/${item.id}")
+ navController.navigateToAlbum(item.id)
}
is ArtistItem -> {
- navController.navigate("artist/${item.id}")
+ navController.navigateToArtist(item.id)
}
is PlaylistItem -> {
@@ -274,11 +276,11 @@ fun ArtistItemsScreen(
}
is AlbumItem -> {
- navController.navigate("album/${item.id}")
+ navController.navigateToAlbum(item.id)
}
is ArtistItem -> {
- navController.navigate("artist/${item.id}")
+ navController.navigateToArtist(item.id)
}
is PlaylistItem -> {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/artist/ArtistScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/artist/ArtistScreen.kt
index 3463e93ecb..070d614cd3 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/artist/ArtistScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/artist/ArtistScreen.kt
@@ -144,6 +144,8 @@ import moe.rukamori.archivetune.ui.menu.YouTubeSongMenu
import moe.rukamori.archivetune.ui.utils.YtimgResizePolicy
import moe.rukamori.archivetune.ui.utils.backToMain
import moe.rukamori.archivetune.ui.utils.formatCompactCount
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.ui.utils.resize
import moe.rukamori.archivetune.utils.rememberPreference
import moe.rukamori.archivetune.viewmodels.ArtistAction
@@ -623,7 +625,7 @@ fun ArtistScreen(
) {
ArtistNewReleaseSection(
release = release,
- onClick = { navController.navigate("album/${release.id}") },
+ onClick = { navController.navigateToAlbum(release.id) },
)
}
}
@@ -774,7 +776,7 @@ fun ArtistScreen(
Modifier
.combinedClickable(
onClick = {
- navController.navigate("album/${album.id}")
+ navController.navigateToAlbum(album.id)
},
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
@@ -926,11 +928,11 @@ fun ArtistScreen(
}
is AlbumItem -> {
- navController.navigate("album/${item.id}")
+ navController.navigateToAlbum(item.id)
}
is ArtistItem -> {
- navController.navigate("artist/${item.id}")
+ navController.navigateToArtist(item.id)
}
is PlaylistItem -> {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/downloads/DownloadLibraryScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/downloads/DownloadLibraryScreen.kt
index 37e16731d7..67d82b88df 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/downloads/DownloadLibraryScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/downloads/DownloadLibraryScreen.kt
@@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
@@ -41,6 +42,8 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ContainedLoadingIndicator
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledIconButton
@@ -106,6 +109,8 @@ import moe.rukamori.archivetune.utils.makeTimeString
import moe.rukamori.archivetune.viewmodels.DownloadLibraryEvent
import moe.rukamori.archivetune.viewmodels.DownloadRemovalConfirmation
import moe.rukamori.archivetune.viewmodels.DownloadLibraryScreenState
+import moe.rukamori.archivetune.viewmodels.DownloadLibrarySortField
+import moe.rukamori.archivetune.viewmodels.DownloadLibrarySortOrder
import moe.rukamori.archivetune.viewmodels.DownloadLibraryTab
import moe.rukamori.archivetune.viewmodels.DownloadLibraryViewModel
@@ -153,6 +158,8 @@ fun DownloadLibraryScreen(
onQueryChange = viewModel::updateQuery,
onClearQuery = viewModel::clearQuery,
onTabSelected = viewModel::selectTab,
+ onSortFieldSelected = viewModel::selectSortField,
+ onSortOrderSelected = viewModel::selectSortOrder,
onOpenEntry = viewModel::open,
onPauseEntry = viewModel::pause,
onResumeEntry = viewModel::resume,
@@ -179,6 +186,8 @@ private fun DownloadLibraryScreenContent(
onQueryChange: (String) -> Unit,
onClearQuery: () -> Unit,
onTabSelected: (DownloadLibraryTab) -> Unit,
+ onSortFieldSelected: (DownloadLibrarySortField) -> Unit,
+ onSortOrderSelected: (DownloadLibrarySortOrder) -> Unit,
onOpenEntry: (DownloadEntryUiModel) -> Unit,
onPauseEntry: (DownloadEntryUiModel) -> Unit,
onResumeEntry: (DownloadEntryUiModel) -> Unit,
@@ -194,6 +203,7 @@ private fun DownloadLibraryScreenContent(
val selectedTab = state.selectedTab()
val query = state.query()
val isSearchActive = state.isSearchActive()
+ val sort = state.sort
val tabs = remember { DownloadLibraryTab.entries }
val pagerState = rememberPagerState(initialPage = tabs.indexOf(selectedTab), pageCount = { tabs.size })
val scope = rememberCoroutineScope()
@@ -287,6 +297,12 @@ private fun DownloadLibraryScreenContent(
}
},
actions = {
+ DownloadSortMenu(
+ sortField = sort.field,
+ sortOrder = sort.order,
+ onSortFieldSelected = onSortFieldSelected,
+ onSortOrderSelected = onSortOrderSelected,
+ )
IconButton(onClick = onActivateSearch) {
Icon(
painter = painterResource(R.drawable.search),
@@ -391,6 +407,134 @@ private fun DownloadLibraryScreenContent(
}
}
+@Composable
+private fun DownloadSortMenu(
+ sortField: DownloadLibrarySortField,
+ sortOrder: DownloadLibrarySortOrder,
+ onSortFieldSelected: (DownloadLibrarySortField) -> Unit,
+ onSortOrderSelected: (DownloadLibrarySortOrder) -> Unit,
+) {
+ var menuExpanded by remember { mutableStateOf(false) }
+ var sortOrderMenuExpanded by remember { mutableStateOf(false) }
+
+ Box {
+ IconButton(onClick = { menuExpanded = true }) {
+ Icon(
+ painter = painterResource(R.drawable.tune),
+ contentDescription = stringResource(R.string.sort_by_name),
+ )
+ }
+ DropdownMenu(
+ expanded = menuExpanded,
+ onDismissRequest = {
+ menuExpanded = false
+ sortOrderMenuExpanded = false
+ },
+ ) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.sort_by_name)) },
+ trailingIcon = {
+ Icon(
+ painterResource(
+ if (sortField == DownloadLibrarySortField.NAME) {
+ R.drawable.radio_button_checked
+ } else {
+ R.drawable.radio_button_unchecked
+ },
+ ),
+ contentDescription = null,
+ )
+ },
+ onClick = { onSortFieldSelected(DownloadLibrarySortField.NAME) },
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.sort_by_last_updated)) },
+ leadingIcon = {
+ Icon(painterResource(R.drawable.calendar_today), contentDescription = null)
+ },
+ trailingIcon = {
+ Icon(
+ painterResource(
+ if (sortField == DownloadLibrarySortField.DATE_MODIFIED) {
+ R.drawable.radio_button_checked
+ } else {
+ R.drawable.radio_button_unchecked
+ },
+ ),
+ contentDescription = null,
+ )
+ },
+ onClick = { onSortFieldSelected(DownloadLibrarySortField.DATE_MODIFIED) },
+ )
+ HorizontalDivider()
+ DropdownMenuItem(
+ text = {
+ Text(
+ stringResource(
+ if (sortOrder == DownloadLibrarySortOrder.ASCENDING) {
+ R.string.sort_order_ascending
+ } else {
+ R.string.sort_order_descending
+ },
+ ),
+ )
+ },
+ trailingIcon = {
+ Icon(painterResource(R.drawable.arrow_forward), contentDescription = null)
+ },
+ onClick = { sortOrderMenuExpanded = true },
+ )
+ }
+ DropdownMenu(
+ expanded = menuExpanded && sortOrderMenuExpanded,
+ onDismissRequest = { sortOrderMenuExpanded = false },
+ ) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.sort_order_ascending)) },
+ leadingIcon = { Icon(painterResource(R.drawable.arrow_upward), contentDescription = null) },
+ trailingIcon = {
+ Icon(
+ painterResource(
+ if (sortOrder == DownloadLibrarySortOrder.ASCENDING) {
+ R.drawable.radio_button_checked
+ } else {
+ R.drawable.radio_button_unchecked
+ },
+ ),
+ contentDescription = null,
+ )
+ },
+ onClick = {
+ onSortOrderSelected(DownloadLibrarySortOrder.ASCENDING)
+ menuExpanded = false
+ sortOrderMenuExpanded = false
+ },
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.sort_order_descending)) },
+ leadingIcon = { Icon(painterResource(R.drawable.arrow_downward), contentDescription = null) },
+ trailingIcon = {
+ Icon(
+ painterResource(
+ if (sortOrder == DownloadLibrarySortOrder.DESCENDING) {
+ R.drawable.radio_button_checked
+ } else {
+ R.drawable.radio_button_unchecked
+ },
+ ),
+ contentDescription = null,
+ )
+ },
+ onClick = {
+ onSortOrderSelected(DownloadLibrarySortOrder.DESCENDING)
+ menuExpanded = false
+ sortOrderMenuExpanded = false
+ },
+ )
+ }
+ }
+}
+
@Composable
private fun DownloadTabs(
tabs: List,
@@ -519,7 +663,12 @@ private fun DownloadSections(
val layoutDirection = LocalLayoutDirection.current
val playerPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues()
+ val listState = rememberLazyListState()
+ LaunchedEffect(listState) {
+ listState.scrollToItem(0)
+ }
LazyColumn(
+ state = listState,
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
contentPadding =
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryAlbumsScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryAlbumsScreen.kt
index 86bfd7dcb5..9e188da451 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryAlbumsScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryAlbumsScreen.kt
@@ -87,6 +87,7 @@ import moe.rukamori.archivetune.ui.component.ItemThumbnail
import moe.rukamori.archivetune.ui.component.LocalMenuState
import moe.rukamori.archivetune.ui.menu.AlbumMenu
import moe.rukamori.archivetune.ui.screens.Screens
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
import moe.rukamori.archivetune.utils.rememberEnumPreference
import moe.rukamori.archivetune.utils.rememberPreference
import moe.rukamori.archivetune.viewmodels.LibraryAlbumsViewModel
@@ -386,7 +387,7 @@ fun LibraryAlbumsScreen(
.clip(RoundedCornerShape(36.dp))
.background(MaterialTheme.colorScheme.primaryContainer)
.clickable {
- navController.navigate("album/${album.id}")
+ navController.navigateToAlbum(album.id)
}.padding(20.dp),
) {
Column {
@@ -514,7 +515,7 @@ fun LibraryAlbumsScreen(
Modifier
.fillMaxWidth()
.combinedClickable(
- onClick = { navController.navigate("album/${album.id}") },
+ onClick = { navController.navigateToAlbum(album.id) },
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
menuState.show {
@@ -611,7 +612,7 @@ fun LibraryAlbumsScreen(
.clip(RoundedCornerShape(28.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
.combinedClickable(
- onClick = { navController.navigate("album/${album.id}") },
+ onClick = { navController.navigateToAlbum(album.id) },
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
menuState.show {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryArtistsScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryArtistsScreen.kt
index e0d8895fb5..72ef2b485d 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryArtistsScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryArtistsScreen.kt
@@ -85,6 +85,7 @@ import moe.rukamori.archivetune.ui.component.ItemThumbnail
import moe.rukamori.archivetune.ui.component.LocalMenuState
import moe.rukamori.archivetune.ui.menu.ArtistMenu
import moe.rukamori.archivetune.ui.screens.Screens
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.utils.rememberEnumPreference
import moe.rukamori.archivetune.utils.rememberPreference
import moe.rukamori.archivetune.viewmodels.LibraryArtistsViewModel
@@ -172,7 +173,7 @@ fun LibraryArtistsScreen(
.clip(RoundedCornerShape(36.dp))
.background(MaterialTheme.colorScheme.primaryContainer)
.clickable {
- topArtist?.let { navController.navigate("artist/${it.artist.id}") }
+ topArtist?.let { navController.navigateToArtist(it.artist.id) }
}.padding(16.dp),
) {
Column(
@@ -517,7 +518,7 @@ fun LibraryArtistsScreen(
.clip(RoundedCornerShape(28.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
.combinedClickable(
- onClick = { navController.navigate("artist/${artist.id}") },
+ onClick = { navController.navigateToArtist(artist.id) },
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
menuState.show {
@@ -624,7 +625,7 @@ fun LibraryArtistsScreen(
Modifier
.width(72.dp)
.clickable {
- navController.navigate("artist/${artist.id}")
+ navController.navigateToArtist(artist.id)
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryLocalSongsScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryLocalSongsScreen.kt
new file mode 100644
index 0000000000..ef421a7402
--- /dev/null
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryLocalSongsScreen.kt
@@ -0,0 +1,25 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.ui.screens.library
+
+import androidx.compose.runtime.Composable
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.navigation.NavController
+import moe.rukamori.archivetune.viewmodels.LocalSongsViewModel
+
+@Composable
+fun LibraryLocalSongsScreen(
+ navController: NavController,
+ viewModel: LocalSongsViewModel = hiltViewModel(),
+) {
+ LocalSongScreenContent(
+ navController = navController,
+ embeddedInLibrary = true,
+ viewModel = viewModel,
+ )
+}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryMixScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryMixScreen.kt
index d08a90c3f7..a289f91f17 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryMixScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryMixScreen.kt
@@ -91,6 +91,8 @@ import moe.rukamori.archivetune.spotify.SpotifyLibraryViewModel
import moe.rukamori.archivetune.spotify.SpotifyMapper
import moe.rukamori.archivetune.spotify.models.SpotifyPlaylist
import moe.rukamori.archivetune.ui.component.ExpressivePullToRefreshBox
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.utils.rememberPreference
import moe.rukamori.archivetune.viewmodels.LibraryMixViewModel
import moe.rukamori.archivetune.viewmodels.LibraryRefreshState
@@ -220,7 +222,7 @@ fun LibraryMixScreen(
MostPlayedAlbumSpotlightCard(
album = album,
- onOpenAlbum = { navController.navigate("album/${album.id}") },
+ onOpenAlbum = { navController.navigateToAlbum(album.id) },
onPlayAll = playAlbum,
onShuffle = shuffleAlbum,
)
@@ -663,7 +665,7 @@ fun LibraryMixScreen(
Modifier
.width(80.dp)
.clickable {
- navController.navigate("artist/${artist.id}")
+ navController.navigateToArtist(artist.id)
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryPlaylistsScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryPlaylistsScreen.kt
index 5398274971..e060b1dd26 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryPlaylistsScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryPlaylistsScreen.kt
@@ -136,6 +136,7 @@ fun LibraryPlaylistsScreen(
val (sortDescending, onSortDescendingChange) = rememberPreference(PlaylistSortDescendingKey, true)
var locked by rememberPreference(PlaylistEditLockKey, defaultValue = true)
val playlists by viewModel.allPlaylists.collectAsStateWithLifecycle()
+ val downloadedPlaylistIds by viewModel.downloadedPlaylistIds.collectAsStateWithLifecycle()
val filteredPlaylistIds by database
.playlistIdsByTags(
if (selectedTagIds.isEmpty()) emptyList() else selectedTagIds.toList(),
@@ -485,6 +486,7 @@ fun LibraryPlaylistsScreen(
) { playlist ->
PlaylistGridCard(
playlist = playlist,
+ isDownloaded = playlist.id in downloadedPlaylistIds,
onClick = {
openPlaylist(navController, playlist)
},
@@ -542,6 +544,7 @@ fun LibraryPlaylistsScreen(
) {
PlaylistListCard(
playlist = playlist,
+ isDownloaded = playlist.id in downloadedPlaylistIds,
onClick = {
openPlaylist(navController, playlist)
},
@@ -737,6 +740,7 @@ fun rememberArtworkCardColor(
@Composable
fun PlaylistListCard(
playlist: Playlist,
+ isDownloaded: Boolean,
onClick: () -> Unit,
onPlay: () -> Unit,
onMenuClick: () -> Unit,
@@ -775,15 +779,25 @@ fun PlaylistListCard(
null
}
val metadataTrailingContent: (@Composable RowScope.() -> Unit)? =
- if (playlist.playlist.isHidden) {
+ if (isDownloaded || playlist.playlist.isHidden) {
{
- Spacer(modifier = Modifier.width(8.dp))
- Icon(
- painter = painterResource(R.drawable.visibility_off),
- contentDescription = stringResource(R.string.hide_playlist),
- modifier = Modifier.size(12.dp),
- tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.4f),
- )
+ if (isDownloaded) {
+ Icon(
+ painter = painterResource(R.drawable.offline),
+ contentDescription = stringResource(R.string.downloaded_desc),
+ modifier = Modifier.size(14.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ if (playlist.playlist.isHidden) {
+ Spacer(modifier = Modifier.width(8.dp))
+ Icon(
+ painter = painterResource(R.drawable.visibility_off),
+ contentDescription = stringResource(R.string.hide_playlist),
+ modifier = Modifier.size(12.dp),
+ tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.4f),
+ )
+ }
}
} else {
null
@@ -808,6 +822,7 @@ fun PlaylistListCard(
@Composable
fun PlaylistGridCard(
playlist: Playlist,
+ isDownloaded: Boolean,
onClick: () -> Unit,
onPlay: () -> Unit,
onLongClick: () -> Unit,
@@ -860,6 +875,25 @@ fun PlaylistGridCard(
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
+ if (isDownloaded) {
+ Box(
+ modifier =
+ Modifier
+ .align(Alignment.TopStart)
+ .padding(8.dp)
+ .size(28.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.offline),
+ contentDescription = stringResource(R.string.downloaded_desc),
+ tint = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.size(16.dp),
+ )
+ }
+ }
// Play overlay on bottom right of grid cover
Box(
modifier =
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryScreen.kt
index 07f3ebf3af..79b0788e23 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibraryScreen.kt
@@ -12,6 +12,7 @@ import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
@@ -84,9 +85,11 @@ import moe.rukamori.archivetune.constants.PlaylistTagOrderKey
import moe.rukamori.archivetune.constants.ShowSpotifyPlaylistsKey
import moe.rukamori.archivetune.constants.ShowTagsInLibraryKey
import moe.rukamori.archivetune.constants.toLibraryFilterOrder
+import moe.rukamori.archivetune.constants.toLibraryFilterPreference
import moe.rukamori.archivetune.constants.toPlaylistTagOrder
import moe.rukamori.archivetune.db.entities.TagEntity
import moe.rukamori.archivetune.ui.component.TagsManagementDialog
+import moe.rukamori.archivetune.ui.component.LibraryChipOrderDialog
import moe.rukamori.archivetune.ui.utils.resetHeightOffset
import moe.rukamori.archivetune.utils.rememberEnumPreference
import moe.rukamori.archivetune.utils.rememberPreference
@@ -107,13 +110,14 @@ fun LibraryScreen(
val (showTagsInLibrary) = rememberPreference(ShowTagsInLibraryKey, defaultValue = true)
val (showSpotifyPlaylists) = rememberPreference(ShowSpotifyPlaylistsKey, defaultValue = false)
val (disableBlur) = rememberPreference(DisableBlurKey, false)
- val (libraryChipOrderPreference) =
+ val (libraryChipOrderPreference, onLibraryChipOrderPreferenceChange) =
rememberPreference(
LibraryChipOrderKey,
defaultValue = DefaultLibraryFilterOrderPreference,
)
val (playlistTagOrderPreference) = rememberPreference(PlaylistTagOrderKey, defaultValue = "")
var showTagsManagementDialog by rememberSaveable { mutableStateOf(false) }
+ var showLibraryChipOrderDialog by rememberSaveable { mutableStateOf(false) }
val activeSelectedTagIds = if (showTagsInLibrary) selectedTagIds else emptySet()
val orderedTags =
remember(allTags, playlistTagOrderPreference) {
@@ -135,6 +139,17 @@ fun LibraryScreen(
)
}
+ if (showLibraryChipOrderDialog) {
+ LibraryChipOrderDialog(
+ initialOrder = libraryChipOrderPreference.toLibraryFilterOrder(),
+ onDismiss = { showLibraryChipOrderDialog = false },
+ onConfirm = { order ->
+ onLibraryChipOrderPreferenceChange(order.toLibraryFilterPreference())
+ showLibraryChipOrderDialog = false
+ },
+ )
+ }
+
val pagerState =
rememberPagerState(
initialPage = libraryFilters.indexOf(defaultFilter).takeIf { it >= 0 } ?: 0,
@@ -214,6 +229,7 @@ fun LibraryScreen(
val tabWidth =
when (targetFilter) {
LibraryFilter.LIBRARY -> 116.dp
+ LibraryFilter.LOCAL -> 112.dp
LibraryFilter.PLAYLISTS -> 132.dp
LibraryFilter.PODCASTS -> 126.dp
LibraryFilter.SPOTIFY -> 168.dp
@@ -266,6 +282,10 @@ fun LibraryScreen(
)
}
+ LibraryFilter.LOCAL -> {
+ LibraryLocalSongsScreen(navController = navController)
+ }
+
LibraryFilter.PLAYLISTS -> {
LibraryPlaylistsScreen(
navController = navController,
@@ -348,6 +368,7 @@ fun LibraryScreen(
val label =
when (filter) {
LibraryFilter.LIBRARY -> stringResource(R.string.filter_library)
+ LibraryFilter.LOCAL -> stringResource(R.string.local_songs_tab)
LibraryFilter.PLAYLISTS -> stringResource(R.string.playlists)
LibraryFilter.PODCASTS -> stringResource(R.string.podcast)
LibraryFilter.SPOTIFY -> stringResource(R.string.spotify_playlists)
@@ -358,6 +379,7 @@ fun LibraryScreen(
val iconRes =
when (filter) {
LibraryFilter.LIBRARY -> R.drawable.graphic_eq
+ LibraryFilter.LOCAL -> R.drawable.music_note
LibraryFilter.PLAYLISTS -> R.drawable.queue_music
LibraryFilter.PODCASTS -> R.drawable.mic
LibraryFilter.SPOTIFY -> R.drawable.spotify_icon
@@ -374,6 +396,7 @@ fun LibraryScreen(
pagerState.animateScrollToPage(page)
}
},
+ onLongClick = { showLibraryChipOrderDialog = true },
)
}
}
@@ -543,6 +566,7 @@ fun ExpressiveTabChip(
iconRes: Int,
selected: Boolean,
onClick: () -> Unit,
+ onLongClick: () -> Unit,
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
@@ -590,10 +614,11 @@ fun ExpressiveTabChip(
scaleY = scale
}.clip(CircleShape)
.background(bgColor)
- .clickable(
+ .combinedClickable(
interactionSource = interactionSource,
indication = null,
onClick = onClick,
+ onLongClick = onLongClick,
).padding(horizontal = 18.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibrarySpotifyPlaylistsScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibrarySpotifyPlaylistsScreen.kt
index 799f57a956..57a013fc9d 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibrarySpotifyPlaylistsScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LibrarySpotifyPlaylistsScreen.kt
@@ -19,16 +19,15 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import moe.rukamori.archivetune.LocalPlayerAwareWindowInsets
-import moe.rukamori.archivetune.R
import moe.rukamori.archivetune.spotify.SpotifyLibraryViewModel
import moe.rukamori.archivetune.ui.component.ExpressivePullToRefreshBox
+import moe.rukamori.archivetune.ui.component.SpotifyLikedSongsListItem
import moe.rukamori.archivetune.ui.component.SpotifyLibraryPlaylistListItem
@Composable
@@ -38,7 +37,6 @@ fun LibrarySpotifyPlaylistsScreen(
) {
val playlists by viewModel.playlists.collectAsStateWithLifecycle()
val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
- val refreshPlaylists = remember(viewModel) { viewModel::refreshPlaylists }
val playerAwareBottomPadding =
LocalPlayerAwareWindowInsets.current
.only(WindowInsetsSides.Bottom)
@@ -63,14 +61,8 @@ fun LibrarySpotifyPlaylistsScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(),
) {
- if (playlists.isEmpty()) {
- item(key = "spotify_empty", contentType = "spotify_empty") {
- LibraryEmptyState(
- iconRes = R.drawable.spotify_icon,
- actionLabelRes = R.string.refresh,
- onAction = refreshPlaylists,
- )
- }
+ item(key = "spotify_liked_songs", contentType = "spotify_liked_songs") {
+ SpotifyLikedSongsListItem(navController = navController)
}
items(
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LocalSongScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LocalSongScreen.kt
index d7f9cb0f3e..dd862084f9 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LocalSongScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/library/LocalSongScreen.kt
@@ -26,6 +26,7 @@ import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -44,6 +45,7 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -71,6 +73,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -80,9 +83,12 @@ 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.draw.clip
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
@@ -92,6 +98,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -106,15 +113,18 @@ import moe.rukamori.archivetune.constants.LocalSongsIncludedFoldersKey
import moe.rukamori.archivetune.constants.LocalSongsMinDurationSecondsKey
import moe.rukamori.archivetune.constants.LocalSongsSortDescendingKey
import moe.rukamori.archivetune.constants.LocalSongsSortTypeKey
+import moe.rukamori.archivetune.constants.PureBlackKey
+import moe.rukamori.archivetune.db.entities.Song
import moe.rukamori.archivetune.extensions.toMediaItem
import moe.rukamori.archivetune.extensions.togglePlayPause
import moe.rukamori.archivetune.localmedia.LocalSongScanConfig
-import moe.rukamori.archivetune.localmedia.SupportedLocalAudio
import moe.rukamori.archivetune.playback.queues.ListQueue
+import moe.rukamori.archivetune.ui.component.ItemThumbnail
import moe.rukamori.archivetune.ui.component.LocalMenuState
import moe.rukamori.archivetune.ui.component.SongListItem
import moe.rukamori.archivetune.ui.component.SortHeader
import moe.rukamori.archivetune.ui.menu.SongMenu
+import moe.rukamori.archivetune.utils.makeTimeString
import moe.rukamori.archivetune.utils.rememberPreference
import moe.rukamori.archivetune.viewmodels.LocalSongsScanState
import moe.rukamori.archivetune.viewmodels.LocalSongsViewModel
@@ -132,6 +142,24 @@ import kotlin.math.roundToInt
fun LocalSongScreen(
navController: NavController,
viewModel: LocalSongsViewModel = hiltViewModel(),
+) {
+ LocalSongScreenContent(
+ navController = navController,
+ embeddedInLibrary = false,
+ viewModel = viewModel,
+ )
+}
+
+@OptIn(
+ ExperimentalFoundationApi::class,
+ ExperimentalMaterial3Api::class,
+ ExperimentalMaterial3ExpressiveApi::class,
+)
+@Composable
+internal fun LocalSongScreenContent(
+ navController: NavController,
+ embeddedInLibrary: Boolean,
+ viewModel: LocalSongsViewModel,
) {
val context = LocalContext.current
val haptic = LocalHapticFeedback.current
@@ -140,15 +168,18 @@ fun LocalSongScreen(
val isPlaying by playerConnection.isPlaying.collectAsState()
val mediaMetadata by playerConnection.mediaMetadata.collectAsState()
val songs by viewModel.songs.collectAsStateWithLifecycle()
+ val playCounts by viewModel.playCounts.collectAsStateWithLifecycle()
val scanState by viewModel.scanState.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
+ val isDarkTheme = androidx.compose.foundation.isSystemInDarkTheme()
+ val (pureBlack) = rememberPreference(PureBlackKey, defaultValue = false)
val scanSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var showScanSheet by rememberSaveable { mutableStateOf(false) }
var isSearchActive by rememberSaveable { mutableStateOf(false) }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
var query by rememberSaveable { mutableStateOf("") }
- val (sortDescending, onSortDescendingChange) = rememberPreference(LocalSongsSortDescendingKey, true)
- val (sortTypeName, onSortTypeNameChange) = rememberPreference(LocalSongsSortTypeKey, LocalSongSortType.MODIFIED.name)
+ val (sortDescending, onSortDescendingChange) = rememberPreference(LocalSongsSortDescendingKey, false)
+ val (sortTypeName, onSortTypeNameChange) = rememberPreference(LocalSongsSortTypeKey, LocalSongSortType.CREATED_AT.name)
val (minimumDurationSeconds, onMinimumDurationSecondsChange) =
rememberPreference(
LocalSongsMinDurationSecondsKey,
@@ -189,6 +220,12 @@ fun LocalSongScreen(
)
}
+ LaunchedEffect(viewModel, hasStoragePermission, scanConfig) {
+ if (hasStoragePermission) {
+ viewModel.scanDevice(scanConfig)
+ }
+ }
+
val permissionLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
@@ -236,18 +273,17 @@ fun LocalSongScreen(
}
val bottomContentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues().calculateBottomPadding() + 20.dp
- val visibleSongs by remember(songs, query, sortType, sortDescending, collator) {
+ val visibleSongs by remember(songs, playCounts, query, sortType, sortDescending, collator, minimumDurationSeconds) {
derivedStateOf {
val normalizedQuery = query.trim()
- val supportedSongs =
- songs.filter { song ->
- SupportedLocalAudio.isSupportedMimeType(song.format?.mimeType)
- }
val filteredSongs =
- if (normalizedQuery.isBlank()) {
- supportedSongs
- } else {
- supportedSongs.filter { song ->
+ songs.filter { song ->
+ if (minimumDurationSeconds > 0 && song.song.duration in 1 until minimumDurationSeconds) {
+ return@filter false
+ }
+ if (normalizedQuery.isBlank()) {
+ true
+ } else {
song.song.title.contains(normalizedQuery, ignoreCase = true) ||
song.song.albumName
.orEmpty()
@@ -259,8 +295,8 @@ fun LocalSongScreen(
val sortedSongs =
when (sortType) {
LocalSongSortType.MODIFIED -> {
- filteredSongs.sortedBy { song ->
- song.song.dateModified ?: LocalDateTime.MIN
+ filteredSongs.sortedByDescending { song ->
+ song.song.dateModified ?: song.song.dateAdded ?: song.song.dateDownload ?: LocalDateTime.MIN
}
}
@@ -281,6 +317,23 @@ fun LocalSongScreen(
compareBy(collator) { song -> song.song.albumName.orEmpty() },
)
}
+
+ //? ================== ADDING SORTING BY FILE CREATION AND PLAYED COUNT AND PLAY DURATION =======================
+ //* SORT BY FILE CREATION DATE (Default starts with newest item)
+ LocalSongSortType.CREATED_AT -> {
+ filteredSongs.sortedByDescending { song ->
+ song.song.dateAdded ?: song.song.dateDownload ?: song.song.dateModified ?: LocalDateTime.MIN
+ }
+ }
+ //* SORT BY PLAYED COUNT
+ LocalSongSortType.PLAYED_COUNT -> {
+ filteredSongs.sortedByDescending { song -> playCounts[song.id] ?: 0 }
+ }
+
+ //* SORT BY PLAY DURATION
+ LocalSongSortType.PLAYED_DURATION -> {
+ filteredSongs.sortedByDescending { song -> song.song.totalPlayTime }
+ }
}
if (sortDescending) sortedSongs.asReversed() else sortedSongs
@@ -317,104 +370,117 @@ fun LocalSongScreen(
modifier =
Modifier
.fillMaxSize()
- .nestedScroll(scrollBehavior.nestedScrollConnection),
- containerColor = MaterialTheme.colorScheme.surface,
+ .then(
+ if (embeddedInLibrary) {
+ Modifier
+ } else {
+ Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
+ },
+ ),
+ containerColor =
+ if (embeddedInLibrary) {
+ Color.Transparent
+ } else {
+ MaterialTheme.colorScheme.surface
+ },
topBar = {
- AnimatedContent(
- targetState = isSearchActive,
- transitionSpec = {
- fadeIn(spring(stiffness = Spring.StiffnessMediumLow)) togetherWith
- fadeOut(spring(stiffness = Spring.StiffnessMediumLow))
- },
- label = "localSongTopBar",
- ) { searching ->
- if (searching) {
- SearchBar(
- inputField = {
- SearchBarDefaults.InputField(
- query = query,
- onQueryChange = { query = it },
- onSearch = { isSearchActive = false },
- expanded = false,
- onExpandedChange = {},
- placeholder = {
- Text(text = stringResource(R.string.search_library))
- },
- leadingIcon = {
- IconButton(
- onClick = {
- query = ""
- isSearchActive = false
- },
- ) {
- Icon(
- painter = painterResource(R.drawable.arrow_back),
- contentDescription = stringResource(R.string.back_button_desc),
- )
- }
- },
- trailingIcon =
- if (query.isNotEmpty()) {
- {
- IconButton(onClick = { query = "" }) {
- Icon(
- painter = painterResource(R.drawable.close),
- contentDescription = stringResource(R.string.close),
- )
- }
+ if (!embeddedInLibrary) {
+ AnimatedContent(
+ targetState = isSearchActive,
+ transitionSpec = {
+ fadeIn(spring(stiffness = Spring.StiffnessMediumLow)) togetherWith
+ fadeOut(spring(stiffness = Spring.StiffnessMediumLow))
+ },
+ label = "localSongTopBar",
+ ) { searching ->
+ if (searching) {
+ SearchBar(
+ inputField = {
+ SearchBarDefaults.InputField(
+ query = query,
+ onQueryChange = { query = it },
+ onSearch = { isSearchActive = false },
+ expanded = false,
+ onExpandedChange = {},
+ placeholder = {
+ Text(text = stringResource(R.string.search_library))
+ },
+ leadingIcon = {
+ IconButton(
+ onClick = {
+ query = ""
+ isSearchActive = false
+ },
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.arrow_back),
+ contentDescription = stringResource(R.string.back_button_desc),
+ )
}
- } else {
- null
},
- )
- },
- expanded = false,
- onExpandedChange = {},
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp)
- .padding(top = 8.dp, bottom = 4.dp),
- ) {}
- } else {
- LargeFlexibleTopAppBar(
- title = {
- Text(
- text = stringResource(R.string.local_history),
- fontWeight = FontWeight.Bold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- },
- navigationIcon = {
- IconButton(onClick = navController::navigateUp) {
- Icon(
- painter = painterResource(R.drawable.arrow_back),
- contentDescription = null,
- )
- }
- },
- actions = {
- IconButton(onClick = { isSearchActive = true }) {
- Icon(
- painter = painterResource(R.drawable.search),
- contentDescription = stringResource(R.string.search),
+ trailingIcon =
+ if (query.isNotEmpty()) {
+ {
+ IconButton(onClick = { query = "" }) {
+ Icon(
+ painter = painterResource(R.drawable.close),
+ contentDescription = stringResource(R.string.close),
+ )
+ }
+ }
+ } else {
+ null
+ },
)
- }
- IconButton(onClick = { showScanSheet = true }) {
- Icon(
- painter = painterResource(R.drawable.settings),
- contentDescription = stringResource(R.string.settings),
+ },
+ expanded = false,
+ onExpandedChange = {},
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(top = 8.dp, bottom = 4.dp),
+ ) {}
+ } else {
+ LargeFlexibleTopAppBar(
+ title = {
+ Text(
+ text = stringResource(R.string.local_history),
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
)
- }
- },
- colors =
- TopAppBarDefaults.largeTopAppBarColors(
- containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
- scrolledContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f),
- ),
- scrollBehavior = scrollBehavior,
- )
+ },
+ navigationIcon = {
+ IconButton(onClick = navController::navigateUp) {
+ Icon(
+ painter = painterResource(R.drawable.arrow_back),
+ contentDescription = null,
+ )
+ }
+ },
+ actions = {
+ IconButton(onClick = { isSearchActive = true }) {
+ Icon(
+ painter = painterResource(R.drawable.search),
+ contentDescription = stringResource(R.string.search),
+ )
+ }
+ IconButton(onClick = { showScanSheet = true }) {
+ Icon(
+ painter = painterResource(R.drawable.settings),
+ contentDescription = stringResource(R.string.settings),
+ )
+ }
+ },
+ colors =
+ TopAppBarDefaults.largeTopAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
+ scrolledContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f),
+ ),
+ scrollBehavior = scrollBehavior,
+ )
+ }
}
}
},
@@ -425,40 +491,62 @@ fun LocalSongScreen(
Modifier
.fillMaxSize()
.padding(paddingValues),
- verticalArrangement = Arrangement.spacedBy(14.dp),
+ verticalArrangement = Arrangement.spacedBy(if (embeddedInLibrary) 0.dp else 14.dp),
contentPadding =
PaddingValues(
- top = 12.dp,
+ start = if (embeddedInLibrary) 24.dp else 0.dp,
+ top = if (embeddedInLibrary) 64.dp else 12.dp,
+ end = if (embeddedInLibrary) 24.dp else 0.dp,
bottom = bottomContentPadding,
),
) {
- item(
- key = "controls",
- contentType = CONTENT_TYPE_HEADER,
- ) {
- LocalSongControlsCard(
- sortType = sortType,
- sortDescending = sortDescending,
- visibleSongCount = visibleSongs.size,
- shuffleEnabled = queueItems.isNotEmpty(),
- onSortTypeChange = { onSortTypeNameChange(it.name) },
- onSortDescendingChange = onSortDescendingChange,
- onShuffleClick = {
- if (queueItems.isNotEmpty()) {
- playerConnection.playQueue(
- ListQueue(
- title =
- if (query.isBlank()) {
- context.getString(R.string.local_history)
- } else {
- context.getString(R.string.queue_searched_songs)
- },
- items = queueItems.shuffled(),
- ),
- )
- }
- },
- )
+ if (!embeddedInLibrary) {
+ item(
+ key = "controls",
+ contentType = CONTENT_TYPE_HEADER,
+ ) {
+ LocalSongControlsCard(
+ sortType = sortType,
+ sortDescending = sortDescending,
+ visibleSongCount = visibleSongs.size,
+ shuffleEnabled = queueItems.isNotEmpty(),
+ onSortTypeChange = { onSortTypeNameChange(it.name) },
+ onSortDescendingChange = onSortDescendingChange,
+ onShuffleClick = {
+ if (queueItems.isNotEmpty()) {
+ playerConnection.playQueue(
+ ListQueue(
+ title =
+ if (query.isBlank()) {
+ context.getString(R.string.local_history)
+ } else {
+ context.getString(R.string.queue_searched_songs)
+ },
+ items = queueItems.shuffled(),
+ ),
+ )
+ }
+ },
+ )
+ }
+ }
+
+ if (embeddedInLibrary) {
+ item(
+ key = "embedded_controls",
+ contentType = CONTENT_TYPE_HEADER,
+ ) {
+ LocalSongControlsCard(
+ sortType = sortType,
+ sortDescending = sortDescending,
+ visibleSongCount = visibleSongs.size,
+ shuffleEnabled = false,
+ onSortTypeChange = { onSortTypeNameChange(it.name) },
+ onSortDescendingChange = onSortDescendingChange,
+ onShuffleClick = {},
+ compact = true,
+ )
+ }
}
if (visibleSongs.isEmpty()) {
@@ -469,11 +557,13 @@ fun LocalSongScreen(
LocalSongEmptyState(query = query)
}
} else {
- item(
- key = "divider",
- contentType = CONTENT_TYPE_HEADER,
- ) {
- HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ if (!embeddedInLibrary) {
+ item(
+ key = "divider",
+ contentType = CONTENT_TYPE_HEADER,
+ ) {
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ }
}
itemsIndexed(
@@ -481,72 +571,220 @@ fun LocalSongScreen(
key = { _, item -> item.id },
contentType = { _, _ -> CONTENT_TYPE_SONG },
) { index, song ->
- SongListItem(
- song = song,
- showInLibraryIcon = false,
- showDownloadIcon = false,
- showSongIconPlaceholder = true,
- isActive = song.id == mediaMetadata?.id,
- isPlaying = isPlaying,
- trailingContent = {
- IconButton(
- onClick = {
- menuState.show {
- SongMenu(
- originalSong = song,
- navController = navController,
- onDismiss = menuState::dismiss,
- )
- }
- },
- ) {
- Icon(
- painter = painterResource(R.drawable.more_vert),
- contentDescription = null,
- )
- }
- },
- modifier =
- Modifier
- .fillMaxWidth()
- .combinedClickable(
- onClick = {
- if (song.id == mediaMetadata?.id) {
- playerConnection.player.togglePlayPause()
+ val isActive = song.id == mediaMetadata?.id
+ val onClick = {
+ if (isActive) {
+ playerConnection.player.togglePlayPause()
+ } else {
+ playerConnection.playQueue(
+ ListQueue(
+ title =
+ if (query.isBlank()) {
+ context.getString(R.string.local_history)
} else {
- playerConnection.playQueue(
- ListQueue(
- title =
- if (query.isBlank()) {
- context.getString(R.string.local_history)
- } else {
- context.getString(R.string.queue_searched_songs)
- },
- items = queueItems,
- startIndex = index,
- ),
- )
- }
- },
- onLongClick = {
- haptic.performHapticFeedback(HapticFeedbackType.LongPress)
- menuState.show {
- SongMenu(
- originalSong = song,
- navController = navController,
- onDismiss = menuState::dismiss,
- )
- }
- },
- ).padding(horizontal = 16.dp)
- .animateItem(),
- )
+ context.getString(R.string.queue_searched_songs)
+ },
+ items = queueItems,
+ startIndex = index,
+ ),
+ )
+ }
+ }
+ val onMenuClick = {
+ menuState.show {
+ SongMenu(
+ originalSong = song,
+ navController = navController,
+ onDismiss = menuState::dismiss,
+ )
+ }
+ }
+
+ if (embeddedInLibrary) {
+ LibraryLocalSongCard(
+ song = song,
+ index = index,
+ isActive = isActive,
+ isPlaying = isPlaying,
+ isDarkTheme = isDarkTheme,
+ pureBlack = pureBlack,
+ onClick = onClick,
+ onLongClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ onMenuClick()
+ },
+ onMenuClick = onMenuClick,
+ modifier = Modifier.animateItem(),
+ )
+ } else {
+ SongListItem(
+ song = song,
+ showInLibraryIcon = false,
+ showDownloadIcon = false,
+ showSongIconPlaceholder = true,
+ isActive = isActive,
+ isPlaying = isPlaying,
+ trailingContent = {
+ IconButton(onClick = onMenuClick) {
+ Icon(
+ painter = painterResource(R.drawable.more_vert),
+ contentDescription = null,
+ )
+ }
+ },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = onClick,
+ onLongClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ onMenuClick()
+ },
+ ).padding(horizontal = 16.dp)
+ .animateItem(),
+ )
+ }
}
}
}
}
}
+@Composable
+private fun LibraryLocalSongCard(
+ song: Song,
+ index: Int,
+ isActive: Boolean,
+ isPlaying: Boolean,
+ isDarkTheme: Boolean,
+ pureBlack: Boolean,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+ onMenuClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val activeCardColor =
+ rememberArtworkCardColor(
+ thumbnailUrl = song.song.thumbnailUrl,
+ fallbackColor = MaterialTheme.colorScheme.primaryContainer,
+ )
+ val showDivider = isDarkTheme && pureBlack && index > 0
+
+ if (showDivider) {
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.15f),
+ thickness = 0.5.dp,
+ )
+ }
+
+ Row(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .padding(top = if (index == 0 || showDivider) 0.dp else 8.dp)
+ .clip(RoundedCornerShape(if (isActive) 36.dp else 24.dp))
+ .background(
+ if (isActive) activeCardColor else MaterialTheme.colorScheme.surfaceContainerLow,
+ ).combinedClickable(
+ onClick = onClick,
+ onLongClick = onLongClick,
+ ).padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ItemThumbnail(
+ thumbnailUrl = song.song.thumbnailUrl,
+ isActive = isActive,
+ isPlaying = isPlaying,
+ shape = RoundedCornerShape(if (isActive) 26.dp else 10.dp),
+ contentScale = ContentScale.Crop,
+ showPlaceholder = true,
+ modifier = Modifier.size(52.dp),
+ )
+
+ Spacer(modifier = Modifier.width(14.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = song.song.title,
+ style =
+ MaterialTheme.typography.bodyLarge.copy(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 16.sp,
+ ),
+ color = if (isActive) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = song.artists.joinToString(", ") { it.name },
+ style = MaterialTheme.typography.bodySmall,
+ color =
+ if (isActive) {
+ MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.75f)
+ } else {
+ MaterialTheme.colorScheme.onSurface.copy(alpha = 0.60f)
+ },
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ if (isActive && isPlaying) {
+ Icon(
+ painter = painterResource(R.drawable.graphic_eq),
+ contentDescription = stringResource(R.string.playing_desc),
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(16.dp),
+ )
+ }
+
+ Box(
+ modifier =
+ Modifier
+ .clip(CircleShape)
+ .background(
+ MaterialTheme.colorScheme.surfaceVariant.copy(
+ alpha = if (isActive) 0.5f else 0.8f,
+ ),
+ ).padding(horizontal = 6.dp, vertical = 2.dp),
+ ) {
+ Text(
+ text = makeTimeString(song.song.duration * 1000L),
+ style =
+ MaterialTheme.typography.labelSmall.copy(
+ fontSize = 9.sp,
+ fontWeight = FontWeight.Bold,
+ ),
+ color =
+ if (isActive) {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ }
+
+ IconButton(
+ onClick = onMenuClick,
+ modifier = Modifier.size(24.dp),
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.more_vert),
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ )
+ }
+ }
+ }
+}
+
@Composable
private fun LocalSongBadge(
iconRes: Int,
@@ -587,13 +825,14 @@ private fun LocalSongControlsCard(
onSortTypeChange: (LocalSongSortType) -> Unit,
onSortDescendingChange: (Boolean) -> Unit,
onShuffleClick: () -> Unit,
+ compact: Boolean = false,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
- .padding(horizontal = 28.dp, vertical = 8.dp),
+ .padding(horizontal = if (compact) 8.dp else 28.dp, vertical = if (compact) 0.dp else 8.dp),
) {
SortHeader(
sortType = sortType,
@@ -606,27 +845,33 @@ private fun LocalSongControlsCard(
LocalSongSortType.NAME -> R.string.sort_by_name
LocalSongSortType.ARTIST -> R.string.sort_by_artist
LocalSongSortType.ALBUM -> R.string.sort_by_album
+ LocalSongSortType.PLAYED_COUNT -> R.string.sort_by_most_played
+ LocalSongSortType.CREATED_AT -> R.string.sort_by_create_date
+ LocalSongSortType.PLAYED_DURATION -> R.string.sort_by_play_time
}
},
+ compact = compact,
)
- Spacer(modifier = Modifier.width(8.dp))
+ if (!compact) {
+ Spacer(modifier = Modifier.width(8.dp))
- IconButton(
- onClick = onShuffleClick,
- enabled = shuffleEnabled,
- ) {
- Icon(
- painter = painterResource(R.drawable.shuffle),
- contentDescription = stringResource(R.string.shuffle),
- )
+ IconButton(
+ onClick = onShuffleClick,
+ enabled = shuffleEnabled,
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.shuffle),
+ contentDescription = stringResource(R.string.shuffle),
+ )
+ }
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = pluralStringResource(R.plurals.n_song, visibleSongCount, visibleSongCount),
- style = MaterialTheme.typography.titleSmall,
+ style = if (compact) MaterialTheme.typography.labelMedium else MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.secondary,
)
}
@@ -1404,6 +1649,9 @@ private fun Uri.toFolderEntry(): String? {
private enum class LocalSongSortType {
MODIFIED,
+ CREATED_AT,
+ PLAYED_COUNT,
+ PLAYED_DURATION,
NAME,
ARTIST,
ALBUM,
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/musicrecognition/MusicRecognitionScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/musicrecognition/MusicRecognitionScreen.kt
index 53dafd79de..ad6a3f4e02 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/musicrecognition/MusicRecognitionScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/musicrecognition/MusicRecognitionScreen.kt
@@ -39,7 +39,6 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
@@ -103,6 +102,7 @@ import androidx.window.core.layout.WindowSizeClass
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.allowHardware
+import moe.rukamori.archivetune.LocalPlayerAwareWindowInsets
import moe.rukamori.archivetune.R
import moe.rukamori.archivetune.ui.component.SwitchPreference
import moe.rukamori.archivetune.ui.screens.search.onlineSearchResultRoute
@@ -296,7 +296,7 @@ private fun MusicRecognitionContent(
)
},
containerColor = MaterialTheme.colorScheme.surface,
- contentWindowInsets = WindowInsets.safeDrawing,
+ contentWindowInsets = LocalPlayerAwareWindowInsets.current,
) { contentPadding ->
Box(
modifier =
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/onboarding/OnboardingScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/onboarding/OnboardingScreen.kt
index a1a2632847..68e97c7d6c 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/onboarding/OnboardingScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/onboarding/OnboardingScreen.kt
@@ -889,26 +889,39 @@ private fun PermissionRow(
.heightIn(min = 88.dp),
colors = ListItemDefaults.segmentedColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
- leadingContent = {
- PermissionIcon(permission = permission)
- },
- supportingContent = {
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ PermissionIcon(permission = permission)
+ Text(
+ text = stringResource(permission.titleResId),
+ modifier = Modifier.weight(1f),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
Text(
text = stringResource(permission.descriptionResId),
+ modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
- },
- trailingContent = {
- PermissionStatusAction(
- permission = permission,
- onPermissionAction = onPermissionAction,
- )
- },
- ) {
- Text(
- text = stringResource(permission.titleResId),
- style = MaterialTheme.typography.titleMedium,
- )
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.CenterEnd,
+ ) {
+ PermissionStatusAction(
+ permission = permission,
+ onPermissionAction = onPermissionAction,
+ )
+ }
+ }
}
}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/playlist/OnlinePlaylistScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/playlist/OnlinePlaylistScreen.kt
index 73abbeb8cc..974fb81190 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/playlist/OnlinePlaylistScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/playlist/OnlinePlaylistScreen.kt
@@ -123,6 +123,7 @@ import moe.rukamori.archivetune.ui.utils.ItemWrapper
import moe.rukamori.archivetune.ui.utils.backToMain
import moe.rukamori.archivetune.ui.utils.formatCompactCount
import moe.rukamori.archivetune.ui.utils.headerDownloadState
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.ui.utils.sendAddMissingDownloads
import moe.rukamori.archivetune.ui.utils.sendRemoveDownloads
import moe.rukamori.archivetune.utils.rememberPreference
@@ -381,9 +382,7 @@ fun OnlinePlaylistScreen(
if (artist.id != null) {
withLink(
LinkAnnotation.Clickable(artist.id!!) {
- navController.navigate(
- "artist/${artist.id}",
- )
+ navController.navigateToArtist(artist.id)
},
) {
append(artist.name)
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/playlist/SpotifyLikedSongsScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/playlist/SpotifyLikedSongsScreen.kt
new file mode 100644
index 0000000000..1daea1ecdb
--- /dev/null
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/playlist/SpotifyLikedSongsScreen.kt
@@ -0,0 +1,311 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package moe.rukamori.archivetune.ui.screens.playlist
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.layout.union
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.CircularWavyProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.NavController
+import com.google.common.collect.ImmutableList
+import moe.rukamori.archivetune.LocalPlayerAwareWindowInsets
+import moe.rukamori.archivetune.LocalPlayerConnection
+import moe.rukamori.archivetune.R
+import moe.rukamori.archivetune.constants.ListThumbnailSize
+import moe.rukamori.archivetune.constants.ThumbnailCornerRadius
+import moe.rukamori.archivetune.spotify.SpotifyLikedSongUiModel
+import moe.rukamori.archivetune.spotify.SpotifyLikedSongsEvent
+import moe.rukamori.archivetune.spotify.SpotifyLikedSongsScreenState
+import moe.rukamori.archivetune.spotify.SpotifyLikedSongsViewModel
+import moe.rukamori.archivetune.ui.component.EmptyPlaceholder
+import moe.rukamori.archivetune.ui.component.ExpressivePullToRefreshBox
+import moe.rukamori.archivetune.ui.component.IconButton
+import moe.rukamori.archivetune.ui.component.ItemThumbnail
+import moe.rukamori.archivetune.ui.component.ListItem
+import moe.rukamori.archivetune.ui.component.MediaDetailHero
+import moe.rukamori.archivetune.ui.component.MediaDetailIconAction
+import moe.rukamori.archivetune.ui.utils.backToMain
+import moe.rukamori.archivetune.utils.joinByBullet
+import moe.rukamori.archivetune.utils.makeTimeString
+
+@Composable
+fun SpotifyLikedSongsScreen(
+ navController: NavController,
+ scrollBehavior: TopAppBarScrollBehavior,
+ viewModel: SpotifyLikedSongsViewModel = hiltViewModel(),
+) {
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ val playerConnection = LocalPlayerConnection.current
+ val likedSongsTitle = stringResource(R.string.liked_songs)
+
+ LaunchedEffect(viewModel, playerConnection) {
+ viewModel.events.collect { event ->
+ when (event) {
+ is SpotifyLikedSongsEvent.PlaybackRequested -> {
+ playerConnection?.playQueue(
+ event.queue,
+ )
+ }
+ }
+ }
+ }
+
+ SpotifyLikedSongsContent(
+ state = state,
+ title = likedSongsTitle,
+ scrollBehavior = scrollBehavior,
+ onNavigateUp = { navController.navigateUp() },
+ onNavigateHome = { navController.backToMain() },
+ onRefresh = viewModel::reload,
+ onPlayAll = viewModel::playAll,
+ onPlaySong = viewModel::playSong,
+ )
+}
+
+@Composable
+private fun SpotifyLikedSongsContent(
+ state: SpotifyLikedSongsScreenState,
+ title: String,
+ scrollBehavior: TopAppBarScrollBehavior,
+ onNavigateUp: () -> Unit,
+ onNavigateHome: () -> Unit,
+ onRefresh: () -> Unit,
+ onPlayAll: (Boolean) -> Unit,
+ onPlaySong: (Int) -> Unit,
+) {
+ val songs: ImmutableList =
+ (state as? SpotifyLikedSongsScreenState.Success)?.songs ?: ImmutableList.of()
+ val lazyListState = rememberLazyListState()
+ val showTopBarTitle by remember { derivedStateOf { lazyListState.firstVisibleItemIndex > 0 } }
+ val systemBarsTopPadding = WindowInsets.systemBars.asPaddingValues().calculateTopPadding()
+ val isLoading = state is SpotifyLikedSongsScreenState.Loading
+ val surfaceColor = MaterialTheme.colorScheme.surface
+ val canPlay = songs.isNotEmpty()
+ val onShuffle: (() -> Unit)? =
+ remember(canPlay, onPlayAll) {
+ if (canPlay) {
+ { onPlayAll(true) }
+ } else {
+ null
+ }
+ }
+ val onPlay: (() -> Unit)? =
+ remember(canPlay, onPlayAll) {
+ if (canPlay) {
+ { onPlayAll(false) }
+ } else {
+ null
+ }
+ }
+ val metadata =
+ if (state is SpotifyLikedSongsScreenState.Success) {
+ pluralStringResource(R.plurals.n_song, songs.size, songs.size)
+ } else {
+ null
+ }
+
+ ExpressivePullToRefreshBox(
+ isRefreshing = isLoading,
+ onRefresh = onRefresh,
+ modifier = Modifier.fillMaxSize().background(surfaceColor),
+ ) {
+ LazyColumn(
+ state = lazyListState,
+ contentPadding =
+ PaddingValues(
+ bottom =
+ LocalPlayerAwareWindowInsets.current
+ .union(WindowInsets.systemBars)
+ .asPaddingValues()
+ .calculateBottomPadding(),
+ ),
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ item(key = "header", contentType = "spotify_liked_songs_header") {
+ MediaDetailHero(
+ title = title,
+ thumbnailUrl = null,
+ fallbackIcon = R.drawable.favorite,
+ systemBarsTopPadding = systemBarsTopPadding,
+ metadata = metadata,
+ isAdded = false,
+ addContentDescription = R.string.add_to_library,
+ removeContentDescription = R.string.remove_from_library,
+ onShuffle = onShuffle,
+ onPlay = onPlay,
+ onToggleAdd = null,
+ additionalPrimaryActions = { contentColor ->
+ MediaDetailIconAction(
+ icon = R.drawable.sync,
+ contentDescription = R.string.spotify_reload_playlist,
+ contentColor = contentColor,
+ onClick = onRefresh,
+ )
+ },
+ )
+ }
+
+ when (state) {
+ SpotifyLikedSongsScreenState.Loading -> {
+ item(key = "loading", contentType = "loading") {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxWidth().height(160.dp),
+ ) {
+ CircularWavyProgressIndicator()
+ }
+ }
+ }
+
+ SpotifyLikedSongsScreenState.Empty -> {
+ item(key = "empty", contentType = "empty") {
+ EmptyPlaceholder(
+ icon = R.drawable.music_note,
+ text = stringResource(R.string.spotify_no_tracks),
+ )
+ }
+ }
+
+ SpotifyLikedSongsScreenState.Error -> {
+ item(key = "error", contentType = "error") {
+ EmptyPlaceholder(
+ icon = R.drawable.spotify_icon,
+ text = stringResource(R.string.error_unknown),
+ )
+ }
+ }
+
+ is SpotifyLikedSongsScreenState.Success -> {
+ itemsIndexed(
+ items = songs,
+ key = { index, song -> "${song.id}_$index" },
+ contentType = { _, _ -> "spotify_liked_song" },
+ ) { index, song ->
+ val onSongClick = remember(index, onPlaySong) { { onPlaySong(index) } }
+ SpotifyLikedSongListItem(
+ song = song,
+ onClick = onSongClick,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ }
+ }
+
+ TopAppBar(
+ colors =
+ if (showTopBarTitle) {
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ scrolledContainerColor = MaterialTheme.colorScheme.surface,
+ )
+ } else {
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = Color.Transparent,
+ scrolledContainerColor = Color.Transparent,
+ navigationIconContentColor = Color.White,
+ titleContentColor = Color.White,
+ )
+ },
+ title = {
+ if (showTopBarTitle) {
+ Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ },
+ navigationIcon = {
+ IconButton(onClick = onNavigateUp, onLongClick = onNavigateHome) {
+ Icon(
+ painter = painterResource(R.drawable.arrow_back),
+ contentDescription = null,
+ )
+ }
+ },
+ scrollBehavior = scrollBehavior,
+ )
+ }
+}
+
+@Composable
+private fun SpotifyLikedSongListItem(
+ song: SpotifyLikedSongUiModel,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val duration =
+ remember(song.durationMs) {
+ song.durationMs.takeIf { it > 0 }?.toLong()?.let(::makeTimeString)
+ }
+ val subtitle = remember(song.artistNames, duration) { joinByBullet(song.artistNames.joinToString(), duration) }
+ val isExplicit = song.isExplicit
+ val badges: @Composable RowScope.() -> Unit =
+ remember(isExplicit) {
+ {
+ if (isExplicit) {
+ Icon(
+ painter = painterResource(R.drawable.explicit),
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ }
+ }
+
+ ListItem(
+ title = song.title,
+ subtitle = subtitle,
+ badges = badges,
+ thumbnailContent = {
+ ItemThumbnail(
+ thumbnailUrl = song.thumbnailUrl,
+ isActive = false,
+ isPlaying = false,
+ shape = androidx.compose.foundation.shape.RoundedCornerShape(ThumbnailCornerRadius),
+ placeholderIconRes = R.drawable.music_note,
+ modifier = Modifier.size(ListThumbnailSize),
+ )
+ },
+ modifier = modifier.clickable(onClick = onClick),
+ )
+}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/search/LocalSearchScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/search/LocalSearchScreen.kt
index 7e8b451496..89ad28ece2 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/search/LocalSearchScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/search/LocalSearchScreen.kt
@@ -44,6 +44,8 @@ import moe.rukamori.archivetune.extensions.togglePlayPause
import moe.rukamori.archivetune.playback.queues.ListQueue
import moe.rukamori.archivetune.ui.component.*
import moe.rukamori.archivetune.ui.menu.SongMenu
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.viewmodels.LocalFilter
import moe.rukamori.archivetune.viewmodels.LocalSearchViewModel
@@ -282,7 +284,7 @@ fun LocalSearchScreen(
Modifier
.clickable {
onDismiss()
- navController.navigate("album/${item.id}")
+ navController.navigateToAlbum(item.id)
}.animateItem(),
)
}
@@ -294,7 +296,7 @@ fun LocalSearchScreen(
Modifier
.clickable {
onDismiss()
- navController.navigate("artist/${item.id}")
+ navController.navigateToArtist(item.id)
}.animateItem(),
)
}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/search/OnlineSearchResult.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/search/OnlineSearchResult.kt
index 71b4d86b97..6316c069fa 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/search/OnlineSearchResult.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/search/OnlineSearchResult.kt
@@ -87,6 +87,7 @@ import moe.rukamori.archivetune.playback.queues.YouTubeQueue
import moe.rukamori.archivetune.ui.component.ChipsRow
import moe.rukamori.archivetune.ui.component.EmptyPlaceholder
import moe.rukamori.archivetune.ui.component.LocalMenuState
+import moe.rukamori.archivetune.ui.component.SongListItem
import moe.rukamori.archivetune.ui.component.YouTubeListItem
import moe.rukamori.archivetune.ui.component.shimmer.ListItemPlaceHolder
import moe.rukamori.archivetune.ui.component.shimmer.ShimmerHost
@@ -94,6 +95,8 @@ import moe.rukamori.archivetune.ui.menu.YouTubeAlbumMenu
import moe.rukamori.archivetune.ui.menu.YouTubeArtistMenu
import moe.rukamori.archivetune.ui.menu.YouTubePlaylistMenu
import moe.rukamori.archivetune.ui.menu.YouTubeSongMenu
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.viewmodels.OnlineSearchSort
import moe.rukamori.archivetune.viewmodels.OnlineSearchViewModel
@@ -114,6 +117,9 @@ fun OnlineSearchResult(
val lazyListState = rememberLazyListState()
val searchFilter by viewModel.filter.collectAsStateWithLifecycle()
+ val localSongs by viewModel.localSongs.collectAsStateWithLifecycle()
+ val localQueueItems = remember(localSongs) { localSongs.map { it.toMediaItem() } }
+ val showLocalSongs = searchFilter == null || searchFilter == FILTER_SONG
val searchSummary = viewModel.summaryPage
val itemsPage by remember(searchFilter) {
derivedStateOf {
@@ -143,7 +149,7 @@ fun OnlineSearchResult(
?.items
?.takeIf { it.isNotEmpty() }
?.let { items ->
- add(SearchSummary(title = sectionTitle, items = viewModel.sortedItems(items, searchSort)))
+ add(SearchSummary(title = sectionTitle, items = items))
}
}
}
@@ -252,11 +258,11 @@ fun OnlineSearchResult(
}
is AlbumItem -> {
- navController.navigate("album/${item.id}")
+ navController.navigateToAlbum(item.id)
}
is ArtistItem -> {
- navController.navigate("artist/${item.id}")
+ navController.navigateToArtist(item.id)
}
is PlaylistItem -> {
@@ -341,6 +347,68 @@ fun OnlineSearchResult(
.asPaddingValues(),
modifier = Modifier.weight(1f),
) {
+ if (showLocalSongs && localSongs.isNotEmpty()) {
+ item(key = "local_results_header", contentType = "section_header") {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .width(3.dp)
+ .height(18.dp)
+ .clip(RoundedCornerShape(2.dp))
+ .background(MaterialTheme.colorScheme.primary),
+ )
+ Spacer(Modifier.width(10.dp))
+ Text(
+ text = stringResource(R.string.on_device),
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+
+ itemsIndexed(
+ items = localSongs,
+ key = { _, song -> "local_${song.id}" },
+ contentType = { _, _ -> "local_search_result" },
+ ) { index, song ->
+ SongListItem(
+ song = song,
+ isActive = mediaMetadata?.id == song.id,
+ isPlaying = isPlaying,
+ modifier =
+ Modifier
+ .combinedClickable(
+ onClick = {
+ if (mediaMetadata?.id == song.id) {
+ playerConnection.player.togglePlayPause()
+ } else {
+ playerConnection.playQueue(
+ ListQueue(
+ title = viewModel.query,
+ items = localQueueItems,
+ startIndex = index,
+ ),
+ )
+ }
+ },
+ onLongClick = null,
+ ).animateItem(),
+ )
+ }
+ item(key = "local_results_divider", contentType = "divider") {
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp),
+ thickness = 0.5.dp,
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
+ )
+ }
+ }
+
if (searchFilter == null) {
allModeSections.forEachIndexed { index, summary ->
if (index > 0) {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/search/OnlineSearchScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/search/OnlineSearchScreen.kt
index da43c76371..d0dde2f5d6 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/search/OnlineSearchScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/search/OnlineSearchScreen.kt
@@ -48,6 +48,8 @@ import moe.rukamori.archivetune.playback.queues.YouTubeQueue
import moe.rukamori.archivetune.ui.component.LocalMenuState
import moe.rukamori.archivetune.ui.component.YouTubeListItem
import moe.rukamori.archivetune.ui.menu.*
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.viewmodels.OnlineSearchSuggestionViewModel
@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@@ -297,12 +299,12 @@ fun OnlineSearchScreen(
}
is AlbumItem -> {
- navController.navigate("album/${item.id}")
+ navController.navigateToAlbum(item.id)
onDismiss()
}
is ArtistItem -> {
- navController.navigate("artist/${item.id}")
+ navController.navigateToArtist(item.id)
onDismiss()
}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/search/SearchScreen.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/search/SearchScreen.kt
index 914b3e00be..8ba1a9f848 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/search/SearchScreen.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/search/SearchScreen.kt
@@ -90,6 +90,8 @@ import moe.rukamori.archivetune.ui.menu.YouTubeArtistMenu
import moe.rukamori.archivetune.ui.menu.YouTubeSongMenu
import moe.rukamori.archivetune.ui.screens.MoodAndGenresButton
import moe.rukamori.archivetune.ui.screens.MoodAndGenresButtonHeight
+import moe.rukamori.archivetune.ui.utils.navigateToAlbum
+import moe.rukamori.archivetune.ui.utils.navigateToArtist
import moe.rukamori.archivetune.utils.rememberPreference
import moe.rukamori.archivetune.viewmodels.SearchDiscoveryScreenState
import moe.rukamori.archivetune.viewmodels.SearchDiscoveryTab
@@ -604,7 +606,7 @@ private fun TrendingAlbumsSection(
Modifier
.combinedClickable(
onClick = {
- navController.navigate("album/${album.id}")
+ navController.navigateToAlbum(album.id)
},
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
@@ -652,7 +654,7 @@ private fun SuggestedArtistsSection(
Modifier
.combinedClickable(
onClick = {
- navController.navigate("artist/${artist.id}")
+ navController.navigateToArtist(artist.id)
},
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/settings/AiIntegrationSettings.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/settings/AiIntegrationSettings.kt
index 919a8b5e49..1c64d15e22 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/settings/AiIntegrationSettings.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/settings/AiIntegrationSettings.kt
@@ -263,6 +263,15 @@ private fun AiIntegrationSettingsContent(
)
}
+ item(visible = model.provider != AiProvider.NONE && model.provider != AiProvider.GEMINI) {
+ PreferenceEntry(
+ title = { Text(stringResource(R.string.ai_request_parameters)) },
+ description = stringResource(R.string.ai_request_parameters_desc),
+ icon = { Icon(painterResource(R.drawable.edit), null) },
+ onClick = { onOpenEditor(AiSettingsEditorField.REQUEST_PARAMETERS) },
+ )
+ }
+
item {
val testVisualState =
when {
@@ -444,12 +453,15 @@ private fun AiSettingsEditorDialog(
) {
val field = editor.field ?: return
val isCustomPrompt = field == AiSettingsEditorField.CUSTOM_PROMPT
+ val isRequestParameters = field == AiSettingsEditorField.REQUEST_PARAMETERS
+ val isMultiline = isCustomPrompt || isRequestParameters
val titleResId =
when (field) {
AiSettingsEditorField.API_KEY -> R.string.ai_api_key
AiSettingsEditorField.CUSTOM_ENDPOINT -> R.string.ai_custom_endpoint
AiSettingsEditorField.CUSTOM_MODEL -> R.string.ai_model
AiSettingsEditorField.CUSTOM_PROMPT -> R.string.ai_custom_prompt
+ AiSettingsEditorField.REQUEST_PARAMETERS -> R.string.ai_request_parameters
}
val iconResId =
when (field) {
@@ -457,6 +469,7 @@ private fun AiSettingsEditorDialog(
AiSettingsEditorField.CUSTOM_ENDPOINT -> R.drawable.website
AiSettingsEditorField.CUSTOM_MODEL -> R.drawable.auto_awesome
AiSettingsEditorField.CUSTOM_PROMPT -> R.drawable.edit
+ AiSettingsEditorField.REQUEST_PARAMETERS -> R.drawable.edit
}
val visualTransformation =
remember(field) {
@@ -475,7 +488,7 @@ private fun AiSettingsEditorDialog(
} else {
KeyboardCapitalization.None
},
- imeAction = if (isCustomPrompt) ImeAction.Default else ImeAction.Done,
+ imeAction = if (isMultiline) ImeAction.Default else ImeAction.Done,
)
}
@@ -500,14 +513,19 @@ private fun AiSettingsEditorDialog(
value = editor.value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
- singleLine = !isCustomPrompt,
- minLines = if (isCustomPrompt) 4 else 1,
- maxLines = if (isCustomPrompt) 8 else 1,
+ singleLine = !isMultiline,
+ minLines = if (isMultiline) 4 else 1,
+ maxLines = if (isMultiline) 8 else 1,
+ isError = editor.errorMessageResId != null,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
label = { Text(stringResource(titleResId)) },
supportingText =
- if (isCustomPrompt) {
+ if (editor.errorMessageResId != null) {
+ { Text(stringResource(editor.errorMessageResId)) }
+ } else if (isRequestParameters) {
+ { Text(stringResource(R.string.ai_request_parameters_desc)) }
+ } else if (isCustomPrompt) {
{ Text(stringResource(R.string.ai_custom_prompt_desc)) }
} else {
null
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/settings/AppearanceSettings.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/settings/AppearanceSettings.kt
index 355b0985d2..07a03bec19 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/settings/AppearanceSettings.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/settings/AppearanceSettings.kt
@@ -67,6 +67,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
+import com.google.common.collect.ImmutableList
import moe.rukamori.archivetune.LocalPlayerAwareWindowInsets
import moe.rukamori.archivetune.R
import moe.rukamori.archivetune.constants.AppFontPreference
@@ -95,6 +96,10 @@ import moe.rukamori.archivetune.constants.LyricsBackgroundStyle
import moe.rukamori.archivetune.constants.LyricsBackgroundStyleKey
import moe.rukamori.archivetune.constants.MiniPlayerBackgroundStyle
import moe.rukamori.archivetune.constants.MiniPlayerBackgroundStyleKey
+import moe.rukamori.archivetune.constants.NavigationBarBackgroundStyle
+import moe.rukamori.archivetune.constants.NavigationBarBackgroundStyleKey
+import moe.rukamori.archivetune.constants.NavigationBarMode
+import moe.rukamori.archivetune.constants.NavigationBarModeKey
import moe.rukamori.archivetune.constants.PlayerBackgroundStyle
import moe.rukamori.archivetune.constants.PlayerBackgroundStyleKey
import moe.rukamori.archivetune.constants.PlayerButtonsStyle
@@ -210,6 +215,16 @@ fun AppearanceSettings(navController: NavController) {
defaultValue = MiniPlayerBackgroundStyle.THEME,
)
val (pureBlack, onPureBlackChange) = rememberPreference(PureBlackKey, defaultValue = false)
+ val (navigationBarBackground, onNavigationBarBackgroundChange) =
+ rememberEnumPreference(
+ NavigationBarBackgroundStyleKey,
+ defaultValue = NavigationBarBackgroundStyle.THEME,
+ )
+ val (navigationBarMode, onNavigationBarModeChange) =
+ rememberEnumPreference(
+ NavigationBarModeKey,
+ defaultValue = NavigationBarMode.CONNECTED,
+ )
val (disableBlur, onDisableBlurChange) = rememberPreference(DisableBlurKey, defaultValue = false)
val (disableAnimations, onDisableAnimationsChange) =
rememberPreference(
@@ -429,6 +444,7 @@ fun AppearanceSettings(navController: NavController) {
SliderStyle.Thick,
SliderStyle.Circular,
SliderStyle.Simple,
+ SliderStyle.Waveform,
)
}
DefaultDialog(
@@ -694,6 +710,37 @@ fun AppearanceSettings(navController: NavController) {
)
}
+ item {
+ EnumListPreference(
+ title = { Text(stringResource(R.string.navigation_bar_background_style)) },
+ icon = { Icon(painterResource(R.drawable.gradient), null) },
+ selectedValue = navigationBarBackground,
+ onValueSelected = onNavigationBarBackgroundChange,
+ valueText = {
+ when (it) {
+ NavigationBarBackgroundStyle.THEME -> stringResource(R.string.follow_theme)
+ NavigationBarBackgroundStyle.BLUR -> stringResource(R.string.blur)
+ NavigationBarBackgroundStyle.ACRYLIC -> stringResource(R.string.acrylic)
+ }
+ },
+ )
+ }
+
+ item {
+ EnumListPreference(
+ title = { Text(stringResource(R.string.navigation_bar_mode)) },
+ icon = { Icon(painterResource(R.drawable.palette), null) },
+ selectedValue = navigationBarMode,
+ onValueSelected = onNavigationBarModeChange,
+ valueText = {
+ when (it) {
+ NavigationBarMode.CONNECTED -> stringResource(R.string.navigation_bar_mode_connected)
+ NavigationBarMode.STANDALONE -> stringResource(R.string.navigation_bar_mode_standalone)
+ }
+ },
+ )
+ }
+
item {
EnumListPreference(
title = { Text(stringResource(R.string.font_preference)) },
@@ -846,6 +893,8 @@ fun AppearanceSettings(navController: NavController) {
MiniPlayerBackgroundStyle.THEME -> stringResource(R.string.follow_theme)
MiniPlayerBackgroundStyle.GRADIENT -> stringResource(R.string.gradient)
MiniPlayerBackgroundStyle.GLOW -> stringResource(R.string.glow)
+ MiniPlayerBackgroundStyle.ACRYLIC -> stringResource(R.string.acrylic)
+ MiniPlayerBackgroundStyle.BLUR -> stringResource(R.string.blur)
}
},
)
@@ -1054,6 +1103,7 @@ fun AppearanceSettings(navController: NavController) {
LibraryFilter.PODCASTS -> stringResource(R.string.podcast)
LibraryFilter.SPOTIFY -> stringResource(R.string.spotify_playlists)
LibraryFilter.LIBRARY -> stringResource(R.string.filter_library)
+ LibraryFilter.LOCAL -> stringResource(R.string.local_songs_tab)
}
},
onValueSelected = onDefaultChipChange,
@@ -1218,6 +1268,7 @@ private fun SliderStyleOptionCard(
) {
StyledPlaybackSlider(
sliderStyle = sliderStyle,
+ waveformAmplitudes = if (sliderStyle == SliderStyle.Waveform) SliderStyleWaveformPreview else null,
value = sliderValue,
valueRange = 0f..1f,
onValueChange = { sliderValue = it },
@@ -1245,8 +1296,25 @@ private fun sliderStyleLabel(sliderStyle: SliderStyle): String =
SliderStyle.Thick -> stringResource(R.string.slider_style_thick)
SliderStyle.Circular -> stringResource(R.string.slider_style_circular)
SliderStyle.Simple -> stringResource(R.string.slider_style_simple)
+ SliderStyle.Waveform -> stringResource(R.string.slider_style_waveform)
}
+private val SliderStyleWaveformPreview =
+ ImmutableList.of(
+ 0.18f,
+ 0.9f,
+ 0.32f,
+ 0.86f,
+ 0.94f,
+ 0.42f,
+ 0.78f,
+ 0.28f,
+ 0.64f,
+ 0.36f,
+ 0.82f,
+ 0.24f,
+ )
+
enum class DarkMode {
ON,
OFF,
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/settings/StorageSettings.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/settings/StorageSettings.kt
index 0e93884acd..73de4d3ad7 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/settings/StorageSettings.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/screens/settings/StorageSettings.kt
@@ -9,6 +9,9 @@
package moe.rukamori.archivetune.ui.screens.settings
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -112,6 +115,14 @@ fun StorageSettings(
val screenState by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val storagePickerSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val downloadFolderPicker =
+ rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
+ uri?.let(viewModel::setDownloadFolder)
+ }
+ val downloadFolderName =
+ (screenState as? StorageSettingsScreenState.Success)
+ ?.model
+ ?.downloadFolderName
LaunchedEffect(viewModel, context) {
viewModel.effects.collect { effect ->
@@ -296,6 +307,21 @@ fun StorageSettings(
)
PreferenceGroup(title = stringResource(R.string.downloaded_songs)) {
+ item {
+ PreferenceEntry(
+ title = { Text(stringResource(R.string.download_folder)) },
+ description =
+ downloadFolderName
+ ?: stringResource(R.string.download_folder_default_description),
+ icon = {
+ Icon(
+ painter = painterResource(R.drawable.snippet_folder),
+ contentDescription = null,
+ )
+ },
+ onClick = { downloadFolderPicker.launch(null) },
+ )
+ }
item {
PreferenceEntry(
title = { Text(stringResource(R.string.clear_all_downloads)) },
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/theme/Theme.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/theme/Theme.kt
index 10f7d1da3e..073bc2c6b8 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/theme/Theme.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/theme/Theme.kt
@@ -16,6 +16,7 @@ import android.util.Base64
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.snap
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -54,6 +55,7 @@ import kotlin.math.min
val DefaultThemeColor = Color(0xFFED5564)
val LocalArchiveTuneFontPreference = staticCompositionLocalOf { AppFontPreference.DEFAULT }
val LocalArchiveTuneFontFamily = staticCompositionLocalOf { AppFontFamily }
+private const val ThemeColorTransitionDurationMs = 650
@Composable
fun rememberArchiveTuneLyricsFontFamily(): FontFamily {
@@ -160,7 +162,7 @@ fun ArchiveTuneTheme(
} else {
animateColorScheme(
targetColorScheme = colorScheme,
- animationSpec = motionScheme.defaultEffectsSpec(),
+ animationSpec = tween(durationMillis = ThemeColorTransitionDurationMs),
)
}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/utils/NavControllerUtils.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/utils/NavControllerUtils.kt
index 3e799f25f8..c19a7a9f77 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/utils/NavControllerUtils.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/utils/NavControllerUtils.kt
@@ -7,9 +7,25 @@
package moe.rukamori.archivetune.ui.utils
+import android.net.Uri
import androidx.navigation.NavController
+import moe.rukamori.archivetune.innertube.models.PODCAST_SHOW_BROWSE_PREFIX
import moe.rukamori.archivetune.ui.screens.Screens
+internal fun NavController.navigateToArtist(artistId: String?) {
+ navigateToMediaDetail(artistId, "artist")
+}
+
+internal fun NavController.navigateToAlbum(albumId: String?) {
+ navigateToMediaDetail(albumId, "album")
+}
+
+private fun NavController.navigateToMediaDetail(id: String?, destination: String) {
+ if (id.isNullOrBlank()) return
+ val route = if (id.startsWith(PODCAST_SHOW_BROWSE_PREFIX)) "podcast" else destination
+ navigate("$route/${Uri.encode(id)}")
+}
+
fun NavController.backToMain() {
val mainRoutes = Screens.MainScreens.map { it.route }
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/ui/utils/ShowMediaInfo.kt b/app/src/main/kotlin/moe/rukamori/archivetune/ui/utils/ShowMediaInfo.kt
index 7456c390f6..5d323b1545 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/ui/utils/ShowMediaInfo.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/ui/utils/ShowMediaInfo.kt
@@ -270,7 +270,7 @@ private fun MediaInfoContent(
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
},
colors =
- ToggleButtonDefaults.toggleButtonColors(
+ ToggleButtonDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
checkedContainerColor = MaterialTheme.colorScheme.primaryContainer,
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/utils/GlobalLog.kt b/app/src/main/kotlin/moe/rukamori/archivetune/utils/GlobalLog.kt
index 45e050d317..d17d1add5f 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/utils/GlobalLog.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/utils/GlobalLog.kt
@@ -7,6 +7,7 @@
package moe.rukamori.archivetune.utils
+import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import timber.log.Timber
@@ -66,7 +67,7 @@ class GlobalLogTree : Timber.DebugTree() {
t: Throwable?,
) {
try {
- val final = if (t != null) "$message\n$t" else message
+ val final = if (t != null) "$message\n${Log.getStackTraceString(t)}" else message
GlobalLog.append(priority, tag, final)
} catch (_: Exception) {
// swallow
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/utils/ScrobbleManager.kt b/app/src/main/kotlin/moe/rukamori/archivetune/utils/ScrobbleManager.kt
index 7564263bce..43d7c7d9c9 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/utils/ScrobbleManager.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/utils/ScrobbleManager.kt
@@ -119,10 +119,15 @@ class ScrobbleManager(
private fun scrobbleSong(metadata: MediaMetadata) {
scope.launch {
+ val (track, artist) =
+ normalizeScrobbleMetadata(
+ title = metadata.title,
+ artists = metadata.artists.map { it.name },
+ )
LastFM
.scrobble(
- artist = metadata.artists.joinToString(", ") { artist -> artist.name },
- track = metadata.title,
+ artist = artist,
+ track = track,
duration = metadata.duration,
timestamp = songStartedAt,
album = metadata.album?.title,
@@ -130,27 +135,32 @@ class ScrobbleManager(
Timber
.tag(
"ScrobbleManager",
- ).d("Scrobbled: ${metadata.title} by ${metadata.artists.joinToString(", ") { artist -> artist.name }}")
+ ).d("Scrobbled: $track by $artist")
}.onFailure { throwable ->
if (throwable is CancellationException) throw throwable
- Timber.tag("ScrobbleManager").e(throwable, "Failed to scrobble: ${metadata.title}")
+ Timber.tag("ScrobbleManager").e(throwable, "Failed to scrobble: $track")
}
}
}
private fun updateNowPlaying(metadata: MediaMetadata) {
scope.launch {
+ val (track, artist) =
+ normalizeScrobbleMetadata(
+ title = metadata.title,
+ artists = metadata.artists.map { it.name },
+ )
LastFM
.updateNowPlaying(
- artist = metadata.artists.joinToString(", ") { artist -> artist.name },
- track = metadata.title,
+ artist = artist,
+ track = track,
album = metadata.album?.title,
duration = metadata.duration,
).onSuccess {
- Timber.tag("ScrobbleManager").d("Updated now playing: ${metadata.title}")
+ Timber.tag("ScrobbleManager").d("Updated now playing: $track")
}.onFailure { throwable ->
if (throwable is CancellationException) throw throwable
- Timber.tag("ScrobbleManager").e(throwable, "Failed to update now playing: ${metadata.title}")
+ Timber.tag("ScrobbleManager").e(throwable, "Failed to update now playing: $track")
}
}
}
@@ -172,3 +182,73 @@ class ScrobbleManager(
}
}
}
+
+private val FEAT_BRACKETS_REGEX =
+ Regex(
+ """\s*[\(\[]\s*(?:feat\.?|ft\.?|featuring|with)\s+([^\)\]]+)[\)\]]""",
+ RegexOption.IGNORE_CASE,
+ )
+
+private val FEAT_SUFFIX_REGEX =
+ Regex(
+ """\s*(?:-\s*)?(?:feat\.?|ft\.?|featuring)\s+(.+)$""",
+ RegexOption.IGNORE_CASE,
+ )
+
+private val ARTIST_SPLIT_REGEX =
+ Regex(
+ """\s*(?:,|&|/|\band\b)\s*""",
+ RegexOption.IGNORE_CASE,
+ )
+
+internal fun normalizeScrobbleMetadata(
+ title: String,
+ artists: List,
+): Pair {
+ var cleanTitle = title
+ val featuredArtists = mutableListOf()
+
+ val bracketMatch = FEAT_BRACKETS_REGEX.find(cleanTitle)
+ if (bracketMatch != null) {
+ val rawArtists = bracketMatch.groupValues[1]
+ featuredArtists.addAll(
+ rawArtists
+ .split(ARTIST_SPLIT_REGEX)
+ .map { it.trim() }
+ .filter { it.isNotEmpty() },
+ )
+ cleanTitle = cleanTitle.removeRange(bracketMatch.range).trim()
+ } else {
+ val suffixMatch = FEAT_SUFFIX_REGEX.find(cleanTitle)
+ if (suffixMatch != null) {
+ val rawArtists = suffixMatch.groupValues[1]
+ featuredArtists.addAll(
+ rawArtists
+ .split(ARTIST_SPLIT_REGEX)
+ .map { it.trim() }
+ .filter { it.isNotEmpty() },
+ )
+ cleanTitle = cleanTitle.removeRange(suffixMatch.range).trim()
+ }
+ }
+
+ if (cleanTitle.isBlank()) {
+ cleanTitle = title
+ }
+
+ val mergedArtists = artists.map { it.trim() }.filter { it.isNotEmpty() }.toMutableList()
+ for (featured in featuredArtists) {
+ if (mergedArtists.none { it.equals(featured, ignoreCase = true) }) {
+ mergedArtists.add(featured)
+ }
+ }
+
+ val finalArtist =
+ if (mergedArtists.isNotEmpty()) {
+ mergedArtists.joinToString(", ")
+ } else {
+ artists.joinToString(", ")
+ }
+
+ return cleanTitle to finalArtist
+}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/utils/YTPlayerUtils.kt b/app/src/main/kotlin/moe/rukamori/archivetune/utils/YTPlayerUtils.kt
index 18e577a14e..b9c12062b7 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/utils/YTPlayerUtils.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/utils/YTPlayerUtils.kt
@@ -40,7 +40,6 @@ import java.util.concurrent.ConcurrentHashMap
object YTPlayerUtils {
private const val logTag = "YTPlayerUtils"
- private const val YOUTUBEI_PO_TOKEN_RESOLUTION_BUDGET_MS = 5_000L
private const val FAILED_CLIENT_BACKOFF_MS = 10 * 60 * 1000L
private const val DEFAULT_STREAM_EXPIRE_SECONDS = 300
private const val MAX_PLAYBACK_DATA_CACHE_ENTRIES = 128
@@ -160,6 +159,7 @@ object YTPlayerUtils {
private val streamUrlCache = ConcurrentHashMap()
private val playbackDataCache = ConcurrentHashMap()
private val playbackDataResolutionMutexes = Array(PLAYBACK_DATA_RESOLUTION_MUTEX_COUNT) { Mutex() }
+ private val youtubeiVisitorMutex = Mutex()
private val failedStreamClientsUntil = ConcurrentHashMap()
@Volatile private var lastSuccessfulClientKey: String? = null
@@ -376,14 +376,39 @@ object YTPlayerUtils {
videoId: String,
authState: PlaybackAuthState = YouTube.currentPlaybackAuthState(),
): PlaybackAuthState {
- val contentBinding = authState.youtubeiContentBinding() ?: return authState
+ val resolvedAuthState =
+ if (!authState.hasLoginCookie && authState.visitorData.isNullOrBlank()) {
+ youtubeiVisitorMutex.withLock {
+ val currentAuthState = YouTube.currentPlaybackAuthState()
+ val visitorData =
+ currentAuthState.visitorData
+ ?.takeIf {
+ it.isNotBlank() &&
+ authState.cookie == currentAuthState.cookie &&
+ authState.dataSyncId == currentAuthState.dataSyncId
+ }
+ ?: YouTube.visitorData().getOrThrow().trim().takeIf(String::isNotBlank)
+ ?: throw IllegalStateException("YouTube returned empty playback visitor data")
+ if (authState.hasSamePlaybackSession(YouTube.currentPlaybackAuthState())) {
+ YouTube.visitorData = visitorData
+ }
+ authState.copy(visitorData = visitorData).normalized()
+ }
+ } else {
+ authState
+ }
+ val contentBinding = resolvedAuthState.youtubeiContentBinding()
+ ?: throw InvalidPlaybackLoginContextException(
+ videoId = videoId,
+ targetUrl = "https://music.youtube.com/watch?v=$videoId",
+ cause = IllegalStateException("YouTube playback token binding is missing"),
+ )
val tokenResult =
- BotGuardTokenGenerator.mintToken(
+ BotGuardTokenGenerator.mintRequiredToken(
videoId = videoId,
sessionId = contentBinding,
- maximumWaitMillis = YOUTUBEI_PO_TOKEN_RESOLUTION_BUDGET_MS,
- ) ?: return authState
- return authState.withGeneratedPoTokens(videoId, tokenResult)
+ )
+ return resolvedAuthState.withGeneratedPoTokens(videoId, tokenResult)
}
suspend fun preWarmYoutubeiPoTokens(authState: PlaybackAuthState) {
@@ -430,6 +455,7 @@ object YTPlayerUtils {
dataSyncId == other.dataSyncId
private fun PlaybackAuthState.youtubeiContentBinding(): String? {
+ if (!hasLoginCookie) return visitorData?.trim()?.takeIf(String::isNotBlank)
val normalizedDataSyncId = dataSyncId?.trim()?.takeIf(String::isNotBlank)
if (normalizedDataSyncId != null) {
return if ("||" in normalizedDataSyncId) {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/utils/potoken/BotGuardTokenGenerator.kt b/app/src/main/kotlin/moe/rukamori/archivetune/utils/potoken/BotGuardTokenGenerator.kt
index 4086db8e49..a6401d337e 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/utils/potoken/BotGuardTokenGenerator.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/utils/potoken/BotGuardTokenGenerator.kt
@@ -31,10 +31,18 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
+import okhttp3.Call
+import okhttp3.Callback
import okhttp3.Headers.Companion.toHeaders
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import org.json.JSONArray
+import org.json.JSONObject
import timber.log.Timber
+import java.io.IOException
+import java.net.SocketTimeoutException
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.Collections
@@ -70,7 +78,7 @@ import kotlin.coroutines.resumeWithException
*/
object BotGuardTokenGenerator {
private const val TAG = "BotGuardTokenGen"
- private const val CREATE_URL = "https://www.youtube.com/api/jnn/v1/Create"
+ private const val CHALLENGE_PAGE_URL = "https://www.youtube.com/?hl=en"
private const val GENERATE_IT_URL = "https://www.youtube.com/api/jnn/v1/GenerateIT"
private const val REQUEST_KEY = "O43z0dpjhgX20SCx4KAo"
private const val API_KEY = "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw"
@@ -95,6 +103,7 @@ object BotGuardTokenGenerator {
// ── state ────────────────────────────────────────────────────────
private var appContext: Context? = null
+ @Volatile
private var permanentlyBroken = false
private val mutex = Mutex()
@@ -179,6 +188,20 @@ object BotGuardTokenGenerator {
}
}
+ suspend fun mintRequiredToken(
+ videoId: String,
+ sessionId: String,
+ ): PoTokenResult {
+ val ctx = appContext ?: throw PoTokenException("Token generator is not initialized")
+ if (permanentlyBroken) throw BrokenWebViewException("Token generation requires a usable system WebView")
+ require(videoId.isNotBlank() && sessionId.isNotBlank())
+ return withTimeoutOrNull(COLD_START_TIMEOUT_MS * 2L) {
+ mutex.withLock {
+ mintTokenOrThrowLocked(ctx, videoId, sessionId)
+ }
+ } ?: throw SocketTimeoutException("Timed out waiting for the playback token engine")
+ }
+
suspend fun mintToken(
videoId: String,
sessionId: String,
@@ -259,7 +282,6 @@ object BotGuardTokenGenerator {
getOrCreateEngine(
ctx = ctx,
sessionId = sessionId,
- forceNewEngine = false,
)
}
@@ -267,34 +289,13 @@ object BotGuardTokenGenerator {
ctx: Context,
videoId: String,
sessionId: String,
- forceNewEngine: Boolean,
): PoTokenResult {
- val (eng, sessionTok, wasNew) =
+ val (eng, sessionTok) =
getOrCreateEngineLocked(
ctx = ctx,
sessionId = sessionId,
- forceNewEngine = forceNewEngine,
)
-
- val playerTok =
- try {
- eng.mint(videoId)
- } catch (e: Throwable) {
- if (e is CancellationException) throw e
- if (wasNew) throw e
- Timber.tag(TAG).w(e, "mint failed, retrying with fresh engine")
- val (freshEngine, freshSessionToken) =
- getOrCreateEngineLocked(
- ctx = ctx,
- sessionId = sessionId,
- forceNewEngine = true,
- )
- return PoTokenResult(
- playerToken = freshEngine.mint(videoId),
- sessionToken = freshSessionToken,
- )
- }
-
+ val playerTok = eng.mint(videoId)
return PoTokenResult(playerToken = playerTok, sessionToken = sessionTok)
}
@@ -303,7 +304,23 @@ object BotGuardTokenGenerator {
videoId: String,
sessionId: String,
maximumOperationMillis: Long? = null,
- ): PoTokenResult? {
+ ): PoTokenResult? =
+ try {
+ mintTokenOrThrowLocked(ctx, videoId, sessionId, maximumOperationMillis)
+ } catch (cancellation: CancellationException) {
+ throw cancellation
+ } catch (failure: Exception) {
+ Timber.tag(TAG).e(failure, "Token generation failed")
+ null
+ }
+
+ private suspend fun mintTokenOrThrowLocked(
+ ctx: Context,
+ videoId: String,
+ sessionId: String,
+ maximumOperationMillis: Long? = null,
+ ): PoTokenResult {
+ if (permanentlyBroken) throw BrokenWebViewException("Token generation requires a usable system WebView")
if (isEngineReadyForSession(sessionId)) {
val cachedPlayer = playerTokenCache[videoId]
val sessionToken = cachedSessionToken
@@ -325,36 +342,20 @@ object BotGuardTokenGenerator {
val timeout =
maximumOperationMillis?.let { minOf(defaultTimeout, it) }
?: defaultTimeout
- var operationStarted = false
-
return try {
withTimeoutOrNull(timeout) {
- operationStarted = true
mintTokenInternalLocked(
ctx = ctx,
videoId = videoId,
sessionId = sessionId,
- forceNewEngine = false,
).also { result ->
playerTokenCache[videoId] = result.playerToken
}
- } ?: run {
- Timber.tag(TAG).w("Timed out after ${timeout}ms — proceeding without PoToken")
- resetEngineAfterInterruptedMint()
- null
- }
- } catch (cancellation: CancellationException) {
- if (operationStarted) {
- resetEngineAfterInterruptedMint()
- }
- throw cancellation
- } catch (e: BrokenWebViewException) {
- Timber.tag(TAG).e(e, "Permanently broken WebView")
- permanentlyBroken = true
- null
- } catch (e: Exception) {
- Timber.tag(TAG).e(e, "mintToken failed: ${e.message}")
- null
+ } ?: throw SocketTimeoutException("Playback token generation timed out after ${timeout}ms")
+ } catch (failure: Exception) {
+ resetEngineAfterInterruptedMint()
+ if (failure is BrokenWebViewException) permanentlyBroken = true
+ throw failure
}
}
@@ -367,22 +368,19 @@ object BotGuardTokenGenerator {
private suspend fun getOrCreateEngine(
ctx: Context,
sessionId: String,
- forceNewEngine: Boolean,
- ): Triple =
+ ): Pair =
mutex.withLock {
getOrCreateEngineLocked(
ctx = ctx,
sessionId = sessionId,
- forceNewEngine = forceNewEngine,
)
}
private suspend fun getOrCreateEngineLocked(
ctx: Context,
sessionId: String,
- forceNewEngine: Boolean,
- ): Triple {
- val needsNew = forceNewEngine || !isEngineReadyForSession(sessionId)
+ ): Pair {
+ val needsNew = !isEngineReadyForSession(sessionId)
if (needsNew) {
withContext(Dispatchers.Main) {
engine?.close()
@@ -409,10 +407,9 @@ object BotGuardTokenGenerator {
engineReady = true
}
- return Triple(
+ return Pair(
requireNotNull(engine),
requireNotNull(cachedSessionToken),
- needsNew,
)
}
@@ -448,7 +445,7 @@ object BotGuardTokenGenerator {
)
private lateinit var expiry: Instant
- val isExpired: Boolean get() = Instant.now().isAfter(expiry)
+ val isExpired: Boolean get() = closed.get() || !Instant.now().isBefore(expiry)
fun startBootstrap() {
scope.launch(exceptionHandler) {
@@ -473,23 +470,44 @@ object BotGuardTokenGenerator {
@JavascriptInterface
fun onPageLoaded() {
dispatchBridgeCallback {
- Timber.tag(TAG).d("Page loaded — requesting challenge from Create")
- postToBotGuard(CREATE_URL, "[ \"$REQUEST_KEY\" ]") { body ->
- val challengeJson = parseCreateChallenge(body)
- webView.evaluateJavascript(
- """
- try {
- var data = $challengeJson;
- runBotGuard(data).then(function(r) {
- this.webPoSignalOutput = r.webPoSignalOutput;
- $JS_BRIDGE.onBotGuardReady(r.botguardResponse);
- }, function(e) {
- $JS_BRIDGE.onFatalError(e + "\n" + e.stack);
- });
- } catch(e) { $JS_BRIDGE.onFatalError(e + "\n" + e.stack); }
- """.trimIndent(),
- null,
- )
+ Timber.tag(TAG).d("Page loaded — requesting page-bound attestation challenge")
+ requestBotGuard(CHALLENGE_PAGE_URL) { body ->
+ val page = withContext(Dispatchers.Default) { parsePageChallenge(body) }
+ val interpreterUrl = page.challenge
+ .getJSONObject("interpreterUrl")
+ .getString("privateDoNotAccessOrElseTrustedResourceUrlWrappedValue")
+ .let { if (it.startsWith("//")) "https:$it" else it }
+ .toHttpUrlOrNull()
+ ?: throw PoTokenException("Invalid BotGuard interpreter URL")
+ if (!interpreterUrl.isHttps ||
+ !(interpreterUrl.host == "www.youtube.com" ||
+ interpreterUrl.host == "www.google.com" ||
+ interpreterUrl.host == "www.gstatic.com")
+ ) {
+ throw PoTokenException("Unsupported BotGuard interpreter origin")
+ }
+ requestBotGuard(interpreterUrl.toString()) { interpreter ->
+ if (interpreter.isBlank()) throw PoTokenException("Empty BotGuard interpreter")
+ page.challenge.put(
+ "interpreterJavascript",
+ JSONObject().put("privateDoNotAccessOrElseSafeScriptWrappedValue", interpreter),
+ )
+ webView.evaluateJavascript(
+ """
+ try {
+ globalThis.yt = { config_: ${page.configuration} };
+ var data = ${page.challenge};
+ runBotGuard(data).then(function(r) {
+ this.webPoSignalOutput = r.webPoSignalOutput;
+ $JS_BRIDGE.onBotGuardReady(r.botguardResponse);
+ }, function(e) {
+ $JS_BRIDGE.onFatalError(e + "\n" + e.stack);
+ });
+ } catch(e) { $JS_BRIDGE.onFatalError(e + "\n" + e.stack); }
+ """.trimIndent(),
+ null,
+ )
+ }
}
}
}
@@ -498,7 +516,7 @@ object BotGuardTokenGenerator {
fun onBotGuardReady(botguardResponse: String) {
dispatchBridgeCallback {
Timber.tag(TAG).d("BotGuard executed — requesting integrity token from GenerateIT")
- postToBotGuard(GENERATE_IT_URL, "[ \"$REQUEST_KEY\", \"$botguardResponse\" ]") { body ->
+ requestBotGuard(GENERATE_IT_URL, JSONArray().put(REQUEST_KEY).put(botguardResponse).toString()) { body ->
try {
val (tokenU8, lifetimeSec) = parseIntegrityToken(body)
expiry = Instant.now().plusSeconds(lifetimeSec).minus(10, ChronoUnit.MINUTES)
@@ -574,7 +592,7 @@ object BotGuardTokenGenerator {
) {
dispatchBridgeCallback {
val base64 = commaSeparatedBytesToBase64(csvBytes)
- Timber.tag(TAG).d("Minted token for $identifier (${base64.length} chars)")
+ Timber.tag(TAG).d("Minted playback token (%d chars)", base64.length)
pendingMints
.remove(identifier)
?.let { continuation ->
@@ -625,38 +643,56 @@ object BotGuardTokenGenerator {
readySignal.resumeWithException(error)
}
- private fun postToBotGuard(
+ private fun requestBotGuard(
url: String,
- jsonBody: String,
- onSuccess: (String) -> Unit,
+ jsonBody: String? = null,
+ onSuccess: suspend (String) -> Unit,
) {
scope.launch(exceptionHandler) {
val request =
okhttp3.Request
.Builder()
.url(url)
- .post(jsonBody.toRequestBody())
+ .apply {
+ if (jsonBody != null) post(jsonBody.toRequestBody())
+ }
.headers(
- mapOf(
- "User-Agent" to WV_USER_AGENT,
- "Accept" to "application/json",
- "Content-Type" to "application/json+protobuf",
- "x-goog-api-key" to API_KEY,
- "x-user-agent" to "grpc-web-javascript/0.1",
- ).toHeaders(),
+ buildMap {
+ put("User-Agent", WV_USER_AGENT)
+ put("Accept", "*/*")
+ put("Accept-Language", "en-US,en;q=0.7")
+ if (jsonBody != null) {
+ put("Content-Type", "application/json+protobuf")
+ put("x-goog-api-key", API_KEY)
+ put("x-user-agent", "grpc-web-javascript/0.1")
+ }
+ }.toHeaders(),
).build()
- val response =
- withContext(Dispatchers.IO) {
- httpClient.newCall(request).execute()
- }
+ val body = suspendCancellableCoroutine { continuation ->
+ val call = httpClient.newCall(request)
+ continuation.invokeOnCancellation { call.cancel() }
+ call.enqueue(
+ object : Callback {
+ override fun onFailure(call: Call, e: IOException) {
+ if (continuation.isActive) continuation.resumeWithException(e)
+ }
- if (response.code != 200) {
- signalError(PoTokenException("BotGuard HTTP ${response.code} from $url"))
- } else {
- val body = withContext(Dispatchers.IO) { response.body!!.string() }
- onSuccess(body)
+ override fun onResponse(call: Call, response: Response) {
+ val result = runCatching {
+ response.use {
+ if (it.code != 200) {
+ throw PoTokenException("BotGuard HTTP ${it.code}")
+ }
+ it.body.string()
+ }
+ }
+ if (continuation.isActive) continuation.resumeWith(result)
+ }
+ },
+ )
}
+ onSuccess(body)
}
}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/utils/potoken/ChallengeParser.kt b/app/src/main/kotlin/moe/rukamori/archivetune/utils/potoken/ChallengeParser.kt
index c40a5c72ad..18fdd588d5 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/utils/potoken/ChallengeParser.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/utils/potoken/ChallengeParser.kt
@@ -8,78 +8,118 @@
package moe.rukamori.archivetune.utils.potoken
import kotlinx.serialization.json.Json
-import kotlinx.serialization.json.JsonNull
-import kotlinx.serialization.json.JsonObject
-import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import okio.ByteString.Companion.decodeBase64
import okio.ByteString.Companion.toByteString
+import org.json.JSONObject
private val json = Json { ignoreUnknownKeys = true }
-/**
- * Parses the raw response from YouTube's `api/jnn/v1/Create` endpoint into a JSON object
- * that can be embedded directly into a JavaScript snippet for `runBotGuard()`.
- *
- * The response is a JSON array. The first element may be:
- * - a nested JSON array (unscrambled challenge), or
- * - a base64-encoded string (scrambled challenge) that must be descrambled first.
- *
- * The resulting challenge array contains (by index):
- * [0] messageId
- * [1] interpreterJavascript array (or null)
- * [2] interpreterTrustedResourceUrl array (or null)
- * [3] interpreterHash
- * [4] program (base64)
- * [5] globalName
- * [6] (unknown)
- * [7] clientExperimentsStateBlob
- */
-fun parseCreateChallenge(rawResponse: String): String {
- val outer = json.parseToJsonElement(rawResponse).jsonArray
+internal data class PageChallenge(
+ val configuration: JSONObject,
+ val challenge: JSONObject,
+)
+
+internal fun parsePageChallenge(html: String): PageChallenge {
+ val configuration = JSONObject()
+ Regex("ytcfg\\.set\\s*\\(\\s*(?=\\{)").findAll(html).forEach { match ->
+ val value = parsePageObject(extractPageObject(html, match.range.last + 1))
+ value.keys().forEach { key -> configuration.put(key, value.get(key)) }
+ }
+ if (configuration.optString("EVENT_ID").isBlank()) {
+ throw PoTokenException("Attestation page is missing EVENT_ID")
+ }
+ val attestationStart = Regex("window\\.ytAtN\\s*\\(\\s*(?=\\{)").find(html)
+ ?: throw PoTokenException("Attestation page is missing its challenge")
+ val attestation = parsePageObject(extractPageObject(html, attestationStart.range.last + 1))
+ val response = when (val value = attestation.get("R")) {
+ is JSONObject -> value
+ is String -> parsePageObject(value)
+ else -> throw PoTokenException("Invalid page attestation response")
+ }
+ val challenge = response.getJSONObject("bgChallenge")
+ if (challenge.optString("program").isBlank() || challenge.optString("globalName").isBlank()) {
+ throw PoTokenException("Incomplete page attestation challenge")
+ }
+ return PageChallenge(configuration, challenge)
+}
- val challenge =
- if (outer.size > 1 && outer[1].jsonPrimitive.isString) {
- // Scrambled: base64-decode then add 97 to each byte
- val decoded = descramble(outer[1].jsonPrimitive.content)
- json.parseToJsonElement(decoded).jsonArray
+private fun parsePageObject(source: String): JSONObject {
+ val normalized = StringBuilder(source.length)
+ var quote: Char? = null
+ var index = 0
+ while (index < source.length) {
+ val character = source[index]
+ if (quote != null) {
+ if (character == '\\') {
+ val escaped = source.getOrNull(index + 1)
+ ?: throw PoTokenException("Incomplete attestation string escape")
+ if (escaped == 'x') {
+ val first = source.getOrNull(index + 2)
+ val second = source.getOrNull(index + 3)
+ if (first == null || second == null ||
+ first.digitToIntOrNull(16) == null || second.digitToIntOrNull(16) == null
+ ) {
+ throw PoTokenException("Invalid attestation hexadecimal escape")
+ }
+ normalized.append("\\u00").append(first).append(second)
+ index += 4
+ } else {
+ normalized.append(character).append(escaped)
+ index += 2
+ }
+ continue
+ }
+ if (character == quote) quote = null
} else {
- outer[0].jsonArray
+ when (character) {
+ '\'', '"' -> quote = character
+ ',' -> {
+ var next = index + 1
+ while (next < source.length && source[next].isWhitespace()) next++
+ if (source.getOrNull(next) == '}' || source.getOrNull(next) == ']') {
+ index++
+ continue
+ }
+ }
+ }
}
+ normalized.append(character)
+ index++
+ }
+ if (quote != null) throw PoTokenException("Unterminated attestation string")
+ return JSONObject(normalized.toString())
+}
- val program = challenge[4].jsonPrimitive.content
- val globalName = challenge[5].jsonPrimitive.content
-
- val interpreterJs =
- challenge[1]
- .takeIf { it !is JsonNull }
- ?.jsonArray
- ?.firstOrNull { it.jsonPrimitive.isString }
-
- val interpreterUrl =
- challenge[2]
- .takeIf { it !is JsonNull }
- ?.jsonArray
- ?.firstOrNull { it.jsonPrimitive.isString }
-
- return json.encodeToString(
- JsonObject.serializer(),
- JsonObject(
- mapOf(
- "program" to JsonPrimitive(program),
- "globalName" to JsonPrimitive(globalName),
- "interpreterJavascript" to
- JsonObject(
- mapOf(
- "privateDoNotAccessOrElseSafeScriptWrappedValue" to (interpreterJs ?: JsonNull),
- "privateDoNotAccessOrElseTrustedResourceUrlWrappedValue" to (interpreterUrl ?: JsonNull),
- ),
- ),
- ),
- ),
- )
+private fun extractPageObject(source: String, offset: Int): String {
+ var start = offset
+ while (start < source.length && source[start].isWhitespace()) start++
+ if (source.getOrNull(start) != '{') throw PoTokenException("Invalid attestation page object")
+ var depth = 0
+ var quote: Char? = null
+ var escaped = false
+ for (index in start until source.length) {
+ val character = source[index]
+ if (quote != null) {
+ when {
+ escaped -> escaped = false
+ character == '\\' -> escaped = true
+ character == quote -> quote = null
+ }
+ } else {
+ when (character) {
+ '\'', '"' -> quote = character
+ '{' -> depth++
+ '}' -> {
+ depth--
+ if (depth == 0) return source.substring(start, index + 1)
+ }
+ }
+ }
+ }
+ throw PoTokenException("Unterminated attestation page object")
}
/**
@@ -120,12 +160,6 @@ fun commaSeparatedBytesToBase64(commaBytes: String): String =
// --- internal helpers ---
-private fun descramble(base64Payload: String): String =
- base64ToByteArray(base64Payload)
- .map { (it + 97).toByte() }
- .toByteArray()
- .decodeToString()
-
private fun base64ToJsUint8Array(base64: String): String {
val bytes = base64ToByteArray(base64)
return "new Uint8Array([${bytes.joinToString(",") { (it.toInt() and 0xFF).toString() }}])"
@@ -139,6 +173,6 @@ private fun base64ToByteArray(base64: String): ByteArray {
.replace('.', '=')
return (
normalised.decodeBase64()
- ?: throw PoTokenException("Cannot decode base64: ${base64.take(40)}…")
+ ?: throw PoTokenException("Cannot decode attestation token")
).toByteArray()
}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/AboutViewModel.kt b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/AboutViewModel.kt
index 6f216873bf..785d8e0927 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/AboutViewModel.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/AboutViewModel.kt
@@ -422,7 +422,7 @@ class AboutViewModel
id = "website",
iconResId = R.drawable.website,
labelResId = R.string.about_content_desc_website,
- url = "https://archivetune.koiiverse.cloud",
+ url = "https://archivetune.miuka.moe",
),
AboutLinkUiModel(
id = "telegram",
@@ -434,7 +434,7 @@ class AboutViewModel
id = "donate",
iconResId = R.drawable.coffee,
labelResId = R.string.about_content_desc_donate,
- url = "https://koiiverse.cloud/donate",
+ url = "https://miuka.moe/donate",
),
AboutLinkUiModel(
id = "discord",
@@ -446,7 +446,7 @@ class AboutViewModel
id = "privacy_policy",
iconResId = R.drawable.lock,
labelResId = R.string.privacy,
- url = "https://archivetune.koiiverse.cloud/privacy",
+ url = "https://archivetune.miuka.moe/privacy",
),
),
leadDeveloper =
@@ -467,7 +467,7 @@ class AboutViewModel
id = "website",
iconResId = R.drawable.website,
labelResId = R.string.about_content_desc_website,
- url = "https://koiiverse.cloud",
+ url = "https://miuka.moe",
),
AboutLinkUiModel(
id = "discord",
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/AiIntegrationSettingsViewModel.kt b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/AiIntegrationSettingsViewModel.kt
index ed4c01dd79..fd949b82da 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/AiIntegrationSettingsViewModel.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/AiIntegrationSettingsViewModel.kt
@@ -32,6 +32,7 @@ import kotlinx.coroutines.launch
import moe.rukamori.archivetune.R
import moe.rukamori.archivetune.ai.AiIntegrationSettingsData
import moe.rukamori.archivetune.ai.AiModelOption
+import moe.rukamori.archivetune.ai.AiRequestParameters
import moe.rukamori.archivetune.ai.AiServiceConfig
import moe.rukamori.archivetune.ai.FetchAiModelsUseCase
import moe.rukamori.archivetune.ai.ObserveAiIntegrationSettingsUseCase
@@ -39,6 +40,7 @@ import moe.rukamori.archivetune.ai.TestAiIntegrationUseCase
import moe.rukamori.archivetune.ai.UpdateAiIntegrationSettingsUseCase
import moe.rukamori.archivetune.constants.AiApiValidationStatus
import moe.rukamori.archivetune.constants.AiProvider
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import timber.log.Timber
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
@@ -62,6 +64,7 @@ enum class AiSettingsEditorField {
CUSTOM_ENDPOINT,
CUSTOM_MODEL,
CUSTOM_PROMPT,
+ REQUEST_PARAMETERS,
}
@Immutable
@@ -69,6 +72,7 @@ data class AiSettingsEditorUiModel(
val field: AiSettingsEditorField? = null,
val value: String = "",
val canSave: Boolean = false,
+ @StringRes val errorMessageResId: Int? = null,
) {
val visible: Boolean
get() = this.field != null
@@ -90,6 +94,7 @@ data class AiIntegrationSettingsUiModel(
val selectedModel: String,
val customModel: String,
val customPrompt: String,
+ val requestParameters: String,
val availableModels: ImmutableList,
val isTesting: Boolean,
val isFetchingModels: Boolean,
@@ -154,6 +159,7 @@ class AiIntegrationSettingsViewModel
private var fetchModelsJob: Job? = null
private var testApiJob: Job? = null
private val fetchModelsRequestId = AtomicInteger()
+ private val testApiRequestId = AtomicInteger()
val state: StateFlow =
combine(observeSettings(), transientState) { settings, transient ->
@@ -186,6 +192,7 @@ class AiIntegrationSettingsViewModel
AiSettingsEditorField.CUSTOM_ENDPOINT -> model.customEndpoint
AiSettingsEditorField.CUSTOM_MODEL -> model.customModel
AiSettingsEditorField.CUSTOM_PROMPT -> model.customPrompt
+ AiSettingsEditorField.REQUEST_PARAMETERS -> model.requestParameters
}
transientState.update { state ->
state.copy(editor = editorModel(field = field, value = value))
@@ -229,6 +236,10 @@ class AiIntegrationSettingsViewModel
AiSettingsEditorField.CUSTOM_PROMPT -> {
updateSetting { updateSettings.setCustomPrompt(editor.value) }
}
+
+ AiSettingsEditorField.REQUEST_PARAMETERS -> {
+ updateSetting { updateSettings.setRequestParameters(editor.value) }
+ }
}
}
@@ -304,21 +315,25 @@ class AiIntegrationSettingsViewModel
transientState.update {
it.copy(isTesting = true, errorMessage = null, apiTestError = null)
}
+ val requestId = testApiRequestId.incrementAndGet()
testApiJob =
viewModelScope.launch(Dispatchers.IO) {
try {
- testAiIntegration()
- _events.emit(R.string.ai_api_connected)
+ if (testAiIntegration() && requestId == testApiRequestId.get()) {
+ _events.emit(R.string.ai_api_connected)
+ }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
val details = e.apiTestErrorDetails()
- transientState.update {
- it.copy(apiTestError = details)
+ if (requestId == testApiRequestId.get()) {
+ transientState.update { it.copy(apiTestError = details) }
}
} finally {
- transientState.update { it.copy(isTesting = false) }
- testApiJob = null
+ if (requestId == testApiRequestId.get()) {
+ transientState.update { it.copy(isTesting = false) }
+ testApiJob = null
+ }
}
}
}
@@ -343,6 +358,10 @@ class AiIntegrationSettingsViewModel
}
private fun updateSetting(update: suspend () -> Unit) {
+ testApiRequestId.incrementAndGet()
+ testApiJob?.cancel()
+ testApiJob = null
+ transientState.update { it.copy(isTesting = false, apiTestError = null) }
viewModelScope.launch(Dispatchers.IO) {
try {
update()
@@ -363,8 +382,14 @@ class AiIntegrationSettingsViewModel
private fun editorModel(
field: AiSettingsEditorField,
value: String,
- ): AiSettingsEditorUiModel =
- AiSettingsEditorUiModel(
+ ): AiSettingsEditorUiModel {
+ val invalidParameters = field == AiSettingsEditorField.REQUEST_PARAMETERS &&
+ runCatching { AiRequestParameters.normalize(value) }.isFailure
+ val invalidEndpoint = field == AiSettingsEditorField.CUSTOM_ENDPOINT &&
+ value.trim().toHttpUrlOrNull()?.let {
+ it.username.isEmpty() && it.password.isEmpty() && it.fragment == null
+ } != true
+ return AiSettingsEditorUiModel(
field = field,
value = value,
canSave =
@@ -373,12 +398,18 @@ class AiIntegrationSettingsViewModel
AiSettingsEditorField.CUSTOM_MODEL,
-> value.isNotBlank()
- AiSettingsEditorField.CUSTOM_ENDPOINT ->
- value.startsWith("https://") || value.startsWith("http://")
+ AiSettingsEditorField.CUSTOM_ENDPOINT -> !invalidEndpoint
AiSettingsEditorField.CUSTOM_PROMPT -> true
+ AiSettingsEditorField.REQUEST_PARAMETERS -> !invalidParameters
},
+ errorMessageResId = when {
+ invalidParameters -> R.string.ai_request_parameters_invalid
+ invalidEndpoint -> R.string.ai_custom_endpoint_invalid
+ else -> null
+ },
)
+ }
private fun Throwable.shortMessage(@StringRes fallbackResId: Int): String {
val fallback = context.getString(fallbackResId)
@@ -435,6 +466,7 @@ private fun AiIntegrationSettingsData.toUiModel(
selectedModel = selectedModel,
customModel = customModel,
customPrompt = customPrompt,
+ requestParameters = requestParameters,
availableModels = transient.availableModels,
isTesting = transient.isTesting,
isFetchingModels = transient.isFetchingModels,
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/DownloadLibraryViewModel.kt b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/DownloadLibraryViewModel.kt
index c427a043ea..66d70c9a92 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/DownloadLibraryViewModel.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/DownloadLibraryViewModel.kt
@@ -40,13 +40,31 @@ enum class DownloadLibraryTab {
PROGRESS,
}
+enum class DownloadLibrarySortField {
+ NAME,
+ DATE_MODIFIED,
+}
+
+enum class DownloadLibrarySortOrder {
+ ASCENDING,
+ DESCENDING,
+}
+
+@Immutable
+data class DownloadLibrarySort(
+ val field: DownloadLibrarySortField = DownloadLibrarySortField.DATE_MODIFIED,
+ val order: DownloadLibrarySortOrder = DownloadLibrarySortOrder.DESCENDING,
+)
+
sealed interface DownloadLibraryScreenState {
val pendingRemoval: DownloadRemovalConfirmation?
+ val sort: DownloadLibrarySort
data class Loading(
val selectedTab: DownloadLibraryTab,
val query: String,
val isSearchActive: Boolean,
+ override val sort: DownloadLibrarySort,
override val pendingRemoval: DownloadRemovalConfirmation? = null,
) : DownloadLibraryScreenState
@@ -55,6 +73,7 @@ sealed interface DownloadLibraryScreenState {
val library: DownloadLibraryUiModel,
val query: String,
val isSearchActive: Boolean,
+ override val sort: DownloadLibrarySort,
override val pendingRemoval: DownloadRemovalConfirmation? = null,
) : DownloadLibraryScreenState
@@ -62,6 +81,7 @@ sealed interface DownloadLibraryScreenState {
val selectedTab: DownloadLibraryTab,
val query: String,
val isSearchActive: Boolean,
+ override val sort: DownloadLibrarySort,
override val pendingRemoval: DownloadRemovalConfirmation? = null,
) : DownloadLibraryScreenState
@@ -70,6 +90,7 @@ sealed interface DownloadLibraryScreenState {
@StringRes val messageRes: Int,
val query: String,
val isSearchActive: Boolean,
+ override val sort: DownloadLibrarySort,
override val pendingRemoval: DownloadRemovalConfirmation? = null,
) : DownloadLibraryScreenState
}
@@ -118,10 +139,27 @@ class DownloadLibraryViewModel
)
private val query = MutableStateFlow("")
private val isSearchActive = MutableStateFlow(false)
+ private val sort = MutableStateFlow(DownloadLibrarySort())
private val pendingRemoval = MutableStateFlow(null)
private val actionJobs = ConcurrentHashMap()
private val eventChannel = Channel(Channel.BUFFERED)
val events = eventChannel.receiveAsFlow()
+ private val screenControls =
+ combine(
+ selectedTab,
+ query,
+ isSearchActive,
+ sort,
+ pendingRemoval,
+ ) { tab, currentQuery, searchActive, currentSort, removalConfirmation ->
+ DownloadLibraryScreenControls(
+ selectedTab = tab,
+ query = currentQuery,
+ isSearchActive = searchActive,
+ sort = currentSort,
+ pendingRemoval = removalConfirmation,
+ )
+ }
val screenState =
combine(
@@ -131,38 +169,38 @@ class DownloadLibraryViewModel
.catch {
emit(DownloadLibraryResult.Failure)
},
- selectedTab,
- query,
- isSearchActive,
- pendingRemoval,
- ) { result, tab, currentQuery, searchActive, removalConfirmation ->
+ screenControls,
+ ) { result, controls ->
when (result) {
is DownloadLibraryResult.Data -> {
if (result.library.isEmpty) {
DownloadLibraryScreenState.Empty(
- selectedTab = tab,
- query = currentQuery,
- isSearchActive = searchActive,
- pendingRemoval = removalConfirmation,
+ selectedTab = controls.selectedTab,
+ query = controls.query,
+ isSearchActive = controls.isSearchActive,
+ sort = controls.sort,
+ pendingRemoval = controls.pendingRemoval,
)
} else {
DownloadLibraryScreenState.Success(
- selectedTab = tab,
- library = result.library.filteredBy(currentQuery),
- query = currentQuery,
- isSearchActive = searchActive,
- pendingRemoval = removalConfirmation,
+ selectedTab = controls.selectedTab,
+ library = result.library.filteredBy(controls.query).sortedBy(controls.sort),
+ query = controls.query,
+ isSearchActive = controls.isSearchActive,
+ sort = controls.sort,
+ pendingRemoval = controls.pendingRemoval,
)
}
}
DownloadLibraryResult.Failure -> {
DownloadLibraryScreenState.Error(
- selectedTab = tab,
+ selectedTab = controls.selectedTab,
messageRes = R.string.downloads_load_failed,
- query = currentQuery,
- isSearchActive = searchActive,
- pendingRemoval = removalConfirmation,
+ query = controls.query,
+ isSearchActive = controls.isSearchActive,
+ sort = controls.sort,
+ pendingRemoval = controls.pendingRemoval,
)
}
}
@@ -174,6 +212,7 @@ class DownloadLibraryViewModel
selectedTab = selectedTab.value,
query = "",
isSearchActive = false,
+ sort = sort.value,
),
)
@@ -202,6 +241,14 @@ class DownloadLibraryViewModel
query.value = ""
}
+ fun selectSortField(field: DownloadLibrarySortField) {
+ sort.value = sort.value.copy(field = field)
+ }
+
+ fun selectSortOrder(order: DownloadLibrarySortOrder) {
+ sort.value = sort.value.copy(order = order)
+ }
+
fun open(entry: DownloadEntryUiModel) {
val event =
entry.destinationRoute?.let { DownloadLibraryEvent.Navigate(it) }
@@ -212,13 +259,17 @@ class DownloadLibraryViewModel
private fun createPlaySongEvent(entry: DownloadEntryUiModel): DownloadLibraryEvent.PlaySong? {
val selectedMetadata = entry.playbackMetadata ?: return null
- val downloadedSongQueue = currentDownloadedSongQueue()
- val startIndex = downloadedSongQueue.indexOfFirst { metadata -> metadata.id == selectedMetadata.id }
+ val downloadedSongQueue =
+ currentDownloadedSongEntries()
+ .mapNotNull { queueEntry ->
+ queueEntry.playbackMetadata?.let { metadata -> queueEntry.id to metadata }
+ }
+ val startIndex = downloadedSongQueue.indexOfFirst { (entryId) -> entryId == entry.id }
return if (startIndex >= 0) {
DownloadLibraryEvent.PlaySong(
metadata = selectedMetadata,
- queue = downloadedSongQueue,
+ queue = ImmutableList.copyOf(downloadedSongQueue.map { (_, metadata) -> metadata }),
startIndex = startIndex,
)
} else {
@@ -230,17 +281,14 @@ class DownloadLibraryViewModel
}
}
- private fun currentDownloadedSongQueue(): ImmutableList {
- val state = screenState.value as? DownloadLibraryScreenState.Success ?: return ImmutableList.of()
- if (state.selectedTab != DownloadLibraryTab.DOWNLOADED) return ImmutableList.of()
-
- val metadata =
- state.library.downloadedSections
- .firstOrNull { section -> section.mediaType == DownloadMediaType.SONG }
- ?.entries
- .orEmpty()
- .mapNotNull { entry -> entry.playbackMetadata }
- return ImmutableList.copyOf(metadata)
+ private fun currentDownloadedSongEntries(): List {
+ val state = screenState.value as? DownloadLibraryScreenState.Success ?: return emptyList()
+ if (state.selectedTab != DownloadLibraryTab.DOWNLOADED) return emptyList()
+
+ return state.library.downloadedSections
+ .firstOrNull { section -> section.mediaType == DownloadMediaType.SONG }
+ ?.entries
+ .orEmpty()
}
fun pause(entry: DownloadEntryUiModel) = runAction(entry.id) { manageDownloads.pause(entry.songIds) }
@@ -300,6 +348,14 @@ class DownloadLibraryViewModel
data object Failure : DownloadLibraryResult
}
+ private data class DownloadLibraryScreenControls(
+ val selectedTab: DownloadLibraryTab,
+ val query: String,
+ val isSearchActive: Boolean,
+ val sort: DownloadLibrarySort,
+ val pendingRemoval: DownloadRemovalConfirmation?,
+ )
+
private fun DownloadLibraryUiModel.filteredBy(query: String): DownloadLibraryUiModel {
val normalizedQuery = query.trim()
if (normalizedQuery.isEmpty()) return this
@@ -320,6 +376,34 @@ class DownloadLibraryViewModel
)
}
+ private fun DownloadLibraryUiModel.sortedBy(sort: DownloadLibrarySort): DownloadLibraryUiModel {
+ fun List.sortedEntries(): List {
+ val comparator: Comparator =
+ when (sort.field) {
+ DownloadLibrarySortField.NAME ->
+ Comparator { first, second ->
+ String.CASE_INSENSITIVE_ORDER.compare(first.title, second.title)
+ }
+
+ DownloadLibrarySortField.DATE_MODIFIED -> compareBy(DownloadEntryUiModel::modifiedAtMs)
+ }.thenBy(DownloadEntryUiModel::id)
+ val sortedEntries = sortedWith(comparator)
+ return if (sort.order == DownloadLibrarySortOrder.ASCENDING) sortedEntries else sortedEntries.asReversed()
+ }
+
+ fun List.sortedSections(): List =
+ map { section ->
+ section.copy(
+ entries = section.entries.sortedEntries(),
+ )
+ }
+
+ return copy(
+ downloadedSections = downloadedSections.sortedSections(),
+ progressSections = progressSections.sortedSections(),
+ )
+ }
+
private companion object {
const val PROGRESS_TAB_ARGUMENT = "progress"
}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/HomeViewModel.kt b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/HomeViewModel.kt
index 370144a30c..1fd4b15ba9 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/HomeViewModel.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/HomeViewModel.kt
@@ -50,6 +50,7 @@ import moe.rukamori.archivetune.home.HomeEvent
import moe.rukamori.archivetune.home.HomePresentationPreferences
import moe.rukamori.archivetune.home.HomeScreenState
import moe.rukamori.archivetune.home.HomeUiState
+import moe.rukamori.archivetune.home.hasVisibleContent
import moe.rukamori.archivetune.home.LoadCommunityPlaylistPreviewsUseCase
import moe.rukamori.archivetune.home.LoadPersonalizedQuickPicksUseCase
import moe.rukamori.archivetune.home.ObserveHomePresentationPreferencesUseCase
@@ -697,7 +698,7 @@ class HomeViewModel
aiContentFilterPolicy,
),
)
- },
+ }.filter(HomePage.Section::hasVisibleContent),
)
val (pageWithoutQuickPicks, remoteQuickPicksFallback) = filteredPage.extractQuickPicks()
if (
@@ -847,7 +848,16 @@ class HomeViewModel
if (!isCurrentAccountRefresh(refreshGeneration)) return@onSuccess
_accountName.value = info.name
_accountImageUrl.value = info.thumbnailUrl
+ context.dataStore.edit { preferences ->
+ if (isCurrentAccountRefresh(refreshGeneration)) {
+ preferences[AccountNameKey] = info.name
+ info.email?.takeIf { it.isNotBlank() }?.let { email ->
+ preferences[AccountEmailKey] = email
+ }
+ }
+ }
}.onFailure { error ->
+ if (error is CancellationException) throw error
Timber.w(error, "Failed to fetch account info")
}
@@ -941,7 +951,7 @@ class HomeViewModel
aiContentFilterPolicy,
),
)
- },
+ }.filter(HomePage.Section::hasVisibleContent),
)
val (pageWithoutQuickPicks, _) = mergedPage.extractQuickPicks()
applyRemoteHomePage(
@@ -1002,7 +1012,7 @@ class HomeViewModel
aiContentFilterPolicy,
),
)
- },
+ }.filter(HomePage.Section::hasVisibleContent),
)
val (pageWithoutQuickPicks, selectedQuickPicks) = filteredPage.extractQuickPicks()
remoteQuickPicks.value = selectedQuickPicks?.takeIf { it.items.isNotEmpty() }
@@ -1162,7 +1172,7 @@ class HomeViewModel
context.dataStore.data
.map { it[InnerTubeCookieKey] }
.distinctUntilChanged()
- .collect { cookie ->
+ .collectLatest { cookie ->
try {
val isLoggedIn = hasYouTubeLoginCookie(cookie)
val shouldSynchronize =
@@ -1175,16 +1185,14 @@ class HomeViewModel
if (isLoggedIn && cookie != null && cookie.isNotEmpty()) {
if (!prepareYouTubeAccount(cookie)) {
clearAccountData()
- return@collect
+ return@collectLatest
}
- load()
-
val refreshGeneration = beginAccountRefresh()
supervisorScope {
- kotlinx.coroutines.delay(100)
launch { refreshAccountIdentity(refreshGeneration) }
launch { refreshAccountPlaylistsInternal(refreshGeneration) }
+ load()
}
if (shouldSynchronize) {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LibraryViewModels.kt b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LibraryViewModels.kt
index 81005e5821..3fabbb64c6 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LibraryViewModels.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LibraryViewModels.kt
@@ -92,6 +92,7 @@ import moe.rukamori.archivetune.models.toMediaMetadata
import moe.rukamori.archivetune.playback.DownloadUtil
import moe.rukamori.archivetune.utils.dataStore
import moe.rukamori.archivetune.utils.get
+import moe.rukamori.archivetune.utils.isLocalMediaId
import moe.rukamori.archivetune.utils.reportException
import java.text.Collator
import java.time.Duration
@@ -425,6 +426,7 @@ class LibraryPlaylistsViewModel
constructor(
@ApplicationContext context: Context,
private val database: MusicDatabase,
+ private val downloadUtil: DownloadUtil,
refreshLibrary: RefreshLibraryUseCase,
) : LibraryRefreshViewModel(refreshLibrary) {
val allPlaylists =
@@ -439,6 +441,30 @@ class LibraryPlaylistsViewModel
database.playlists(sortType, descending)
}.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
+ val downloadedPlaylistIds =
+ combine(
+ database.allPlaylistSongMapsForDownloads(),
+ downloadUtil.downloads,
+ ) { playlistSongMaps, downloads ->
+ playlistSongMaps
+ .groupBy { it.playlistId }
+ .mapNotNull { (playlistId, songMaps) ->
+ val songIds =
+ songMaps
+ .asSequence()
+ .map { it.songId }
+ .filterNot { it.isLocalMediaId() }
+ .distinct()
+ .toList()
+ playlistId.takeIf {
+ songIds.isNotEmpty() &&
+ songIds.all { songId ->
+ downloads[songId]?.state == Download.STATE_COMPLETED
+ }
+ }
+ }.toSet()
+ }.stateIn(viewModelScope, SharingStarted.Lazily, emptySet())
+
fun sync() {
refreshLibrary(LibrarySyncTarget.Playlists)
}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LocalPlaylistViewModel.kt b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LocalPlaylistViewModel.kt
index cd99e1e846..e5d2740e42 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LocalPlaylistViewModel.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LocalPlaylistViewModel.kt
@@ -128,14 +128,11 @@ class LocalPlaylistViewModel
PlaylistSongSortType.ARTIST -> {
val collator = Collator.getInstance(Locale.getDefault())
collator.strength = Collator.PRIMARY
- visibleSongs
- .sortedWith(compareBy(collator) { song -> song.song.artists.joinToString("") { artist -> artist.name } })
- .groupBy { it.song.album?.title }
- .flatMap { (_, songsByAlbum) ->
- songsByAlbum.sortedBy { playlistSong ->
- playlistSong.song.artists.joinToString("") { artist -> artist.name }
- }
- }
+ visibleSongs.sortedWith(
+ compareBy(collator) { song ->
+ song.song.artists.joinToString("") { artist -> artist.name }
+ },
+ )
}
PlaylistSongSortType.PLAY_TIME -> {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LocalSongsViewModel.kt b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LocalSongsViewModel.kt
index f33652e7a3..c5ebae5e79 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LocalSongsViewModel.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LocalSongsViewModel.kt
@@ -11,45 +11,109 @@ import androidx.annotation.StringRes
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import androidx.media3.exoplayer.offline.Download
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
+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 moe.rukamori.archivetune.R
+import moe.rukamori.archivetune.db.LocalMusicIdentity
import moe.rukamori.archivetune.db.MusicDatabase
import moe.rukamori.archivetune.localmedia.LocalSongScanConfig
import moe.rukamori.archivetune.localmedia.LocalSongScanSummary
import moe.rukamori.archivetune.localmedia.ScanLocalMusicUseCase
+import moe.rukamori.archivetune.playback.DownloadUtil
import moe.rukamori.archivetune.utils.reportException
import javax.inject.Inject
@HiltViewModel
class LocalSongsViewModel @Inject constructor(
- database: MusicDatabase,
+ private val database: MusicDatabase,
private val scanLocalMusic: ScanLocalMusicUseCase,
+ private val downloadUtil: DownloadUtil,
) : ViewModel() {
private val _scanState = MutableStateFlow(LocalSongsScanState.Empty)
val scanState = _scanState.asStateFlow()
private var scanJob: Job? = null
- val songs = database.localSongs().stateIn(
- viewModelScope,
- SharingStarted.WhileSubscribed(5_000),
- emptyList(),
- )
+ private val completedDownloadSongs =
+ downloadUtil.downloads
+ .map { downloads ->
+ downloads.values
+ .filter { download ->
+ download.state == Download.STATE_COMPLETED ||
+ download.state == Download.STATE_DOWNLOADING ||
+ download.state == Download.STATE_QUEUED
+ }
+ .map { download -> download.request.id }
+ .distinct()
+ }.distinctUntilChanged()
+ .flatMapLatest { songIds ->
+ val songFlows =
+ songIds
+ .chunked(MAX_SONG_IDS_PER_QUERY)
+ .map(database::songsByIds)
+ if (songFlows.isEmpty()) {
+ flowOf(emptyList())
+ } else {
+ combine(songFlows) { songGroups -> songGroups.flatMap { it } }
+ }
+ }
+ .map { songs -> songs.filterNot { song -> song.song.isLocal } }
+
+ val songs =
+ combine(database.localSongs(), completedDownloadSongs) { localSongs, completedDownloads ->
+ val activeDownloadIds = completedDownloads.mapTo(mutableSetOf()) { it.id }
+ (localSongs.filterNot { it.song.exportedDownloadId in activeDownloadIds } + completedDownloads).distinctBy { song ->
+ LocalMusicIdentity.trackKey(
+ title = song.song.title,
+ artists = song.artists.map { it.name },
+ durationSeconds = song.song.duration,
+ fallbackId = song.id,
+ )
+ }
+ }.stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(5_000),
+ emptyList(),
+ )
+
+ //* adding this here so we can fetch each song's play count on loading it and allowing ordering
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val playCounts = songs
+ .flatMapLatest { localSongs ->
+ val playCountFlows = localSongs.map { song -> database.getLifetimePlayCount(song.id) }
+ if (playCountFlows.isEmpty()) {
+ flowOf(emptyMap())
+ } else {
+ combine(playCountFlows) { counts ->
+ localSongs.mapIndexed { index, song -> song.id to counts[index] }.toMap()
+ }
+ }
+ }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(5_000),
+ emptyMap(),
+ )
fun scanDevice(scanConfig: LocalSongScanConfig = LocalSongScanConfig()) {
- if (scanJob?.isActive == true) return
+ scanJob?.cancel()
_scanState.value = LocalSongsScanState.Loading
scanJob = viewModelScope.launch {
try {
_scanState.value = LocalSongsScanState.Success(scanLocalMusic(scanConfig))
} catch (cancelled: CancellationException) {
- _scanState.value = LocalSongsScanState.Empty
throw cancelled
} catch (error: Exception) {
reportException(error)
@@ -59,6 +123,10 @@ class LocalSongsViewModel @Inject constructor(
}
}
}
+
+ companion object {
+ private const val MAX_SONG_IDS_PER_QUERY = 900
+ }
}
sealed interface LocalSongsScanState {
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LyricsMenuViewModel.kt b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LyricsMenuViewModel.kt
index cdfac35831..5e28252b08 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LyricsMenuViewModel.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LyricsMenuViewModel.kt
@@ -38,6 +38,7 @@ import moe.rukamori.archivetune.constants.AiCustomModelKey
import moe.rukamori.archivetune.constants.AiCustomPromptKey
import moe.rukamori.archivetune.constants.AiProvider
import moe.rukamori.archivetune.constants.AiProviderKey
+import moe.rukamori.archivetune.constants.AiRequestParametersKey
import moe.rukamori.archivetune.constants.AiSelectedModelKey
import moe.rukamori.archivetune.db.MusicDatabase
import moe.rukamori.archivetune.db.entities.LyricsEntity
@@ -347,6 +348,7 @@ class LyricsMenuViewModel
provider = prefs[AiProviderKey].toEnum(AiProvider.NONE),
apiKey = prefs[AiApiKeyKey].orEmpty(),
customEndpoint = prefs[AiCustomEndpointKey].orEmpty(),
+ requestParameters = prefs[AiRequestParametersKey].orEmpty(),
model =
if (prefs[AiProviderKey].toEnum(AiProvider.NONE) == AiProvider.CUSTOM) {
prefs[AiCustomModelKey].orEmpty()
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LyricsPlaybackClock.kt b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LyricsPlaybackClock.kt
new file mode 100644
index 0000000000..d7958dcf3f
--- /dev/null
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LyricsPlaybackClock.kt
@@ -0,0 +1,76 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.viewmodels
+
+import kotlin.math.abs
+import kotlin.math.exp
+import kotlin.math.roundToLong
+
+internal class LyricsPlaybackClock {
+ private var previousSampleNanos = Long.MIN_VALUE
+ private var previousSourcePositionMs = 0L
+ private var previousSpeed = 1f
+ private var wasAdvancing = false
+ private var positionMs = 0.0
+
+ fun reset(positionMs: Long): Long {
+ val position = positionMs.coerceAtLeast(0L)
+ this.positionMs = position.toDouble()
+ previousSampleNanos = Long.MIN_VALUE
+ previousSourcePositionMs = position
+ wasAdvancing = false
+ return position
+ }
+
+ fun update(
+ sourcePositionMs: Long,
+ sampleTimeNanos: Long,
+ speed: Float,
+ isAdvancing: Boolean,
+ durationMs: Long,
+ ): Long {
+ val source = sourcePositionMs.coerceAtLeast(0L)
+ val previousTime = previousSampleNanos
+ val advancing = isAdvancing && speed.isFinite() && speed > 0f
+ if (
+ previousTime == Long.MIN_VALUE || sampleTimeNanos <= previousTime ||
+ !advancing || !wasAdvancing || speed != previousSpeed ||
+ source < previousSourcePositionMs
+ ) {
+ positionMs = source.toDouble()
+ } else {
+ val elapsedMs = (sampleTimeNanos - previousTime) / NANOS_PER_MILLISECOND
+ val projectedPosition = positionMs + elapsedMs * speed
+ positionMs =
+ if (source != previousSourcePositionMs) {
+ val errorMs = source - projectedPosition
+ if (abs(errorMs) > MAX_PHASE_ERROR_MS) {
+ source.toDouble()
+ } else {
+ val correction = errorMs * (1.0 - exp(-elapsedMs / CORRECTION_TIME_MS))
+ (projectedPosition + correction).coerceAtLeast(positionMs)
+ }
+ } else {
+ projectedPosition
+ }
+ }
+ val maximumPositionMs = if (durationMs > 0L) durationMs.toDouble() else Long.MAX_VALUE.toDouble()
+ positionMs = positionMs.coerceIn(0.0, maximumPositionMs)
+ previousSampleNanos = sampleTimeNanos
+ previousSourcePositionMs = source
+ previousSpeed = speed
+ wasAdvancing = advancing
+ return positionMs.roundToLong()
+ }
+
+ private companion object {
+ const val NANOS_PER_MILLISECOND = 1_000_000.0
+ const val MAX_PHASE_ERROR_MS = 32.0
+ const val CORRECTION_TIME_MS = 60.0
+ }
+}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LyricsRenderViewModel.kt b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LyricsRenderViewModel.kt
index c854556c52..70e58a4c1e 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LyricsRenderViewModel.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/LyricsRenderViewModel.kt
@@ -51,8 +51,44 @@ class LyricsRenderViewModel
private val _state = MutableStateFlow(LyricsRenderScreenState.Loading)
val state: StateFlow = _state.asStateFlow()
+ private val _playbackPositionMs = MutableStateFlow(0L)
+ val playbackPositionMs: StateFlow = _playbackPositionMs.asStateFlow()
+ private val playbackClock = LyricsPlaybackClock()
+
private var observationJob: Job? = null
private var binding: Binding? = null
+ private var translationVisibilityJob: Job? = null
+
+ fun onShowTranslationsChanged(show: Boolean) {
+ val lyrics = (_state.value as? LyricsRenderScreenState.Success)?.lyrics ?: return
+ if (!lyrics.hasTranslations) return
+ translationVisibilityJob?.cancel()
+ translationVisibilityJob = viewModelScope.launch {
+ try {
+ prepareLyrics.setShowTranslations(show)
+ } catch (exception: CancellationException) {
+ throw exception
+ } catch (exception: Exception) {
+ reportException(exception)
+ _state.value = LyricsRenderScreenState.Error(LyricsRenderError.INVALID_LYRICS)
+ }
+ }
+ }
+
+ fun onPlaybackPositionChanged(positionMs: Long) {
+ _playbackPositionMs.value = playbackClock.reset(positionMs)
+ }
+
+ fun onPlaybackFrame(
+ positionMs: Long,
+ sampleTimeNanos: Long,
+ playbackSpeed: Float,
+ isAdvancing: Boolean,
+ durationMs: Long,
+ ) {
+ _playbackPositionMs.value =
+ playbackClock.update(positionMs, sampleTimeNanos, playbackSpeed, isAdvancing, durationMs)
+ }
fun bind(
mediaId: String,
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/MainMenuViewModel.kt b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/MainMenuViewModel.kt
new file mode 100644
index 0000000000..abeafe5e86
--- /dev/null
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/MainMenuViewModel.kt
@@ -0,0 +1,86 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.viewmodels
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Immutable
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import moe.rukamori.archivetune.R
+import moe.rukamori.archivetune.auth.MenuProfile
+import moe.rukamori.archivetune.auth.ObserveMenuProfileUseCase
+import timber.log.Timber
+import javax.inject.Inject
+
+@Immutable
+sealed interface MenuProfileState {
+ data object Loading : MenuProfileState
+
+ data class Success(val profile: MenuProfile) : MenuProfileState
+
+ data object Empty : MenuProfileState
+
+ data class Error(@param:StringRes val messageResId: Int) : MenuProfileState
+}
+
+enum class MainMenuDestination(val route: String) {
+ Profile("settings/account"),
+ NewRelease("new_release"),
+ News("news"),
+ History("history"),
+ Settings("settings"),
+}
+
+@HiltViewModel
+class MainMenuViewModel
+ @Inject
+ constructor(
+ observeMenuProfile: ObserveMenuProfileUseCase,
+ ) : ViewModel() {
+ private val _expanded = MutableStateFlow(false)
+ val expanded = _expanded.asStateFlow()
+
+ val profileState: StateFlow =
+ observeMenuProfile()
+ .map { profile ->
+ if (profile != null) MenuProfileState.Success(profile) else MenuProfileState.Empty
+ }.catch {
+ Timber.w("Unable to observe overflow menu account")
+ emit(MenuProfileState.Error(R.string.error_unknown))
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MenuProfileState.Loading)
+
+ private val navigation = Channel(Channel.BUFFERED)
+ val destinations = navigation.receiveAsFlow()
+
+ fun toggle() {
+ _expanded.value = !_expanded.value
+ }
+
+ fun dismiss() {
+ _expanded.value = false
+ }
+
+ fun select(destination: MainMenuDestination) {
+ if (!_expanded.value) return
+ dismiss()
+ viewModelScope.launch {
+ navigation.send(destination)
+ }
+ }
+ }
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/OnlineSearchViewModel.kt b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/OnlineSearchViewModel.kt
index d3dda9bf11..a86dfe45f8 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/OnlineSearchViewModel.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/OnlineSearchViewModel.kt
@@ -18,11 +18,19 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+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 moe.rukamori.archivetune.aicontentfilter.FilterAiContentUseCase
import moe.rukamori.archivetune.aicontentfilter.LoadAiContentFilterPolicyUseCase
import moe.rukamori.archivetune.constants.HideExplicitKey
import moe.rukamori.archivetune.constants.HideVideoKey
+import moe.rukamori.archivetune.db.MusicDatabase
import moe.rukamori.archivetune.innertube.YouTube
import moe.rukamori.archivetune.innertube.YouTube.SearchFilter.Companion.FILTER_ALBUM
import moe.rukamori.archivetune.innertube.YouTube.SearchFilter.Companion.FILTER_ARTIST
@@ -31,12 +39,15 @@ import moe.rukamori.archivetune.innertube.YouTube.SearchFilter.Companion.FILTER_
import moe.rukamori.archivetune.innertube.YouTube.SearchFilter.Companion.FILTER_PODCAST
import moe.rukamori.archivetune.innertube.YouTube.SearchFilter.Companion.FILTER_SONG
import moe.rukamori.archivetune.innertube.YouTube.SearchFilter.Companion.FILTER_VIDEO
+import moe.rukamori.archivetune.innertube.models.PlaylistItem
import moe.rukamori.archivetune.innertube.models.SongItem
import moe.rukamori.archivetune.innertube.models.YTItem
import moe.rukamori.archivetune.innertube.models.filterExplicit
import moe.rukamori.archivetune.innertube.models.filterVideo
import moe.rukamori.archivetune.innertube.pages.SearchSummaryPage
import moe.rukamori.archivetune.models.ItemsPage
+import moe.rukamori.archivetune.playback.DownloadUtil
+import androidx.media3.exoplayer.offline.Download
import moe.rukamori.archivetune.ui.screens.search.OnlineSearchResultArgument
import moe.rukamori.archivetune.ui.screens.search.decodeOnlineSearchQuery
import moe.rukamori.archivetune.utils.dataStore
@@ -57,6 +68,8 @@ class OnlineSearchViewModel
savedStateHandle: SavedStateHandle,
private val loadAiContentFilterPolicy: LoadAiContentFilterPolicyUseCase,
private val filterAiContent: FilterAiContentUseCase,
+ private val database: MusicDatabase,
+ downloadUtil: DownloadUtil,
) : ViewModel() {
val query =
decodeOnlineSearchQuery(
@@ -65,6 +78,32 @@ class OnlineSearchViewModel
val filter = MutableStateFlow(null)
var summaryPage by mutableStateOf(null)
val viewStateMap = mutableStateMapOf()
+ private val downloadedSongs =
+ downloadUtil.downloads
+ .map { downloads ->
+ downloads.values
+ .filter { download -> download.state == Download.STATE_COMPLETED }
+ .map { download -> download.request.id }
+ .distinct()
+ }.distinctUntilChanged()
+ .flatMapLatest { songIds ->
+ val songFlows = songIds.chunked(MAX_SONG_IDS_PER_QUERY).map(database::songsByIds)
+ if (songFlows.isEmpty()) {
+ flowOf(emptyList())
+ } else {
+ combine(songFlows) { songGroups -> songGroups.flatMap { it } }
+ }
+ }.map { songs -> songs.filterNot { song -> song.song.isLocal } }
+ val localSongs =
+ combine(database.localSongs(), downloadedSongs) { deviceSongs, downloads ->
+ (deviceSongs + downloads).distinctBy { song -> song.id }
+ }.map { songs ->
+ songs.filter { song ->
+ song.song.title.contains(query, ignoreCase = true) ||
+ song.song.albumName.orEmpty().contains(query, ignoreCase = true) ||
+ song.artists.any { artist -> artist.name.contains(query, ignoreCase = true) }
+ }
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val allModeFilters =
listOf(
@@ -79,6 +118,10 @@ class OnlineSearchViewModel
private var isSummaryLoading = false
private val loadingFilters = mutableSetOf()
+ companion object {
+ private const val MAX_SONG_IDS_PER_QUERY = 900
+ }
+
init {
viewModelScope.launch {
filter.collect { selectedFilter ->
@@ -200,7 +243,11 @@ class OnlineSearchViewModel
.withIndex()
.sortedWith(
compareByDescending> {
- (it.value as? SongItem)?.viewCount ?: Long.MIN_VALUE
+ when (val item = it.value) {
+ is SongItem -> item.viewCount
+ is PlaylistItem -> item.viewCount
+ else -> null
+ } ?: Long.MIN_VALUE
}.thenBy { it.index },
).map { it.value }
}
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/PlaybackWaveformViewModel.kt b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/PlaybackWaveformViewModel.kt
new file mode 100644
index 0000000000..a9251ae090
--- /dev/null
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/PlaybackWaveformViewModel.kt
@@ -0,0 +1,96 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.viewmodels
+
+import androidx.compose.runtime.Immutable
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.common.collect.ImmutableList
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import moe.rukamori.archivetune.playback.WaveformRepository
+import timber.log.Timber
+import javax.inject.Inject
+
+@Immutable
+data class SongWaveform(
+ val amplitudes: ImmutableList,
+)
+
+sealed interface PlaybackWaveformState {
+ data object Empty : PlaybackWaveformState
+
+ data class Loading(
+ val mediaId: String,
+ ) : PlaybackWaveformState
+
+ data class Ready(
+ val mediaId: String,
+ val waveform: SongWaveform,
+ ) : PlaybackWaveformState
+
+ data class Unavailable(
+ val mediaId: String,
+ ) : PlaybackWaveformState
+}
+
+@HiltViewModel
+class PlaybackWaveformViewModel
+ @Inject
+ constructor(
+ private val repository: WaveformRepository,
+ ) : ViewModel() {
+ private val _state = MutableStateFlow(PlaybackWaveformState.Empty)
+ val state: StateFlow = _state.asStateFlow()
+ private var generationJob: Job? = null
+
+ fun load(
+ mediaId: String,
+ durationMs: Int,
+ ) {
+ when (val current = _state.value) {
+ is PlaybackWaveformState.Loading -> if (current.mediaId == mediaId) return
+ is PlaybackWaveformState.Ready -> if (current.mediaId == mediaId) return
+ is PlaybackWaveformState.Unavailable -> if (current.mediaId == mediaId) return
+ PlaybackWaveformState.Empty -> Unit
+ }
+ generationJob?.cancel()
+ _state.value = PlaybackWaveformState.Loading(mediaId)
+ generationJob =
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val amplitudes = repository.getOrGenerate(mediaId, durationMs)
+ val waveform =
+ withContext(Dispatchers.Default) {
+ SongWaveform(
+ amplitudes = ImmutableList.copyOf(amplitudes.map { sample -> (sample.toInt() and 0xff) / 255f }),
+ )
+ }
+ _state.value = PlaybackWaveformState.Ready(mediaId, waveform)
+ } catch (exception: CancellationException) {
+ throw exception
+ } catch (exception: Exception) {
+ Timber.w(exception, "Unable to generate a waveform for %s", mediaId)
+ _state.value = PlaybackWaveformState.Unavailable(mediaId)
+ }
+ }
+ }
+
+ fun cancel() {
+ generationJob?.cancel()
+ generationJob = null
+ _state.value = PlaybackWaveformState.Empty
+ }
+ }
diff --git a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/StorageSettingsViewModel.kt b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/StorageSettingsViewModel.kt
index cf2d59a12c..514b1cd61e 100644
--- a/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/StorageSettingsViewModel.kt
+++ b/app/src/main/kotlin/moe/rukamori/archivetune/viewmodels/StorageSettingsViewModel.kt
@@ -7,6 +7,7 @@
package moe.rukamori.archivetune.viewmodels
+import android.net.Uri
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -40,6 +41,10 @@ import moe.rukamori.archivetune.storage.StorageLocationOption
import moe.rukamori.archivetune.storage.StorageLocationOptions
import moe.rukamori.archivetune.storage.StorageMigrationPhase
import moe.rukamori.archivetune.storage.StorageMigrationProgress
+import moe.rukamori.archivetune.downloads.DownloadFolderSelection
+import moe.rukamori.archivetune.downloads.ObserveDownloadFolderUseCase
+import moe.rukamori.archivetune.downloads.SetDownloadFolderUseCase
+import timber.log.Timber
import javax.inject.Inject
sealed interface StorageSettingsScreenState {
@@ -61,6 +66,7 @@ data class StorageSettingsUiModel(
val folder: StorageFolderUiModel,
val storageOptions: StorageLocationUiOptions,
val picker: StorageLocationPickerUiModel,
+ val downloadFolderName: String?,
val migration: StorageMigrationUiModel?,
val cacheClear: StorageCacheClearUiModel?,
)
@@ -138,7 +144,9 @@ class StorageSettingsViewModel
@Inject
constructor(
observeStorageFolders: ObserveStorageFoldersUseCase,
+ observeDownloadFolder: ObserveDownloadFolderUseCase,
private val setStorageFolder: SetStorageFolderUseCase,
+ private val setDownloadFolderUseCase: SetDownloadFolderUseCase,
private val clearStorageCache: ClearStorageCacheUseCase,
) : ViewModel() {
private val _effects = MutableSharedFlow(extraBufferCapacity = 1)
@@ -154,7 +162,8 @@ class StorageSettingsViewModel
pickerState,
migrationState,
cacheClearState,
- ) { selection, picker, migration, cacheClear ->
+ observeDownloadFolder(),
+ ) { selection, picker, migration, cacheClear, downloadFolder ->
val selectedOptionId =
picker.selectedOptionId
?.takeIf { optionId ->
@@ -167,6 +176,7 @@ class StorageSettingsViewModel
picker = normalizedPicker,
migration = migration,
cacheClear = cacheClear,
+ downloadFolder = downloadFolder,
)
}.map { payload ->
StorageSettingsScreenState.Success(
@@ -174,6 +184,7 @@ class StorageSettingsViewModel
folder = payload.selection.toUiModel(),
storageOptions = payload.selection.options.toUiOptions(),
picker = payload.picker,
+ downloadFolderName = payload.downloadFolder.displayName,
migration = payload.migration,
cacheClear = payload.cacheClear,
),
@@ -222,6 +233,30 @@ class StorageSettingsViewModel
selectStorageLocation(optionId)
}
+ fun setDownloadFolder(uri: Uri) {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ setDownloadFolderUseCase(uri)
+ _effects.emit(
+ StorageSettingsEffect(
+ messageResId = R.string.download_folder_saved,
+ restartApp = false,
+ ),
+ )
+ } catch (exception: CancellationException) {
+ throw exception
+ } catch (exception: Exception) {
+ Timber.e(exception, "Unable to save the selected download folder")
+ _effects.emit(
+ StorageSettingsEffect(
+ messageResId = R.string.download_folder_save_failed,
+ restartApp = false,
+ ),
+ )
+ }
+ }
+ }
+
fun clearSongCache(showFeedback: Boolean = true) {
clearCache(StorageCacheKind.SONGS, showFeedback)
}
@@ -365,4 +400,5 @@ private data class StorageSettingsStatePayload(
val picker: StorageLocationPickerUiModel,
val migration: StorageMigrationUiModel?,
val cacheClear: StorageCacheClearUiModel?,
+ val downloadFolder: DownloadFolderSelection,
)
diff --git a/app/src/main/res/values-ar-rEG/archivetune_strings.xml b/app/src/main/res/values-ar-rEG/archivetune_strings.xml
index 1a71e02db9..c4d865bba0 100644
--- a/app/src/main/res/values-ar-rEG/archivetune_strings.xml
+++ b/app/src/main/res/values-ar-rEG/archivetune_strings.xml
@@ -1,6 +1,8 @@
استكشف
+ الملفات المحلية
+ تنزيل كل الأغاني في قائمة الانتظار
دوس عشان تسمع
بنسمع…
بنحلل…
diff --git a/app/src/main/res/values-ar/archivetune_strings.xml b/app/src/main/res/values-ar/archivetune_strings.xml
index bff08bcc46..d62b4fbec2 100644
--- a/app/src/main/res/values-ar/archivetune_strings.xml
+++ b/app/src/main/res/values-ar/archivetune_strings.xml
@@ -2,6 +2,8 @@
تصفح
على الجهاز
+ الملفات المحلية
+ تنزيل جميع الأغاني في قائمة الانتظار
عن بعد
القوائم
رجوع
diff --git a/app/src/main/res/values-fr/archivetune_strings.xml b/app/src/main/res/values-fr/archivetune_strings.xml
index 48a14b155d..7f7b420c73 100644
--- a/app/src/main/res/values-fr/archivetune_strings.xml
+++ b/app/src/main/res/values-fr/archivetune_strings.xml
@@ -2,6 +2,8 @@
Explorer
Locale
+ Local
+ Télécharger toutes les chansons de la file
Distant
Graphiques
Retour
diff --git a/app/src/main/res/values/archivetune_strings.xml b/app/src/main/res/values/archivetune_strings.xml
index 4ca04bd919..d9e7e7f417 100644
--- a/app/src/main/res/values/archivetune_strings.xml
+++ b/app/src/main/res/values/archivetune_strings.xml
@@ -62,6 +62,9 @@
Clear selection
Scan device
Scanning device…
+ Local
+ Download all queued songs
+ Queue opening diagnostics
Play local songs on this device.
Allow storage access to scan audio files stored on this device.
No local songs found
@@ -327,6 +330,11 @@
Lyrics background style
Default
Mini player background style
+ Navigation bar background style
+ Navigation bar mode
+ Connected
+ Standalone
+ Acrylic
Follow theme
Gradient
Player design style
@@ -404,6 +412,10 @@
Mini player swipe sensitivity
Storage folder
Choose folder
+ Download location
+ Downloads stay in app storage until you choose a folder.
+ Download folder saved.
+ Unable to save this download folder.
Storage location
Internal storage
SD card
@@ -676,6 +688,10 @@
None (Disabled)
OpenRouter
Custom endpoint
+ Enter an HTTP or HTTPS endpoint without embedded credentials or a fragment.
+ Request parameters
+ OpenAI-compatible JSON options, for example {\"reasoning_effort\":\"low\",\"max_completion_tokens\":8192,\"temperature\":null}. Use null to omit a parameter; leave blank for defaults.
+ Enter a JSON object with valid parameter values. Model, messages, stream, and n are managed by the app.
API key
Required before AI features can run.
Configured
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8d6105f326..1ef61c5dc6 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -172,6 +172,9 @@
Remove download
Are you sure you want to remove this downloaded item? This action cannot be undone.
Are you sure you want to remove all downloads in this section? This action cannot be undone.
+ Delete file
+ Delete \u0022%1$s\u0022 from this device? This cannot be undone.
+ Dismiss player
%1$d%%
Import playlist
Import failed
@@ -220,6 +223,7 @@
Remove from queue
Removed \"%s\" from queue
Removed %d songs from queue
+ Queue changed. Unable to undo.
Search online
Tempo and Pitch
Tempo
@@ -489,6 +493,7 @@
Thick
Circular
Simple
+ Waveform
Misc
Vibration
Enable haptic feedback on interactive elements
@@ -836,6 +841,7 @@
Language
Lyrics
Translation saved
+ Show translation
Translation failed
Button Options
Image Options
diff --git a/app/src/test/kotlin/moe/rukamori/archivetune/db/LocalMusicIdentityTest.kt b/app/src/test/kotlin/moe/rukamori/archivetune/db/LocalMusicIdentityTest.kt
new file mode 100644
index 0000000000..f754175c80
--- /dev/null
+++ b/app/src/test/kotlin/moe/rukamori/archivetune/db/LocalMusicIdentityTest.kt
@@ -0,0 +1,51 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.db
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Test
+
+class LocalMusicIdentityTest {
+ @Test
+ fun trackKey_normalizesTitleArtistsAndArtistOrdering() {
+ val first = LocalMusicIdentity.trackKey(" Song Name ", listOf("Artist B", "Artist A"), 180, "first")
+ val duplicate = LocalMusicIdentity.trackKey("song name", listOf("artist a", "artist b"), 180, "second")
+
+ assertEquals(first, duplicate)
+ }
+
+ @Test
+ fun trackKey_keepsDifferentDurationsSeparate() {
+ val first = LocalMusicIdentity.trackKey("Song", listOf("Artist"), 180, "first")
+ val alternate = LocalMusicIdentity.trackKey("Song", listOf("Artist"), 181, "alternate")
+
+ assertNotEquals(first, alternate)
+ }
+
+ @Test
+ fun trackKey_usesStableIdWhenMetadataIsIncomplete() {
+ val first = LocalMusicIdentity.trackKey("Song", emptyList(), 180, "first")
+ val second = LocalMusicIdentity.trackKey("Song", emptyList(), 180, "second")
+
+ assertNotEquals(first, second)
+ }
+
+ @Test
+ fun downloadIdFromExportedFilename_readsTaskIdSuffix() {
+ assertEquals(
+ "video-id_123",
+ LocalMusicIdentity.downloadIdFromExportedFilename("Song - Artist [video-id_123].opus"),
+ )
+ }
+
+ @Test
+ fun downloadIdFromExportedFilename_ignoresFilesWithoutTaskIdSuffix() {
+ assertEquals(null, LocalMusicIdentity.downloadIdFromExportedFilename("Song - Artist.opus"))
+ }
+}
diff --git a/app/src/test/kotlin/moe/rukamori/archivetune/extensions/QueueUndoTest.kt b/app/src/test/kotlin/moe/rukamori/archivetune/extensions/QueueUndoTest.kt
new file mode 100644
index 0000000000..c9dde4ea05
--- /dev/null
+++ b/app/src/test/kotlin/moe/rukamori/archivetune/extensions/QueueUndoTest.kt
@@ -0,0 +1,60 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.extensions
+
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class QueueUndoTest {
+ @Test
+ fun buildRestoredShuffleOrderIndices_preservesQueueSequence() {
+ val originalShuffleOrder = listOf(4, 1, 3, 0, 2)
+ val restored = buildRestoredShuffleOrderIndices(originalShuffleOrder)
+
+ assertArrayEquals(intArrayOf(4, 1, 3, 0, 2), restored)
+ assertEquals(5, restored.size)
+ }
+
+ @Test
+ fun buildRestoredShuffleOrderIndices_handlesEmptyList() {
+ val restored = buildRestoredShuffleOrderIndices(emptyList())
+
+ assertArrayEquals(intArrayOf(), restored)
+ assertEquals(0, restored.size)
+ }
+
+ @Test
+ fun queueUndo_restoresSingleItemOrder_withoutReShuffling() {
+ // Original playback queue order: tracks with timeline indices [3, 0, 2, 1]
+ val originalQueueTimelineIndices = listOf(3, 0, 2, 1)
+
+ // User removes track at queue index 1 (timeline index 0)
+ val removedTimelineIndex = 0
+ val remainingQueue = originalQueueTimelineIndices.filter { it != removedTimelineIndex }
+ assertEquals(listOf(3, 2, 1), remainingQueue)
+
+ // When undo is clicked, the restored shuffle order must match originalQueueTimelineIndices exactly
+ val restoredOrder = buildRestoredShuffleOrderIndices(originalQueueTimelineIndices)
+ assertArrayEquals(intArrayOf(3, 0, 2, 1), restoredOrder)
+ }
+
+ @Test
+ fun queueUndo_restoresMultipleItemsOrder_withoutReShuffling() {
+ val originalQueueTimelineIndices = listOf(5, 2, 0, 4, 1, 3)
+
+ // User removes tracks with timeline indices 2 and 4
+ val removedIndices = setOf(2, 4)
+ val remainingQueue = originalQueueTimelineIndices.filter { it !in removedIndices }
+ assertEquals(listOf(5, 0, 1, 3), remainingQueue)
+
+ // When undo is clicked, original sequence is restored
+ val restoredOrder = buildRestoredShuffleOrderIndices(originalQueueTimelineIndices)
+ assertArrayEquals(intArrayOf(5, 2, 0, 4, 1, 3), restoredOrder)
+ }
+}
diff --git a/app/src/test/kotlin/moe/rukamori/archivetune/localmedia/LocalSongScanConfigTest.kt b/app/src/test/kotlin/moe/rukamori/archivetune/localmedia/LocalSongScanConfigTest.kt
new file mode 100644
index 0000000000..71b6c52915
--- /dev/null
+++ b/app/src/test/kotlin/moe/rukamori/archivetune/localmedia/LocalSongScanConfigTest.kt
@@ -0,0 +1,51 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.localmedia
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class LocalSongScanConfigTest {
+ @Test
+ fun defaultScanExcludesAndroidStorage() {
+ val config = LocalSongScanConfig()
+
+ assertTrue(config.shouldScanFolder("Music/Albums"))
+ assertFalse(config.shouldScanFolder("Android/media/com.whatsapp"))
+ }
+
+ @Test
+ fun includedFoldersLimitScanToSelectedFolderAndDescendants() {
+ val config = LocalSongScanConfig(includedFolders = setOf("Music/Albums"))
+
+ assertTrue(config.shouldScanFolder("Music/Albums/Artist"))
+ assertFalse(config.shouldScanFolder("Music/Podcasts"))
+ assertFalse(config.shouldScanFolder("Android/media/com.whatsapp"))
+ }
+
+ @Test
+ fun excludedFoldersTakePrecedenceOverIncludedFolders() {
+ val config =
+ LocalSongScanConfig(
+ includedFolders = setOf("Music"),
+ excludedFolders = setOf("Music/WhatsApp"),
+ )
+
+ assertTrue(config.shouldScanFolder("Music/Albums"))
+ assertFalse(config.shouldScanFolder("Music/WhatsApp/Audio"))
+ }
+
+ @Test
+ fun selectedAndroidSubfolderCanBeScannedExplicitly() {
+ val config = LocalSongScanConfig(includedFolders = setOf("Android/media/MyMusic"))
+
+ assertTrue(config.shouldScanFolder("Android/media/MyMusic"))
+ assertFalse(config.shouldScanFolder("Android/media/com.whatsapp"))
+ }
+}
diff --git a/app/src/test/kotlin/moe/rukamori/archivetune/lyrics/LyricsUtilsTest.kt b/app/src/test/kotlin/moe/rukamori/archivetune/lyrics/LyricsUtilsTest.kt
new file mode 100644
index 0000000000..22cdb68605
--- /dev/null
+++ b/app/src/test/kotlin/moe/rukamori/archivetune/lyrics/LyricsUtilsTest.kt
@@ -0,0 +1,63 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.lyrics
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+
+class LyricsUtilsTest {
+ @Test
+ fun parseLyricsPreservesLyricsWhenPrecededByEmptyLineAtSameTimestamp() {
+ val lrc =
+ """
+ [00:16.50] E ho bisogno di amici che mi facciano stare bene
+ [00:21.50]
+ [00:21.50] Respiravo una cosa
+ [00:24.30] Una cosa pericolosa
+ """.trimIndent()
+
+ val parsed = LyricsUtils.parseLyrics(lrc)
+ assertEquals(3, parsed.size)
+ assertEquals(16500L, parsed[0].time)
+ assertEquals("E ho bisogno di amici che mi facciano stare bene", parsed[0].text)
+ assertEquals(21500L, parsed[1].time)
+ assertEquals("Respiravo una cosa", parsed[1].text)
+ assertEquals(24300L, parsed[2].time)
+ assertEquals("Una cosa pericolosa", parsed[2].text)
+ }
+
+ @Test
+ fun parseLyricsIgnoresTrailingEmptyLineAtSameTimestamp() {
+ val lrc =
+ """
+ [00:21.50] Respiravo una cosa
+ [00:21.50]
+ """.trimIndent()
+
+ val parsed = LyricsUtils.parseLyrics(lrc)
+ assertEquals(1, parsed.size)
+ assertEquals(21500L, parsed[0].time)
+ assertEquals("Respiravo una cosa", parsed[0].text)
+ }
+
+ @Test
+ fun parseLyricsMergesDuplicateTimestampTranslation() {
+ val lrc =
+ """
+ [00:10.00] Original English
+ [00:10.00] Spanish Translation
+ """.trimIndent()
+
+ val parsed = LyricsUtils.parseLyrics(lrc)
+ assertEquals(1, parsed.size)
+ assertEquals(10000L, parsed[0].time)
+ assertEquals("Original English", parsed[0].text)
+ assertEquals("Spanish Translation", parsed[0].providerTranslationText)
+ }
+}
diff --git a/app/src/test/kotlin/moe/rukamori/archivetune/utils/ScrobbleMetadataTest.kt b/app/src/test/kotlin/moe/rukamori/archivetune/utils/ScrobbleMetadataTest.kt
new file mode 100644
index 0000000000..518dc197e9
--- /dev/null
+++ b/app/src/test/kotlin/moe/rukamori/archivetune/utils/ScrobbleMetadataTest.kt
@@ -0,0 +1,101 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.utils
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ScrobbleMetadataTest {
+ @Test
+ fun normalizeLeavesCleanTrackUntouched() {
+ val (track, artist) =
+ normalizeScrobbleMetadata(
+ title = "RATHER LIE",
+ artists = listOf("Playboi Carti", "The Weeknd"),
+ )
+ assertEquals("RATHER LIE", track)
+ assertEquals("Playboi Carti, The Weeknd", artist)
+ }
+
+ @Test
+ fun normalizeExtractsWithArtistFromParentheses() {
+ val (track, artist) =
+ normalizeScrobbleMetadata(
+ title = "RATHER LIE (with The Weeknd)",
+ artists = listOf("Playboi Carti"),
+ )
+ assertEquals("RATHER LIE", track)
+ assertEquals("Playboi Carti, The Weeknd", artist)
+ }
+
+ @Test
+ fun normalizeExtractsWithArtistFromSquareBrackets() {
+ val (track, artist) =
+ normalizeScrobbleMetadata(
+ title = "RATHER LIE [with The Weeknd]",
+ artists = listOf("Playboi Carti"),
+ )
+ assertEquals("RATHER LIE", track)
+ assertEquals("Playboi Carti, The Weeknd", artist)
+ }
+
+ @Test
+ fun normalizeExtractsFeatArtistFromParentheses() {
+ val (track, artist) =
+ normalizeScrobbleMetadata(
+ title = "First Person Shooter (feat. J. Cole)",
+ artists = listOf("Drake"),
+ )
+ assertEquals("First Person Shooter", track)
+ assertEquals("Drake, J. Cole", artist)
+ }
+
+ @Test
+ fun normalizeExtractsMultipleFeaturedArtists() {
+ val (track, artist) =
+ normalizeScrobbleMetadata(
+ title = "Creepin' (with The Weeknd & 21 Savage)",
+ artists = listOf("Metro Boomin"),
+ )
+ assertEquals("Creepin'", track)
+ assertEquals("Metro Boomin, The Weeknd, 21 Savage", artist)
+ }
+
+ @Test
+ fun normalizeDeduplicatesExistingFeaturedArtist() {
+ val (track, artist) =
+ normalizeScrobbleMetadata(
+ title = "RATHER LIE (with The Weeknd)",
+ artists = listOf("Playboi Carti", "The Weeknd"),
+ )
+ assertEquals("RATHER LIE", track)
+ assertEquals("Playboi Carti, The Weeknd", artist)
+ }
+
+ @Test
+ fun normalizeExtractsSuffixFeat() {
+ val (track, artist) =
+ normalizeScrobbleMetadata(
+ title = "Forever - feat. Kanye West, Lil Wayne & Eminem",
+ artists = listOf("Drake"),
+ )
+ assertEquals("Forever", track)
+ assertEquals("Drake, Kanye West, Lil Wayne, Eminem", artist)
+ }
+
+ @Test
+ fun normalizeDoesNotStripBareWordWithInTitle() {
+ val (track, artist) =
+ normalizeScrobbleMetadata(
+ title = "Stay With Me",
+ artists = listOf("Sam Smith"),
+ )
+ assertEquals("Stay With Me", track)
+ assertEquals("Sam Smith", artist)
+ }
+}
diff --git a/app/src/test/kotlin/moe/rukamori/archivetune/viewmodels/PlaylistSongSortTest.kt b/app/src/test/kotlin/moe/rukamori/archivetune/viewmodels/PlaylistSongSortTest.kt
new file mode 100644
index 0000000000..8b6dca6f96
--- /dev/null
+++ b/app/src/test/kotlin/moe/rukamori/archivetune/viewmodels/PlaylistSongSortTest.kt
@@ -0,0 +1,61 @@
+/*
+ * ArchiveTune (2026)
+ * © Rukamori — github.com/rukamori
+ * GPL-3.0 License | Contributors: see git history
+ * Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
+ */
+
+package moe.rukamori.archivetune.viewmodels
+
+import moe.rukamori.archivetune.constants.PlaylistSongSortType
+import moe.rukamori.archivetune.db.entities.AlbumEntity
+import moe.rukamori.archivetune.db.entities.ArtistEntity
+import moe.rukamori.archivetune.db.entities.PlaylistSong
+import moe.rukamori.archivetune.db.entities.PlaylistSongMap
+import moe.rukamori.archivetune.db.entities.Song
+import moe.rukamori.archivetune.db.entities.SongEntity
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.text.Collator
+import java.util.Locale
+
+class PlaylistSongSortTest {
+ @Test
+ fun sortSongsByArtistDoesNotGroupByAlbum() {
+ val collator = Collator.getInstance(Locale.getDefault())
+ collator.strength = Collator.PRIMARY
+
+ val songZoe =
+ PlaylistSong(
+ map = PlaylistSongMap(id = 1, playlistId = "p1", songId = "s1"),
+ song =
+ Song(
+ song = SongEntity(id = "s1", title = "Track 1"),
+ artists = listOf(ArtistEntity(id = "a1", name = "Zoe")),
+ album = AlbumEntity(id = "alb1", title = "AAA Album"),
+ ),
+ )
+ val songAlice =
+ PlaylistSong(
+ map = PlaylistSongMap(id = 2, playlistId = "p1", songId = "s2"),
+ song =
+ Song(
+ song = SongEntity(id = "s2", title = "Track 2"),
+ artists = listOf(ArtistEntity(id = "a2", name = "Alice")),
+ album = AlbumEntity(id = "alb2", title = "ZZZ Album"),
+ ),
+ )
+
+ val songs = listOf(songZoe, songAlice)
+
+ val sorted =
+ songs.sortedWith(
+ compareBy(collator) { song ->
+ song.song.artists.joinToString("") { artist -> artist.name }
+ },
+ )
+
+ assertEquals("Alice", sorted[0].song.artists.first().name)
+ assertEquals("Zoe", sorted[1].song.artists.first().name)
+ }
+}
diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts
index 778ae4e338..eeeeeaa0ec 100644
--- a/buildSrc/build.gradle.kts
+++ b/buildSrc/build.gradle.kts
@@ -1,5 +1,5 @@
plugins {
- kotlin("jvm") version "2.4.0"
+ kotlin("jvm") version "2.4.21"
}
repositories {
diff --git a/core b/core
index f9e316fb7e..4421ab42ad 160000
--- a/core
+++ b/core
@@ -1 +1 @@
-Subproject commit f9e316fb7ec10d14487004db07e3b37d7afdfef3
+Subproject commit 4421ab42aded87e28b739fea8d8d203e2dfb7d26
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_2.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_2.jpg
index 88a3b03aee..4e3df2c5af 100644
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_2.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_2.jpg differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_3.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_3.jpg
index d339a33e75..7be976517e 100644
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_3.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_3.jpg differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_4.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_4.jpg
index 1868156f60..0b3ce876ce 100644
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_4.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_4.jpg differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_5.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_5.jpg
index adfd82b97c..bcd386ce85 100644
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_5.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_5.jpg differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_6.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_6.jpg
index 0f62d0ed0a..6c046fc997 100644
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_6.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_6.jpg differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_7.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_7.jpg
index b1afdc45dd..bbc81ec4af 100644
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_7.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_7.jpg differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_8.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_8.jpg
index 8e2f6dba89..b3af5b51bf 100644
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_8.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_8.jpg differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_9.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_9.jpg
index b4ad9e2f15..eb3a60511d 100644
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_9.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_9.jpg differ
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ad24e00302..6c250ce7d9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,34 +1,33 @@
[versions]
-agp = "9.4.0"
-annotation = "1.10.0"
-kotlin = "2.4.10"
-kotlinMetadata = "2.4.0"
-compose = "1.12.1"
+agp = "9.4.1"
+annotation = "1.11.0"
+kotlin = "2.4.21"
+kotlinMetadata = "2.4.21"
+compose = "1.13.0-beta01"
lifecycle = "2.11.0"
-material3 = "1.5.0-alpha23"
-media3 = "1.10.1"
+material3 = "1.5.0-beta01"
+media3 = "1.11.1"
carApp = "1.7.0"
-room = "2.8.4"
+room = "2.8.5"
hilt = "2.60.1"
-ktor = "3.5.1"
-ksp = "2.3.10"
-jsoup = "1.22.2"
+ktor = "3.6.0"
+ksp = "2.3.12"
+jsoup = "1.23.2"
re2j = "1.8"
-coil = "3.5.0"
-guava = "33.6.0-jre"
+coil = "3.6.3"
+guava = "33.7.2-jre"
coroutines = "1.11.0"
materialiconsextended = "1.7.8"
concurrentFutures = "1.3.0"
activity = "1.13.0"
-navigation = "2.9.8"
+navigation = "2.10.2"
hiltNavigation = "1.4.0"
datastore = "1.2.1"
-workManager = "2.11.2"
+workManager = "2.12.0"
composeReorderable = "3.1.0"
-squigglyslider = "1.0.0"
shimmer = "1.5.0"
palette = "1.0.0"
-apacheLang3 = "3.20.0"
+apacheLang3 = "3.21.0"
brotli = "0.1.2"
desugaring = "2.1.5"
junit = "4.13.2"
@@ -39,18 +38,18 @@ extractor = "0.26.5"
translator = "1.1.1"
rhino = "1.9.1"
markwon = "4.6.2"
-okhttp = "5.4.0"
-accompanist = "1.0.19"
-accompanist-core = "0.4.7"
+okhttp = "5.5.0"
+accompanist = "2.0.0-rc.2"
+accompanist-core = "0.5.0"
androidsvg = "1.4"
-aboutLibraries = "15.0.3"
+aboutLibraries = "15.3.0"
leakCanary = "2.14"
-spotless = "8.10.0"
-graphicsShapes = "1.0.1"
-quickjsKt = "1.0.14"
-protobuf = "4.36.1"
-webkit = "1.17.0"
-haze = "2.0.0-rc01"
+spotless = "8.10.4"
+graphicsShapes = "1.1.0"
+quickjsKt = "1.0.15"
+protobuf = "4.36.2"
+webkit = "1.17.1"
+haze = "2.0.1"
[libraries]
@@ -78,8 +77,6 @@ compose-animation = { module = "androidx.compose.animation:animation-graphics",
compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialiconsextended" }
compose-reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "composeReorderable" }
-squigglyslider = { module = "me.saket.squigglyslider:squigglyslider", version.ref = "squigglyslider" }
-
viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 760c45969c..a78c88e539 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionSha256Sum=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
+distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
diff --git a/morideobfuscator b/morideobfuscator
index 47468720f9..9dfc70f9b0 160000
--- a/morideobfuscator
+++ b/morideobfuscator
@@ -1 +1 @@
-Subproject commit 47468720f91769320b2a10bb56ebce73568b07f1
+Subproject commit 9dfc70f9b072c681b09acaa993c548f8d3fbced8