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 @@ Downloads Stars License - MVVM Architecture - Kotlin Language - Jetpack Compose Toolkit - Material Design 3 VirusTotal Telegram Discord + +
+ +

+ ArchiveTune | FMHY + + + + ArchiveTune | FMHY + +
- ArchiveTune | Trendshift + ArchiveTune | Trendshift @@ -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