mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
Compare commits
184
Commits
de4bd473f9
..
v3.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bd29f75e6 | ||
|
|
d7a3a41ab2 | ||
|
|
05e318f9ab | ||
|
|
e8fb0ac564 | ||
|
|
7379f3d93e | ||
|
|
f9151843a6 | ||
|
|
2f95a89682 | ||
|
|
319ff4b645 | ||
|
|
d8ac7fefc7 | ||
|
|
f46f43b6cd | ||
|
|
8052e1db76 | ||
|
|
edc8782302 | ||
|
|
3ffe38d988 | ||
|
|
b76328fc26 | ||
|
|
9feaf54cd2 | ||
|
|
72e1b61bff | ||
|
|
bb3abe39b6 | ||
|
|
be08e50314 | ||
|
|
b9a3ae5ed0 | ||
|
|
47787182d0 | ||
|
|
aca2bd4be2 | ||
|
|
677596c21b | ||
|
|
1dbfb027fe | ||
|
|
8765c5d37b | ||
|
|
2da0d61d4c | ||
|
|
2c6af4b126 | ||
|
|
61774a19ca | ||
|
|
0602f835dc | ||
|
|
45fc169c42 | ||
|
|
e487d2e0b1 | ||
|
|
6f1e1612e1 | ||
|
|
185aaabbc5 | ||
|
|
c1a01b9a47 | ||
|
|
fbd4c0c4fe | ||
|
|
c56a6d4f83 | ||
|
|
5de5b618d1 | ||
|
|
81d45ad0ad | ||
|
|
5583c9c1d6 | ||
|
|
11a304e7ba | ||
|
|
c8de39acb9 | ||
|
|
77e3fa87f1 | ||
|
|
0a5b3512da | ||
|
|
783d7f2ebc | ||
|
|
18118492c7 | ||
|
|
0d1118011f | ||
|
|
6e60ff6183 | ||
|
|
4a85b23383 | ||
|
|
d038a33b36 | ||
|
|
fb1c2a02ff | ||
|
|
d81efe168e | ||
|
|
0bd8f1bda8 | ||
|
|
14ed037996 | ||
|
|
e32df2fb08 | ||
|
|
43f0721d26 | ||
|
|
3f1d9b0d8d | ||
|
|
8348e048b5 | ||
|
|
77190a2af1 | ||
|
|
028685d62b | ||
|
|
cac1f6af24 | ||
|
|
98398ab2fe | ||
|
|
b379a22517 | ||
|
|
d4ba9c0faf | ||
|
|
889bdb452b | ||
|
|
194787dfcc | ||
|
|
2e54d09acb | ||
|
|
4b3a7d1dfa | ||
|
|
bb71f4889d | ||
|
|
915225f812 | ||
|
|
88e83c2a86 | ||
|
|
de64e8bc69 | ||
|
|
0e90f51cfa | ||
|
|
250d02e7ab | ||
|
|
ab32a7bfbe | ||
|
|
8941eb6702 | ||
|
|
63d140e67e | ||
|
|
5758609c25 | ||
|
|
b8b5594d04 | ||
|
|
4f6a292e74 | ||
|
|
5dd6e8dab9 | ||
|
|
733a4d2941 | ||
|
|
3198a94df3 | ||
|
|
8cdbe64942 | ||
|
|
824579c938 | ||
|
|
0ac67a124d | ||
|
|
ce33a2b6fd | ||
|
|
d908b5b26d | ||
|
|
d03dd982b2 | ||
|
|
65d74faa1d | ||
|
|
e0cfba489e | ||
|
|
916da72339 | ||
|
|
87dbabb496 | ||
|
|
746e2319b9 | ||
|
|
4d08b8fb1e | ||
|
|
f1628439be | ||
|
|
562ca1607b | ||
|
|
6b0e705883 | ||
|
|
7161121922 | ||
|
|
a41f77d907 | ||
|
|
9d9550be48 | ||
|
|
c64cdd5172 | ||
|
|
4761772b9a | ||
|
|
0c2e85134b | ||
|
|
7d8a2dae9f | ||
|
|
891406eab9 | ||
|
|
ba77b31dc4 | ||
|
|
ef0c34d973 | ||
|
|
97d33a781a | ||
|
|
6c3028eeb2 | ||
|
|
61a2472ea9 | ||
|
|
9971128078 | ||
|
|
daf305f680 | ||
|
|
fcfc5561ae | ||
|
|
a562e4b316 | ||
|
|
538cb4177a | ||
|
|
b2c66bf03e | ||
|
|
8bcca2767d | ||
|
|
5b6c0ef45c | ||
|
|
d245da20a4 | ||
|
|
730ea11235 | ||
|
|
31859b66b6 | ||
|
|
0b4e1d18bc | ||
|
|
229d6de4c6 | ||
|
|
72784ee914 | ||
|
|
8f34c958fb | ||
|
|
aee631c08d | ||
|
|
62409898c1 | ||
|
|
cf272c4edc | ||
|
|
c92d28c9dd | ||
|
|
7ce754f95a | ||
|
|
5f5ad85dff | ||
|
|
3c0372cceb | ||
|
|
e33784ddd3 | ||
|
|
81e6189ebe | ||
|
|
069a0f7f19 | ||
|
|
86d0d06e76 | ||
|
|
526373c10a | ||
|
|
7b17e33545 | ||
|
|
6cfc1d29f1 | ||
|
|
9cfa4b62e2 | ||
|
|
25dafb0701 | ||
|
|
b019efbef9 | ||
|
|
d171825b5e | ||
|
|
6f997dceb9 | ||
|
|
43ee2d9f27 | ||
|
|
3c184a71a6 | ||
|
|
8396f98a85 | ||
|
|
e396671ac7 | ||
|
|
8e7431cc2e | ||
|
|
7bbc29a1d6 | ||
|
|
0a3e839da0 | ||
|
|
e0a09b724b | ||
|
|
e806eaef89 | ||
|
|
0cef3657bc | ||
|
|
20c6ba526f | ||
|
|
cfb663c250 | ||
|
|
ac1e62fc02 | ||
|
|
adabe21d63 | ||
|
|
0bdac8fa22 | ||
|
|
15d77f998f | ||
|
|
779131c766 | ||
|
|
9695d83b1a | ||
|
|
a8d6111aaa | ||
|
|
d4152ba3a1 | ||
|
|
79fe15da91 | ||
|
|
714440fb25 | ||
|
|
a2d89f28a6 | ||
|
|
b2b02e7c05 | ||
|
|
9d783b2301 | ||
|
|
713d9a976a | ||
|
|
c262e58a96 | ||
|
|
eb5019ea5e | ||
|
|
8fe26a36b1 | ||
|
|
7a890094fc | ||
|
|
4012cc0272 | ||
|
|
336fd9c49f | ||
|
|
5f138bde6a | ||
|
|
32360fac08 | ||
|
|
206ac8988f | ||
|
|
4fad6a78ec | ||
|
|
2439368e4e | ||
|
|
92c6f6945f | ||
|
|
bcd7bbf23a | ||
|
|
ce9e3e89a7 | ||
|
|
66ee837cbb |
@@ -38,8 +38,8 @@ runs:
|
|||||||
- uses: actions/cache@v6
|
- uses: actions/cache@v6
|
||||||
with:
|
with:
|
||||||
path: ~/.gradle/caches
|
path: ~/.gradle/caches
|
||||||
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
key: gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
restore-keys: gradle-
|
restore-keys: gradle-v2-
|
||||||
- id: signing
|
- id: signing
|
||||||
if: inputs.sign == 'true'
|
if: inputs.sign == 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -9,6 +9,6 @@ runs:
|
|||||||
extra_nix_config: experimental-features = nix-command flakes
|
extra_nix_config: experimental-features = nix-command flakes
|
||||||
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
|
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
|
||||||
with:
|
with:
|
||||||
primary-key: nix-v2-${{ hashFiles('flake.lock', '**/*.nix') }}
|
primary-key: nix-v3-${{ hashFiles('flake.lock', '**/*.nix') }}
|
||||||
restore-prefixes-first-match: nix-v2-
|
restore-prefixes-first-match: nix-v3-
|
||||||
gc-max-store-size-linux: 6G
|
gc-max-store-size-linux: 10G
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
|
run-name: Release ${{ github.ref_name }}
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
@@ -83,7 +85,9 @@ jobs:
|
|||||||
echo "value=false" >> "$GITHUB_OUTPUT"
|
echo "value=false" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
- name: Check release notes exist
|
- name: Check release notes exist
|
||||||
run: test -f "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt"
|
run: |
|
||||||
|
test -f "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt"
|
||||||
|
test -f "docs/releases/$GITHUB_REF_NAME.md"
|
||||||
- name: Write Play service account
|
- name: Write Play service account
|
||||||
if: steps.stable.outputs.value == 'true'
|
if: steps.stable.outputs.value == 'true'
|
||||||
env:
|
env:
|
||||||
@@ -117,31 +121,11 @@ jobs:
|
|||||||
printf '%s\n' "$certs"
|
printf '%s\n' "$certs"
|
||||||
fingerprint=$(printf '%s\n' "$certs" | sed -n 's/.*certificate SHA-256 digest: //p' | head -1 | tr '[:lower:]' '[:upper:]' | sed 's/\(..\)/\1:/g; s/:$//')
|
fingerprint=$(printf '%s\n' "$certs" | sed -n 's/.*certificate SHA-256 digest: //p' | head -1 | tr '[:lower:]' '[:upper:]' | sed 's/\(..\)/\1:/g; s/:$//')
|
||||||
echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT"
|
echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT"
|
||||||
- name: Compose release notes
|
|
||||||
run: |
|
|
||||||
{
|
|
||||||
cat "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt"
|
|
||||||
echo
|
|
||||||
echo '---'
|
|
||||||
echo
|
|
||||||
echo "APK SHA-256: \`$(grep 'subtracks\.apk$' SHA256SUMS | cut -d ' ' -f1)\`"
|
|
||||||
echo
|
|
||||||
echo "AAB SHA-256: \`$(grep 'subtracks\.aab$' SHA256SUMS | cut -d ' ' -f1)\`"
|
|
||||||
echo
|
|
||||||
echo "Signing certificate SHA-256: \`${{ steps.checksums.outputs.fingerprint }}\`"
|
|
||||||
echo
|
|
||||||
echo 'The signing certificate is attached as `release-certificate.pem`.'
|
|
||||||
if [ "${{ steps.stable.outputs.value }}" = "true" ]; then
|
|
||||||
echo 'The `.aab` is the exact bundle uploaded to Google Play; the `.apk` is the universal APK for sideloading.'
|
|
||||||
else
|
|
||||||
echo 'The `.aab` is the built app bundle (this pre-release was not uploaded to Google Play); the `.apk` is the universal APK for sideloading.'
|
|
||||||
fi
|
|
||||||
} > release-notes.md
|
|
||||||
- name: Create GitHub release
|
- name: Create GitHub release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
args=(--verify-tag --title "$GITHUB_REF_NAME" --notes-file release-notes.md)
|
args=(--verify-tag --title "$GITHUB_REF_NAME" --notes-file "docs/releases/$GITHUB_REF_NAME.md")
|
||||||
if [ "${{ steps.stable.outputs.value }}" != "true" ]; then
|
if [ "${{ steps.stable.outputs.value }}" != "true" ]; then
|
||||||
args+=(--prerelease)
|
args+=(--prerelease)
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -34,11 +34,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
|
|||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
- Forgejo Actions, `.forgejo/workflows/ci.yml`, a single `ci` job that runs lint, the unit tests, the debug and nightly assembles and the integration suite (one cache restore/save and Gradle warm-up instead of several); the GitHub copy uploads the debug and nightly APKs and the checksums as separate artifacts on a push to `main`.
|
- The CI workflows live under `.forgejo/` and `.github/`, and the GitHub mirror and the GitHub PR import under `.forgejo/`; see `.forgejo/README.md`.
|
||||||
- Runs on the `nix-docker` runner (the `localhost/nix-ci` image built by the separate `nix-home` repo) inside the devshell, restoring the Nix store via `cache-nix-action` and caching Gradle and the integration test music.
|
|
||||||
- The `nix-docker` and `bookworm` runner labels, their images and the runner's networking are defined in the separate `nix-home` repo (`hosts-incus/git-runner.nix`).
|
|
||||||
- Pull requests from a fork need approval before the workflow runs.
|
|
||||||
- The GitHub mirror and the GitHub PR import live under `.forgejo/`; see `.forgejo/README.md`.
|
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
@@ -48,6 +44,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
|
|||||||
- Networking uses OkHttp and DOM XML parsing; auth uses the Subsonic token scheme by default.
|
- Networking uses OkHttp and DOM XML parsing; auth uses the Subsonic token scheme by default.
|
||||||
- Use coroutines and `Flow`; library reads are exposed as `Flow` from Room. Large lists page with Paging 3 over Room `PagingSource` rather than loading the whole table.
|
- Use coroutines and `Flow`; library reads are exposed as `Flow` from Room. Large lists page with Paging 3 over Room `PagingSource` rather than loading the whole table.
|
||||||
- The library is mirrored in full at sync and must work offline. Never fetch library metadata on demand for browsing, filtering or sorting; only media bytes (streams, downloads) and images are on-demand. A server-derived sort key must be synced into a local column, not requested lazily when the sort is selected.
|
- The library is mirrored in full at sync and must work offline. Never fetch library metadata on demand for browsing, filtering or sorting; only media bytes (streams, downloads) and images are on-demand. A server-derived sort key must be synced into a local column, not requested lazily when the sort is selected.
|
||||||
|
- Auto-sync is a foreground-only, in-process timer (`AutoSync`, driven by the Activity lifecycle), not a background worker: do not move it to WorkManager or an alarm. `SyncManager` coalesces a request that arrives while a sync is running into a single follow-up; the library and Home sync actions no-op while one is running.
|
||||||
- Playback is Media3: `PlaybackService` is a `MediaSessionService` owning an `ExoPlayer`, and `PlaybackController` (app-scoped Koin singleton) is the only thing the UI talks to. The queue is a `queue_entries` list of references with optional ranges plus a `playback_cursor` row; keep only a bounded window in memory (never the whole queue, and never load a whole table to build one). Stream URLs are freshly salted per request, so attach artwork by `CoverArtRef` cache key, not by URL.
|
- Playback is Media3: `PlaybackService` is a `MediaSessionService` owning an `ExoPlayer`, and `PlaybackController` (app-scoped Koin singleton) is the only thing the UI talks to. The queue is a `queue_entries` list of references with optional ranges plus a `playback_cursor` row; keep only a bounded window in memory (never the whole queue, and never load a whole table to build one). Stream URLs are freshly salted per request, so attach artwork by `CoverArtRef` cache key, not by URL.
|
||||||
- UI state lives in `androidx.lifecycle.ViewModel`, wired with Koin (`koinViewModel()`); screens split into a stateful `*Route` and a stateless `*Screen` for screenshot tests.
|
- UI state lives in `androidx.lifecycle.ViewModel`, wired with Koin (`koinViewModel()`); screens split into a stateful `*Route` and a stateless `*Screen` for screenshot tests.
|
||||||
- UI and playback preferences go in DataStore (`UserPreferences`), not Room.
|
- UI and playback preferences go in DataStore (`UserPreferences`), not Room.
|
||||||
@@ -59,7 +56,6 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
|
|||||||
- Room 3 does not map `PagingSource` automatically: a DAO that returns it needs `@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)`.
|
- Room 3 does not map `PagingSource` automatically: a DAO that returns it needs `@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)`.
|
||||||
- Material icons are not pulled in by `material3`; use the BOM-managed `material-icons-core`/`material-icons-extended` (frozen at 1.7.8). Extended is large in debug builds but R8 strips unused icons from release.
|
- Material icons are not pulled in by `material3`; use the BOM-managed `material-icons-core`/`material-icons-extended` (frozen at 1.7.8). Extended is large in debug builds but R8 strips unused icons from release.
|
||||||
- Robolectric creates a fresh `Application` per test in one JVM, so `SubtracksApp.onCreate` stops any running Koin before `startKoin`.
|
- Robolectric creates a fresh `Application` per test in one JVM, so `SubtracksApp.onCreate` stops any running Koin before `startKoin`.
|
||||||
- The CI image provides `nix-ld`, `jq`, `sqlite`, `node`, `zstd` and a `runner` user that `cache-nix-action` expects. None of that is needed locally beyond the devshell.
|
|
||||||
- Integration servers: navidrome on 4533 (`admin`/`password`), gonic on 4747 (`admin`/`admin`), lms on 5082 (`admin`/`subtracks-lms`), Nextcloud Music on 8090 (`admin`/`subtracks-nextcloud`, base URL `http://localhost:8090/index.php/apps/music/subsonic/`). The test music is cached in `.integration/music` (gitignored).
|
- Integration servers: navidrome on 4533 (`admin`/`password`), gonic on 4747 (`admin`/`admin`), lms on 5082 (`admin`/`subtracks-lms`), Nextcloud Music on 8090 (`admin`/`subtracks-nextcloud`, base URL `http://localhost:8090/index.php/apps/music/subsonic/`). The test music is cached in `.integration/music` (gitignored).
|
||||||
- lms and Nextcloud Music authenticate the Subsonic API with per-user **API keys** instead of passwords. The harness seeds a key and most tests use `useTokenAuth = false`; `SubsonicAuthIntegrationTest` drives `useTokenAuth = true` and asserts the fallback to plaintext (lms answers code 42, Nextcloud 41).
|
- lms and Nextcloud Music authenticate the Subsonic API with per-user **API keys** instead of passwords. The harness seeds a key and most tests use `useTokenAuth = false`; `SubsonicAuthIntegrationTest` drives `useTokenAuth = true` and asserts the fallback to plaintext (lms answers code 42, Nextcloud 41).
|
||||||
- `nix develop` does not source your shell rc files (so tools like atuin are not active); use direnv if you want your normal interactive shell.
|
- `nix develop` does not source your shell rc files (so tools like atuin are not active); use direnv if you want your normal interactive shell.
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ See [signing/README.md](signing/README.md) for the fingerprints and the commands
|
|||||||
- **Adaptive Theming:** Colours drawn from the current song's artwork.
|
- **Adaptive Theming:** Colours drawn from the current song's artwork.
|
||||||
- **Search:** Fast search across the whole library, with per-list sort and filter controls.
|
- **Search:** Fast search across the whole library, with per-list sort and filter controls.
|
||||||
- **Playback:** Gapless playback, an editable queue you can reorder and jump through, plus loop and shuffle modes.
|
- **Playback:** Gapless playback, an editable queue you can reorder and jump through, plus loop and shuffle modes.
|
||||||
|
- **ReplayGain:** Loudness normalization from the tags your server reports, with Auto, track and album modes and a preamp.
|
||||||
- **Up Next:** Add items into the currently playing queue without rewriting it.
|
- **Up Next:** Add items into the currently playing queue without rewriting it.
|
||||||
- **Multiple Servers:** Connect several servers and switch between them quickly.
|
- **Multiple Servers:** Connect several servers and switch between them quickly.
|
||||||
- **Stream Quality:** Bitrate and format settings for Wi-Fi and mobile, with automatic switching.
|
- **Stream Quality:** Bitrate and format settings for Wi-Fi and mobile, with automatic switching.
|
||||||
|
|||||||
+46
-6
@@ -14,6 +14,19 @@ ktlint {
|
|||||||
version.set(libs.versions.ktlint.get())
|
version.set(libs.versions.ktlint.get())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val commitHash: String =
|
||||||
|
runCatching {
|
||||||
|
providers
|
||||||
|
.exec {
|
||||||
|
commandLine("git", "rev-parse", "HEAD")
|
||||||
|
workingDir = rootProject.layout.projectDirectory.asFile
|
||||||
|
isIgnoreExitValue = true
|
||||||
|
}.standardOutput.asText
|
||||||
|
.get()
|
||||||
|
.trim()
|
||||||
|
.take(7)
|
||||||
|
}.getOrDefault("")
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.subtracks"
|
namespace = "com.subtracks"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
@@ -23,20 +36,26 @@ android {
|
|||||||
applicationId = "com.subtracks"
|
applicationId = "com.subtracks"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 13
|
versionCode = 14
|
||||||
versionName = "3.0.0"
|
versionName = "3.1.0"
|
||||||
|
buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
resValues = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
androidResources {
|
androidResources {
|
||||||
generateLocaleConfig = true
|
generateLocaleConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependenciesInfo {
|
||||||
|
// The APK signing-block copy is encrypted per build, breaking APK reproducibility, and
|
||||||
|
// F-Droid rejects it; the bundle keeps its copy for Play.
|
||||||
|
includeInApk = false
|
||||||
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
@@ -99,10 +118,13 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
getByName("debug").res.srcDir("src/nightly/res")
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
applicationIdSuffix = ".nightly"
|
applicationIdSuffix = ".nightly"
|
||||||
resValue("string", "app_name", "subtracks nightly")
|
|
||||||
}
|
}
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
@@ -115,7 +137,6 @@ android {
|
|||||||
initWith(getByName("release"))
|
initWith(getByName("release"))
|
||||||
applicationIdSuffix = ".nightly"
|
applicationIdSuffix = ".nightly"
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
resValue("string", "app_name", "subtracks nightly")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,15 +178,18 @@ val integrationTestClasses =
|
|||||||
|
|
||||||
val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
|
val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
|
||||||
|
|
||||||
|
val queryTimingClass = "com.subtracks.data.db.QueryTimingMeasurementTest"
|
||||||
|
|
||||||
tasks.withType<Test>().configureEach {
|
tasks.withType<Test>().configureEach {
|
||||||
if (name.endsWith("UnitTest")) {
|
if (name.endsWith("UnitTest")) {
|
||||||
filter {
|
filter {
|
||||||
(integrationTestClasses + demoScreenshotClass).forEach { excludeTestsMatching(it) }
|
(integrationTestClasses + demoScreenshotClass + queryTimingClass).forEach { excludeTestsMatching(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
testLogging {
|
testLogging {
|
||||||
exceptionFormat = TestExceptionFormat.FULL
|
exceptionFormat = TestExceptionFormat.FULL
|
||||||
}
|
}
|
||||||
|
outputs.cacheIf { false }
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register<Test>("integrationTest") {
|
tasks.register<Test>("integrationTest") {
|
||||||
@@ -196,6 +220,20 @@ tasks.register<Test>("demoScreenshots") {
|
|||||||
outputs.upToDateWhen { false }
|
outputs.upToDateWhen { false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.register<Test>("queryTiming") {
|
||||||
|
description = "Measures SQL query timings for the logging threshold (not a CI gate)"
|
||||||
|
group = "verification"
|
||||||
|
val unitTest = tasks.named<Test>("testDebugUnitTest")
|
||||||
|
dependsOn("compileDebugUnitTestKotlin")
|
||||||
|
testClassesDirs = files(provider { unitTest.get().testClassesDirs })
|
||||||
|
classpath = files(provider { unitTest.get().classpath })
|
||||||
|
filter {
|
||||||
|
includeTestsMatching(queryTimingClass)
|
||||||
|
}
|
||||||
|
providers.gradleProperty("measureSongs").orNull?.let { systemProperty("measure.songs", it) }
|
||||||
|
outputs.upToDateWhen { false }
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
@@ -221,7 +259,9 @@ dependencies {
|
|||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.androidx.media3.exoplayer)
|
implementation(libs.androidx.media3.exoplayer)
|
||||||
implementation(libs.androidx.media3.session)
|
implementation(libs.androidx.media3.session)
|
||||||
|
implementation(libs.androidx.media3.datasource)
|
||||||
implementation(libs.androidx.media3.datasource.okhttp)
|
implementation(libs.androidx.media3.datasource.okhttp)
|
||||||
|
implementation(libs.androidx.media3.database)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.coil.network.okhttp)
|
implementation(libs.coil.network.okhttp)
|
||||||
|
|||||||
+1656
File diff suppressed because it is too large
Load Diff
+1812
File diff suppressed because it is too large
Load Diff
+1819
File diff suppressed because it is too large
Load Diff
+1884
File diff suppressed because it is too large
Load Diff
+1891
File diff suppressed because it is too large
Load Diff
+1900
File diff suppressed because it is too large
Load Diff
+1900
File diff suppressed because it is too large
Load Diff
+1943
File diff suppressed because it is too large
Load Diff
+1962
File diff suppressed because it is too large
Load Diff
+1997
File diff suppressed because it is too large
Load Diff
+2009
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,16 @@
|
|||||||
<receiver
|
<receiver
|
||||||
android:name=".data.download.DownloadCancelReceiver"
|
android:name=".data.download.DownloadCancelReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.subtracks.data.sync.AutoSync
|
||||||
import com.subtracks.ui.NowPlayingLauncher
|
import com.subtracks.ui.NowPlayingLauncher
|
||||||
import com.subtracks.ui.SubtracksRoot
|
import com.subtracks.ui.SubtracksRoot
|
||||||
import com.subtracks.ui.theme.SubtracksTheme
|
import com.subtracks.ui.theme.SubtracksTheme
|
||||||
@@ -19,10 +20,21 @@ import org.koin.core.context.GlobalContext
|
|||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val launchViewModel: NowPlayingLaunchViewModel by viewModels()
|
private val launchViewModel: NowPlayingLaunchViewModel by viewModels()
|
||||||
private val nowPlayingLauncher: NowPlayingLauncher by lazy { GlobalContext.get().get() }
|
private val nowPlayingLauncher: NowPlayingLauncher by lazy { GlobalContext.get().get() }
|
||||||
|
private val autoSync: AutoSync by lazy { GlobalContext.get().get() }
|
||||||
|
|
||||||
private val requestNotifications =
|
private val requestNotifications =
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
autoSync.setForeground(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
autoSync.setForeground(false)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
requestNotificationPermission()
|
requestNotificationPermission()
|
||||||
|
|||||||
@@ -5,20 +5,28 @@ import coil3.ImageLoader
|
|||||||
import coil3.SingletonImageLoader
|
import coil3.SingletonImageLoader
|
||||||
import coil3.annotation.DelicateCoilApi
|
import coil3.annotation.DelicateCoilApi
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import com.subtracks.data.LegacyDataCleanup
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.ArtworkSeedStore
|
import com.subtracks.data.repo.ArtworkSeedStore
|
||||||
import com.subtracks.di.appModule
|
import com.subtracks.di.appModule
|
||||||
|
import com.subtracks.log.Log
|
||||||
import com.subtracks.ui.theme.ArtworkSeedCache
|
import com.subtracks.ui.theme.ArtworkSeedCache
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
import org.koin.core.context.stopKoin
|
import org.koin.core.context.stopKoin
|
||||||
|
|
||||||
class SubtracksApp : Application() {
|
class SubtracksApp : Application() {
|
||||||
private val http = OkHttpClient()
|
private val http =
|
||||||
|
UserAgent
|
||||||
|
.httpClient()
|
||||||
|
.newBuilder()
|
||||||
|
.addInterceptor(Log.interceptor())
|
||||||
|
.build()
|
||||||
|
|
||||||
@OptIn(DelicateCoilApi::class)
|
@OptIn(DelicateCoilApi::class)
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
Log.init(this)
|
||||||
|
LegacyDataCleanup.run(this)
|
||||||
stopKoin()
|
stopKoin()
|
||||||
SingletonImageLoader.reset()
|
SingletonImageLoader.reset()
|
||||||
val koin =
|
val koin =
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.subtracks
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
|
||||||
|
object UserAgent {
|
||||||
|
val value: String = "subtracks (Android ${Build.VERSION.RELEASE}; ${BuildConfig.VERSION_NAME})"
|
||||||
|
|
||||||
|
fun interceptor(): Interceptor =
|
||||||
|
Interceptor { chain ->
|
||||||
|
chain.proceed(
|
||||||
|
chain
|
||||||
|
.request()
|
||||||
|
.newBuilder()
|
||||||
|
.header("User-Agent", value)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun httpClient(): OkHttpClient = OkHttpClient.Builder().addInterceptor(interceptor()).build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.subtracks.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
// One-shot cleanup of the 1.x React Native app, which shipped as com.subtracks before this rewrite.
|
||||||
|
// It left an AsyncStorage database (RKStorage), an older `servers` dump and a downloaded/cached
|
||||||
|
// artwork+audio tree (under the app's external files dir) that v3 never reads. Only a real 1.x upgrade
|
||||||
|
// has those paths, so a fresh install does nothing and an upgrade is cleaned exactly once.
|
||||||
|
//
|
||||||
|
// Delete this file and its call in SubtracksApp once installs can no longer be coming from 1.x.
|
||||||
|
internal object LegacyDataCleanup {
|
||||||
|
fun run(context: Context) {
|
||||||
|
// The cached trees can be large, so keep the walk off the main thread.
|
||||||
|
Thread { run(context.dataDir, context.getExternalFilesDir(null)) }.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun run(
|
||||||
|
dataDir: File,
|
||||||
|
externalFilesDir: File?,
|
||||||
|
) {
|
||||||
|
val database = File(dataDir, "databases/RKStorage")
|
||||||
|
val servers = File(dataDir, "files/servers")
|
||||||
|
val cache = externalFilesDir?.let { File(it, "s") }
|
||||||
|
if (!database.exists() && !servers.exists() && cache?.exists() != true) return
|
||||||
|
deleteDatabase(database)
|
||||||
|
deleteTree(servers)
|
||||||
|
cache?.let(::deleteTree)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteDatabase(database: File) {
|
||||||
|
listOf("", "-wal", "-shm", "-journal").forEach { deleteTree(File(database.path + it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteTree(target: File) {
|
||||||
|
if (!target.exists()) return
|
||||||
|
val removed = target.deleteRecursively()
|
||||||
|
Log.i("legacy", "removed ${target.path} ($removed)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,15 @@ package com.subtracks.data.db
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.room3.Room
|
import androidx.room3.Room
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
|
||||||
fun createAndroidDatabase(context: Context): SubtracksDatabase =
|
fun createAndroidDatabase(context: Context): SubtracksDatabase {
|
||||||
Room
|
val driver = LoggingSQLiteDriver(BundledSQLiteDriver(), Log::sql)
|
||||||
|
return Room
|
||||||
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
||||||
.setDriver(BundledSQLiteDriver())
|
.setDriver(driver)
|
||||||
.addMigrations(*MIGRATIONS)
|
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
|
||||||
|
.addCallback(ArtistSearchTriggerOptimization)
|
||||||
|
.addCallback(SqliteLifecycleLogging)
|
||||||
.build()
|
.build()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import androidx.room3.RoomDatabase
|
||||||
|
import androidx.room3.migration.Migration
|
||||||
|
import androidx.sqlite.SQLiteConnection
|
||||||
|
import androidx.sqlite.SQLiteDriver
|
||||||
|
import androidx.sqlite.SQLiteStatement
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
|
||||||
|
data class SqlExecution(
|
||||||
|
val sql: String,
|
||||||
|
val durationNanos: Long,
|
||||||
|
val rows: Long,
|
||||||
|
val error: Throwable?,
|
||||||
|
) {
|
||||||
|
val durationMillis: Double get() = durationNanos / 1_000_000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoggingSQLiteDriver(
|
||||||
|
private val delegate: SQLiteDriver,
|
||||||
|
private val onExecution: (SqlExecution) -> Unit,
|
||||||
|
) : SQLiteDriver {
|
||||||
|
override val hasConnectionPool: Boolean
|
||||||
|
get() = delegate.hasConnectionPool
|
||||||
|
|
||||||
|
override fun open(fileName: String): SQLiteConnection = LoggingConnection(delegate.open(fileName), onExecution)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LoggingConnection(
|
||||||
|
private val delegate: SQLiteConnection,
|
||||||
|
private val onExecution: (SqlExecution) -> Unit,
|
||||||
|
) : SQLiteConnection by delegate {
|
||||||
|
override fun prepare(sql: String): SQLiteStatement {
|
||||||
|
val start = System.nanoTime()
|
||||||
|
return try {
|
||||||
|
LoggingStatement(delegate.prepare(sql), sql, onExecution)
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
onExecution(SqlExecution(sql, System.nanoTime() - start, 0, failure))
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LoggingStatement(
|
||||||
|
private val delegate: SQLiteStatement,
|
||||||
|
private val sql: String,
|
||||||
|
private val onExecution: (SqlExecution) -> Unit,
|
||||||
|
) : SQLiteStatement by delegate {
|
||||||
|
private var startNanos = 0L
|
||||||
|
private var rows = 0L
|
||||||
|
private var failure: Throwable? = null
|
||||||
|
private var active = false
|
||||||
|
|
||||||
|
override fun step(): Boolean {
|
||||||
|
begin()
|
||||||
|
val result =
|
||||||
|
try {
|
||||||
|
delegate.step()
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
failure = error
|
||||||
|
report()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (result) rows++
|
||||||
|
if (!result) report()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun reset() {
|
||||||
|
report()
|
||||||
|
delegate.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
report()
|
||||||
|
delegate.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun report() {
|
||||||
|
if (!active) return
|
||||||
|
active = false
|
||||||
|
onExecution(SqlExecution(sql, System.nanoTime() - startNanos, rows, failure))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Started on the first step, not on reset: Room's statement cache resets a statement when it is
|
||||||
|
// returned to the cache (BasePreparedStatementCache.CachedStatement.close), so timing from reset
|
||||||
|
// would count however long the statement idled there before its next use.
|
||||||
|
private fun begin() {
|
||||||
|
if (active) return
|
||||||
|
startNanos = System.nanoTime()
|
||||||
|
rows = 0
|
||||||
|
failure = null
|
||||||
|
active = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoggedMigration(
|
||||||
|
private val delegate: Migration,
|
||||||
|
) : Migration(delegate.startVersion, delegate.endVersion) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
val label = "${delegate.startVersion} -> ${delegate.endVersion}"
|
||||||
|
Log.i("sql", "migrating $label")
|
||||||
|
val start = System.nanoTime()
|
||||||
|
try {
|
||||||
|
delegate.migrate(connection)
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
Log.e("sql", "migration $label failed after ${(System.nanoTime() - start) / 1_000_000}ms", failure)
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
Log.i("sql", "migrated $label in ${(System.nanoTime() - start) / 1_000_000}ms")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object ArtistSearchTriggerOptimization : RoomDatabase.Callback() {
|
||||||
|
override suspend fun onCreate(connection: SQLiteConnection) {
|
||||||
|
optimizeArtistSearchTriggers(connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onOpen(connection: SQLiteConnection) {
|
||||||
|
if (!artistSearchTriggersOptimized(connection)) optimizeArtistSearchTriggers(connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object SqliteLifecycleLogging : RoomDatabase.Callback() {
|
||||||
|
override suspend fun onCreate(connection: SQLiteConnection) {
|
||||||
|
Log.i("sql", "created fresh database")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onOpen(connection: SQLiteConnection) {
|
||||||
|
Log.i("sql", "database opened")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onDestructiveMigration(connection: SQLiteConnection) {
|
||||||
|
Log.w("sql", "destructive migration: dropping all tables")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,18 +13,43 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
|
|
||||||
// A song is attributed to its album artist and to its credited track artist, so a guest artist's
|
// A song is attributed to its album artist and to its credited track artist, so a guest artist's
|
||||||
// downloads are visible even though they lead no albums of their own.
|
// downloads are visible even though they lead no albums of their own.
|
||||||
private const val ARTIST_DOWNLOAD_ROWS =
|
// Starts from songs so status totals include every song the artist is credited on.
|
||||||
"SELECT albums.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
internal const val ARTIST_DOWNLOAD_ROWS =
|
||||||
"FROM songs " +
|
"SELECT credits.artistId AS id, credits.songId AS songId, song_downloads.status AS status " +
|
||||||
|
"FROM (" + CREDITED_SONG_ROWS + ") credits " +
|
||||||
|
"LEFT JOIN song_downloads ON song_downloads.sourceId = :sourceId " +
|
||||||
|
"AND song_downloads.songId = credits.songId"
|
||||||
|
|
||||||
|
// Starts from downloads so lists and filters over partially downloaded libraries do not scan songs.
|
||||||
|
// Kept hand-written for that plan.
|
||||||
|
internal const val DOWNLOADED_ARTIST_ROWS =
|
||||||
|
"SELECT albums.artistId AS id, song_downloads.songId AS songId, " +
|
||||||
|
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||||
|
"FROM song_downloads " +
|
||||||
|
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
"WHERE song_downloads.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||||
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
"UNION ALL " +
|
||||||
"SELECT songs.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
"SELECT album_artists.artistId AS id, song_downloads.songId AS songId, " +
|
||||||
"FROM songs " +
|
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||||
|
"FROM song_downloads " +
|
||||||
|
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
"JOIN album_artists ON album_artists.sourceId = albums.sourceId AND album_artists.albumId = albums.id " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL"
|
"WHERE song_downloads.sourceId = :sourceId " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT songs.artistId AS id, song_downloads.songId AS songId, " +
|
||||||
|
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||||
|
"FROM song_downloads " +
|
||||||
|
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
|
||||||
|
"WHERE song_downloads.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT song_artists.artistId AS id, song_downloads.songId AS songId, " +
|
||||||
|
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||||
|
"FROM song_downloads " +
|
||||||
|
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
|
||||||
|
"JOIN song_artists ON song_artists.sourceId = songs.sourceId AND song_artists.songId = songs.id " +
|
||||||
|
"WHERE song_downloads.sourceId = :sourceId"
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface DownloadDao {
|
interface DownloadDao {
|
||||||
@@ -38,10 +63,15 @@ interface DownloadDao {
|
|||||||
suspend fun all(): List<SongDownload>
|
suspend fun all(): List<SongDownload>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT COUNT(DISTINCT songId) AS total, " +
|
"SELECT COALESCE((SELECT songCount FROM artists " +
|
||||||
|
"WHERE sourceId = :sourceId AND id = :artistId), 0) AS total, " +
|
||||||
|
"COALESCE(statuses.downloaded, 0) AS downloaded, " +
|
||||||
|
"COALESCE(statuses.downloading, 0) AS downloading " +
|
||||||
|
"FROM (SELECT " +
|
||||||
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
|
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
|
||||||
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
|
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
|
||||||
"FROM (" + ARTIST_DOWNLOAD_ROWS + ") WHERE id = :artistId",
|
"FROM (" + DOWNLOADED_ARTIST_ROWS + ") " +
|
||||||
|
"WHERE id = :artistId AND status IN ('Completed', 'Queued', 'Running')) statuses",
|
||||||
)
|
)
|
||||||
fun artistStatus(
|
fun artistStatus(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -89,12 +119,15 @@ interface DownloadDao {
|
|||||||
fun albumStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
|
fun albumStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT id, " +
|
"SELECT statuses.id, artists.songCount AS total, statuses.downloaded, statuses.downloading " +
|
||||||
"COUNT(DISTINCT songId) AS total, " +
|
"FROM (SELECT id, " +
|
||||||
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
|
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
|
||||||
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
|
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
|
||||||
"FROM (" + ARTIST_DOWNLOAD_ROWS + ") GROUP BY id " +
|
"FROM (" + DOWNLOADED_ARTIST_ROWS + ") " +
|
||||||
"HAVING downloaded > 0 OR downloading > 0",
|
"WHERE status IN ('Completed', 'Queued', 'Running') " +
|
||||||
|
"GROUP BY id) statuses " +
|
||||||
|
"JOIN artists ON artists.sourceId = :sourceId AND artists.id = statuses.id " +
|
||||||
|
"WHERE statuses.downloaded > 0 OR statuses.downloading > 0",
|
||||||
)
|
)
|
||||||
fun artistStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
|
fun artistStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
|
||||||
|
|
||||||
@@ -114,24 +147,30 @@ interface DownloadDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT d.sourceId AS sourceId, d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " +
|
"SELECT d.sourceId AS sourceId, d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " +
|
||||||
"al.artistId AS artistId, ar.name AS artistName, d.status AS status, " +
|
"al.artistId AS artistId, COALESCE(ar.name, al.albumArtist) AS artistName, d.status AS status, " +
|
||||||
"d.bytes AS bytes, d.total AS total " +
|
"d.bytes AS bytes, d.total AS total, " +
|
||||||
|
"COALESCE(NULLIF(ar.sortName, ''), NULLIF(al.albumArtistSort, ''), ar.name, al.albumArtist, '') AS artistSort, " +
|
||||||
|
"COALESCE(NULLIF(al.sortName, ''), al.name, '') AS albumSort, " +
|
||||||
|
"COALESCE(NULLIF(s.sortName, ''), s.title, '') AS songSort " +
|
||||||
"FROM song_downloads d " +
|
"FROM song_downloads d " +
|
||||||
"JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
"JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
||||||
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
|
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
|
||||||
"LEFT JOIN artists ar ON ar.sourceId = al.sourceId AND ar.id = al.artistId " +
|
"LEFT JOIN artists ar ON ar.sourceId = al.sourceId AND ar.id = al.artistId " +
|
||||||
"WHERE d.sourceId = :sourceId",
|
"WHERE d.sourceId = :sourceId ORDER BY s.disc, s.track, s.id",
|
||||||
)
|
)
|
||||||
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
|
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
|
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
|
||||||
|
"al.artistId AS albumArtistId, " +
|
||||||
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
|
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
|
||||||
"ar.id AS artistRow, ar.coverArt AS artistCoverArt " +
|
"ar.id AS artistRow, ar.coverArt AS artistCoverArt, " +
|
||||||
|
"aar.coverArt AS albumArtistCoverArt " +
|
||||||
"FROM song_downloads d " +
|
"FROM song_downloads d " +
|
||||||
"LEFT JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
"LEFT JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
||||||
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
|
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
|
||||||
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId",
|
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId " +
|
||||||
|
"LEFT JOIN artists aar ON aar.sourceId = al.sourceId AND aar.id = al.artistId",
|
||||||
)
|
)
|
||||||
suspend fun artwork(): List<DownloadArtwork>
|
suspend fun artwork(): List<DownloadArtwork>
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
|
|||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistBio
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.DiscKey
|
import com.subtracks.data.model.DiscKey
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.model.Source
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
internal const val PLAYLIST_SONGS_SELECT =
|
internal const val PLAYLIST_SONGS_SELECT =
|
||||||
@@ -44,72 +46,305 @@ internal const val ALBUMS_FILTER =
|
|||||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
||||||
"WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
"WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
||||||
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
||||||
"OR instr(lower(albumArtist), lower(:search)) > 0))) "
|
"OR instr(lower(albumArtist), lower(:search)) > 0 " +
|
||||||
|
"OR instr(lower(sortName), lower(:search)) > 0 " +
|
||||||
|
"OR instr(lower(albumArtistSort), lower(:search)) > 0))) "
|
||||||
|
|
||||||
internal const val ALBUM_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, sortName COLLATE NOCASE, id"
|
||||||
internal const val ALBUM_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ALBUM_ORDER_BY_ARTIST = "albumArtist COLLATE NOCASE, year, name COLLATE NOCASE, id"
|
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id"
|
||||||
internal const val ALBUM_ORDER_BY_ARTIST_REVERSED = "albumArtist COLLATE NOCASE DESC, year DESC, name COLLATE NOCASE DESC, id DESC"
|
internal const val ALBUM_ORDER_BY_ADDED_REVERSED = "created ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, name COLLATE NOCASE, id"
|
|
||||||
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, name COLLATE NOCASE DESC, id DESC"
|
|
||||||
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, name COLLATE NOCASE, id"
|
|
||||||
internal const val ALBUM_ORDER_BY_ADDED_REVERSED = "created ASC, name COLLATE NOCASE DESC, id DESC"
|
|
||||||
|
|
||||||
// Starred orders keep unstarred rows last in both directions, matching the list grouping, so the
|
// Starred orders keep unstarred rows last in both directions, matching the list grouping, so the
|
||||||
// reversed order is not a literal mirror of the base order.
|
// reversed order is not a literal mirror of the base order.
|
||||||
internal const val ALBUM_ORDER_BY_STARRED = "starred DESC NULLS LAST, name COLLATE NOCASE, id"
|
internal const val ALBUM_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||||
internal const val ALBUM_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
|
internal const val ALBUM_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
|
|
||||||
internal const val ALBUM_ORDER_BY_FREQUENT = "playCount DESC, name COLLATE NOCASE, id"
|
internal const val ALBUM_ORDER_BY_FREQUENT = "playCount DESC, sortName COLLATE NOCASE, id"
|
||||||
internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, name COLLATE NOCASE DESC, id DESC"
|
internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, name COLLATE NOCASE, id"
|
internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||||
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
|
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
|
|
||||||
|
private const val ALBUM_NAME_SORT_KEY_MODE =
|
||||||
|
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
|
||||||
|
private const val ALBUM_ARTIST_SORT_KEY_MODE =
|
||||||
|
"(CASE :sortMode WHEN 'Name' THEN albumArtist WHEN 'NonLatin' THEN (CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) ELSE albumArtistSort END) COLLATE NOCASE"
|
||||||
|
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE = "$ALBUM_NAME_SORT_KEY_MODE, id"
|
||||||
|
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED = "$ALBUM_NAME_SORT_KEY_MODE DESC, id DESC"
|
||||||
|
internal const val ALBUM_ORDER_BY_ARTIST_SORT_MODE = "$ALBUM_ARTIST_SORT_KEY_MODE, year, $ALBUM_NAME_SORT_KEY_MODE, id"
|
||||||
|
internal const val ALBUM_ORDER_BY_ARTIST_SORT_MODE_REVERSED =
|
||||||
|
"$ALBUM_ARTIST_SORT_KEY_MODE DESC, year DESC, $ALBUM_NAME_SORT_KEY_MODE DESC, id DESC"
|
||||||
|
|
||||||
|
internal const val ARTIST_CREDITED =
|
||||||
|
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
|
||||||
|
|
||||||
|
// May repeat a (artistId, songId) pair when several credit paths reach it; consumers dedupe.
|
||||||
|
internal const val CREDITED_SONG_ROWS =
|
||||||
|
"SELECT songs.artistId AS artistId, songs.id AS songId, songs.albumId AS albumId, " +
|
||||||
|
"songs.playCount AS playCount, songs.played AS played " +
|
||||||
|
"FROM songs WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT song_artists.artistId, songs.id, songs.albumId, songs.playCount, songs.played " +
|
||||||
|
"FROM song_artists JOIN songs " +
|
||||||
|
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||||
|
"WHERE song_artists.sourceId = :sourceId " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT albums.artistId, songs.id, songs.albumId, songs.playCount, songs.played " +
|
||||||
|
"FROM albums JOIN songs ON songs.sourceId = albums.sourceId AND songs.albumId = albums.id " +
|
||||||
|
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT album_artists.artistId, songs.id, songs.albumId, songs.playCount, songs.played " +
|
||||||
|
"FROM album_artists JOIN songs " +
|
||||||
|
"ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
||||||
|
"WHERE album_artists.sourceId = :sourceId"
|
||||||
|
|
||||||
|
// May repeat an (artistId, albumId) pair when several credit paths reach it; consumers dedupe.
|
||||||
|
internal const val CREDITED_ALBUM_ROWS =
|
||||||
|
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
|
||||||
|
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT album_artists.artistId, albums.id, 'album' " +
|
||||||
|
"FROM album_artists JOIN albums " +
|
||||||
|
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||||
|
"WHERE album_artists.sourceId = :sourceId " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT songs.artistId, songs.albumId, 'track' " +
|
||||||
|
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT song_artists.artistId, songs.albumId, 'track' " +
|
||||||
|
"FROM song_artists JOIN songs " +
|
||||||
|
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||||
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"WHERE song_artists.sourceId = :sourceId"
|
||||||
|
|
||||||
|
internal const val ALBUMS_FOR_ARTIST =
|
||||||
|
"SELECT albums.* FROM (SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) WHERE artistId = :artistId) credits " +
|
||||||
|
"JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
|
||||||
|
"ORDER BY year DESC, sortName COLLATE NOCASE, id"
|
||||||
|
|
||||||
|
internal const val ALBUM_CREDIT_ALBUM_IDS =
|
||||||
|
"SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) " +
|
||||||
|
"WHERE artistId = :artistId AND creditType = 'album'"
|
||||||
|
|
||||||
|
// Download visibility follows the credit path: an album credit includes the album when any song is
|
||||||
|
// downloaded, while a track credit includes it only when the credited track is downloaded.
|
||||||
|
internal const val DOWNLOADED_ALBUM_ROWS =
|
||||||
|
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
|
||||||
|
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||||
|
"AND EXISTS (SELECT 1 FROM songs " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
|
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT albums.id, album_artists.artistId FROM album_artists " +
|
||||||
|
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||||
|
"WHERE album_artists.sourceId = :sourceId " +
|
||||||
|
"AND EXISTS (SELECT 1 FROM songs " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
|
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT songs.albumId, songs.artistId FROM songs " +
|
||||||
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
|
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND sd.status = 'Completed' " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
|
||||||
|
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||||
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
|
"WHERE song_artists.sourceId = :sourceId AND sd.status = 'Completed'"
|
||||||
|
|
||||||
|
private const val DOWNLOADED_ARTIST_ALBUMS =
|
||||||
|
"SELECT albumId AS id FROM ($DOWNLOADED_ALBUM_ROWS) WHERE artistId = artists.id"
|
||||||
|
|
||||||
|
private const val DOWNLOADED_COVER_ALBUM_ROWS =
|
||||||
|
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
|
||||||
|
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
||||||
|
"AND EXISTS (SELECT 1 FROM songs " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
|
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT albums.id, album_artists.artistId FROM album_artists " +
|
||||||
|
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||||
|
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
|
||||||
|
"AND EXISTS (SELECT 1 FROM songs " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
|
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT songs.albumId, songs.artistId FROM songs " +
|
||||||
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
|
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
||||||
|
"AND sd.status = 'Completed' " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
|
||||||
|
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||||
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
|
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL AND sd.status = 'Completed'"
|
||||||
|
|
||||||
|
// Cover art only needs credited albums that actually have a cover, so filter before the window sort.
|
||||||
|
private const val ARTIST_COVER_CREDITS =
|
||||||
|
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
|
||||||
|
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||||
|
"AND albums.coverArt IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT album_artists.artistId, albums.id, 'album' " +
|
||||||
|
"FROM album_artists JOIN albums " +
|
||||||
|
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||||
|
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT songs.artistId, songs.albumId, 'track' " +
|
||||||
|
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT song_artists.artistId, songs.albumId, 'track' " +
|
||||||
|
"FROM song_artists JOIN songs " +
|
||||||
|
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||||
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL"
|
||||||
|
|
||||||
|
private const val ARTIST_COVER_CREDITS_FOR_ARTIST =
|
||||||
|
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
|
||||||
|
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
||||||
|
"AND albums.coverArt IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT album_artists.artistId, albums.id, 'album' " +
|
||||||
|
"FROM album_artists JOIN albums " +
|
||||||
|
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||||
|
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
|
||||||
|
"AND albums.coverArt IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT songs.artistId, songs.albumId, 'track' " +
|
||||||
|
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId AND albums.coverArt IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT song_artists.artistId, songs.albumId, 'track' " +
|
||||||
|
"FROM song_artists JOIN songs " +
|
||||||
|
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||||
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
|
||||||
|
"AND albums.coverArt IS NOT NULL"
|
||||||
|
|
||||||
|
private const val ARTIST_COVER_RANKS_TAIL =
|
||||||
|
"ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||||
|
"ROW_NUMBER() OVER (PARTITION BY credits.artistId " +
|
||||||
|
"ORDER BY credits.creditRank, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
|
||||||
|
"FROM credits JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
|
||||||
|
"WHERE albums.coverArt IS NOT NULL), " +
|
||||||
|
"unique_ranked AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM ranked GROUP BY artistId, coverArt), " +
|
||||||
|
"deduped AS (SELECT artistId, coverArt, " +
|
||||||
|
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_ranked)"
|
||||||
|
|
||||||
|
private const val ARTIST_COVER_RANKS =
|
||||||
|
"credits AS (SELECT artistId, albumId, " +
|
||||||
|
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||||
|
"FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " +
|
||||||
|
ARTIST_COVER_RANKS_TAIL
|
||||||
|
|
||||||
|
private const val ARTIST_COVER_RANKS_FOR_ARTIST =
|
||||||
|
"credits AS (SELECT artistId, albumId, " +
|
||||||
|
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||||
|
"FROM ($ARTIST_COVER_CREDITS_FOR_ARTIST) GROUP BY artistId, albumId), " +
|
||||||
|
ARTIST_COVER_RANKS_TAIL
|
||||||
|
|
||||||
|
internal const val ARTIST_ART_UPDATE_SQL =
|
||||||
|
"WITH $ARTIST_COVER_RANKS, " +
|
||||||
|
"covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
||||||
|
"SELECT artistId, coverArt, coverRank FROM deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
||||||
|
") GROUP BY artistId), " +
|
||||||
|
"downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS)), " +
|
||||||
|
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||||
|
"ROW_NUMBER() OVER (PARTITION BY dl.artistId " +
|
||||||
|
"ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
|
||||||
|
"FROM downloaded_albums dl JOIN albums ON albums.sourceId = :sourceId AND albums.id = dl.albumId " +
|
||||||
|
"WHERE albums.coverArt IS NOT NULL), " +
|
||||||
|
"unique_downloaded AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM downloaded GROUP BY artistId, coverArt), " +
|
||||||
|
"downloaded_deduped AS (SELECT artistId, coverArt, " +
|
||||||
|
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_downloaded), " +
|
||||||
|
"downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
||||||
|
"SELECT artistId, coverArt, coverRank FROM downloaded_deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
||||||
|
") GROUP BY artistId), " +
|
||||||
|
"desired AS (SELECT artists.rowid AS rowid, " +
|
||||||
|
"COALESCE((SELECT coverArts FROM covers WHERE covers.artistId = artists.id), '') AS coverArts, " +
|
||||||
|
"COALESCE((SELECT coverArts FROM downloaded_covers " +
|
||||||
|
"WHERE downloaded_covers.artistId = artists.id), '') AS downloadedCoverArts " +
|
||||||
|
"FROM artists WHERE artists.sourceId = :sourceId) " +
|
||||||
|
"UPDATE artists SET coverArts = desired.coverArts, downloadedCoverArts = desired.downloadedCoverArts " +
|
||||||
|
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
||||||
|
"(artists.coverArts != desired.coverArts OR artists.downloadedCoverArts != desired.downloadedCoverArts)"
|
||||||
|
|
||||||
|
internal const val ARTIST_COVER_ARTS_SQL =
|
||||||
|
"WITH $ARTIST_COVER_RANKS_FOR_ARTIST " +
|
||||||
|
"SELECT coverArt FROM deduped WHERE coverRank <= 4 ORDER BY coverRank"
|
||||||
|
|
||||||
|
internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
|
||||||
|
"WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " +
|
||||||
|
"WHERE artistId IN (:artistIds)), " +
|
||||||
|
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||||
|
"ROW_NUMBER() OVER (PARTITION BY dl.artistId " +
|
||||||
|
"ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
|
||||||
|
"FROM downloaded_albums dl JOIN albums ON albums.sourceId = :sourceId AND albums.id = dl.albumId " +
|
||||||
|
"WHERE albums.coverArt IS NOT NULL), " +
|
||||||
|
"unique_downloaded AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM downloaded GROUP BY artistId, coverArt), " +
|
||||||
|
"downloaded_deduped AS (SELECT artistId, coverArt, " +
|
||||||
|
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_downloaded), " +
|
||||||
|
"downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
||||||
|
"SELECT artistId, coverArt, coverRank FROM downloaded_deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
||||||
|
") GROUP BY artistId), " +
|
||||||
|
"desired AS (SELECT artists.rowid AS rowid, " +
|
||||||
|
"COALESCE((SELECT coverArts FROM downloaded_covers " +
|
||||||
|
"WHERE downloaded_covers.artistId = artists.id), '') AS downloadedCoverArts " +
|
||||||
|
"FROM artists WHERE artists.sourceId = :sourceId AND artists.id IN (:artistIds)) " +
|
||||||
|
"UPDATE artists SET downloadedCoverArts = desired.downloadedCoverArts " +
|
||||||
|
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
||||||
|
"artists.downloadedCoverArts != desired.downloadedCoverArts"
|
||||||
|
|
||||||
internal const val ARTISTS_FILTER =
|
internal const val ARTISTS_FILTER =
|
||||||
"FROM artists WHERE sourceId = :sourceId " +
|
"FROM artists WHERE sourceId = :sourceId " +
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
||||||
"AND (:downloadedFilter = 0 OR " +
|
"AND $ARTIST_CREDITED " +
|
||||||
"EXISTS (SELECT 1 FROM songs " +
|
"AND (:downloadedFilter = 0 OR EXISTS (" +
|
||||||
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
|
"SELECT 1 FROM ($ARTIST_DOWNLOAD_ROWS) dl " +
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
"WHERE dl.id = artists.id AND dl.status = 'Completed')) " +
|
||||||
"WHERE songs.sourceId = artists.sourceId AND dl.artistId = artists.id AND sd.status = 'Completed') " +
|
|
||||||
"OR EXISTS (SELECT 1 FROM songs " +
|
|
||||||
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id AND sd.status = 'Completed')) " +
|
|
||||||
"AND (:search = '' " +
|
"AND (:search = '' " +
|
||||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
||||||
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
||||||
"OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)) "
|
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
||||||
|
"OR instr(lower(sortName), lower(:search)) > 0))) "
|
||||||
|
|
||||||
|
internal const val ARTIST_COLUMNS =
|
||||||
|
"artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " +
|
||||||
|
"artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
|
||||||
|
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited"
|
||||||
|
|
||||||
// With the Downloaded filter on, count the albums that actually have a download so the number under
|
// With the Downloaded filter on, count the albums that actually have a download so the number under
|
||||||
// an artist matches the albums its detail screen lists.
|
// an artist matches the albums its detail screen lists.
|
||||||
internal const val ARTISTS_SELECT =
|
internal const val ARTISTS_SELECT =
|
||||||
"SELECT artists.sourceId, artists.id, artists.name, " +
|
"SELECT artists.sourceId, artists.id, artists.name, " +
|
||||||
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
||||||
"SELECT COUNT(*) FROM (" +
|
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
|
||||||
"SELECT dl.id FROM songs " +
|
") ELSE artists.albumCount END AS albumCount, " +
|
||||||
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
|
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited "
|
||||||
"WHERE songs.sourceId = artists.sourceId AND dl.artistId = artists.id AND sd.status = 'Completed' " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT dl.id FROM songs " +
|
|
||||||
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id AND sd.status = 'Completed'" +
|
|
||||||
")) ELSE artists.albumCount END AS albumCount, " +
|
|
||||||
"artists.starred, artists.coverArt, artists.playCount, artists.played "
|
|
||||||
|
|
||||||
internal const val ARTIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "albumCount DESC, sortName COLLATE NOCASE, id"
|
||||||
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "albumCount ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "artists.albumCount DESC, name COLLATE NOCASE, id"
|
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "artists.albumCount ASC, name COLLATE NOCASE DESC, id DESC"
|
internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, name COLLATE NOCASE, id"
|
internal const val ARTIST_ORDER_BY_FREQUENT = "artists.playCount DESC, sortName COLLATE NOCASE, id"
|
||||||
internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
|
internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ARTIST_ORDER_BY_FREQUENT = "artists.playCount DESC, name COLLATE NOCASE, id"
|
internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||||
internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, name COLLATE NOCASE DESC, id DESC"
|
internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, name COLLATE NOCASE, id"
|
|
||||||
internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
|
private const val ARTIST_NAME_SORT_KEY_MODE =
|
||||||
|
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
|
||||||
|
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE = "$ARTIST_NAME_SORT_KEY_MODE, id"
|
||||||
|
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE_REVERSED = "$ARTIST_NAME_SORT_KEY_MODE DESC, id DESC"
|
||||||
|
|
||||||
|
private const val SONG_NAME_SORT_KEY_MODE =
|
||||||
|
"(CASE :sortMode WHEN 'Name' THEN songs.title WHEN 'NonLatin' THEN (CASE WHEN songs.title GLOB '*[A-Za-z]*' THEN songs.title ELSE songs.sortName END) ELSE songs.sortName END) COLLATE NOCASE"
|
||||||
|
internal const val SONG_SEARCH_ORDER_SORT_MODE = "$SONG_NAME_SORT_KEY_MODE, songs.id"
|
||||||
|
|
||||||
internal const val PLAYLISTS_FILTER =
|
internal const val PLAYLISTS_FILTER =
|
||||||
"FROM playlists WHERE sourceId = :sourceId " +
|
"FROM playlists WHERE sourceId = :sourceId " +
|
||||||
@@ -130,7 +365,7 @@ internal const val PLAYLISTS_SELECT =
|
|||||||
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId AND sd.status = 'Completed' " +
|
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId AND sd.status = 'Completed' " +
|
||||||
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id " +
|
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id " +
|
||||||
") ELSE playlists.songCount END AS songCount, " +
|
") ELSE playlists.songCount END AS songCount, " +
|
||||||
"playlists.created, playlists.changed, playlists.duration "
|
"playlists.created, playlists.changed, playlists.duration, playlists.owner, playlists.shared "
|
||||||
|
|
||||||
internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
||||||
internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
||||||
@@ -191,26 +426,63 @@ interface LibraryDao {
|
|||||||
)
|
)
|
||||||
suspend fun recomputeAlbumPlayData(sourceId: Long)
|
suspend fun recomputeAlbumPlayData(sourceId: Long)
|
||||||
|
|
||||||
// INDEXED BY is required: the planner otherwise answers MAX(played) from index_albums_recent
|
// Counts, plays and the credited flag come from the shared credit unions. The download-aware
|
||||||
// (sourceId, played), which cannot apply the artistId equality.
|
// queries are separate because their visibility follows the credit path.
|
||||||
@Query(
|
@Query(
|
||||||
"UPDATE artists SET " +
|
"WITH credited AS (SELECT DISTINCT * FROM ($CREDITED_SONG_ROWS)), " +
|
||||||
"playCount = COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
"credited_albums AS ($CREDITED_ALBUM_ROWS), " +
|
||||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id), 0), " +
|
"totals AS (" +
|
||||||
"played = (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
"SELECT artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
|
||||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id) " +
|
"SUM(playCount) AS playCount, MAX(played) AS played " +
|
||||||
"WHERE sourceId = :sourceId AND (" +
|
"FROM credited GROUP BY artistId" +
|
||||||
"playCount IS NOT COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
"), album_totals AS (" +
|
||||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id), 0) " +
|
"SELECT artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
|
||||||
"OR played IS NOT (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
"FROM credited_albums GROUP BY artistId" +
|
||||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id))",
|
") " +
|
||||||
|
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
|
||||||
|
"playCount = c.playCount, played = c.played, credited = c.credited " +
|
||||||
|
"FROM (SELECT artists.id AS id, COALESCE(totals.songCount, 0) AS songCount, " +
|
||||||
|
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
|
||||||
|
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played, " +
|
||||||
|
"((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " +
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " +
|
||||||
|
"OR EXISTS (SELECT 1 FROM album_artists aa " +
|
||||||
|
"WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " +
|
||||||
|
"OR EXISTS (SELECT 1 FROM song_artists sa " +
|
||||||
|
"WHERE sa.sourceId = artists.sourceId AND sa.artistId = artists.id)) AS credited " +
|
||||||
|
"FROM artists LEFT JOIN totals ON totals.id = artists.id " +
|
||||||
|
"LEFT JOIN album_totals ON album_totals.id = artists.id " +
|
||||||
|
"WHERE artists.sourceId = :sourceId) c " +
|
||||||
|
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
|
||||||
|
"AND (artists.songCount != c.songCount OR artists.albumCount != c.albumCount " +
|
||||||
|
"OR artists.playCount != c.playCount OR artists.played IS NOT c.played " +
|
||||||
|
"OR artists.credited != c.credited)",
|
||||||
)
|
)
|
||||||
suspend fun recomputeArtistPlayData(sourceId: Long)
|
suspend fun recomputeArtistAggregates(sourceId: Long)
|
||||||
|
|
||||||
|
@Query(ARTIST_ART_UPDATE_SQL)
|
||||||
|
suspend fun recomputeArtistArt(sourceId: Long)
|
||||||
|
|
||||||
|
@Query(ARTIST_COVER_ARTS_SQL)
|
||||||
|
fun artistCoverArts(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Flow<List<String>>
|
||||||
|
|
||||||
|
@Query(DOWNLOADED_ARTIST_ART_UPDATE_SQL)
|
||||||
|
suspend fun recomputeDownloadedArtistArt(
|
||||||
|
sourceId: Long,
|
||||||
|
artistIds: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Query("UPDATE artists SET downloadedCoverArts = '' WHERE sourceId = :sourceId AND downloadedCoverArts != ''")
|
||||||
|
suspend fun clearDownloadedArtistArt(sourceId: Long)
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
suspend fun recomputePlayData(sourceId: Long) {
|
suspend fun recomputeAggregates(sourceId: Long) {
|
||||||
recomputeAlbumPlayData(sourceId)
|
recomputeAlbumPlayData(sourceId)
|
||||||
recomputeArtistPlayData(sourceId)
|
recomputeArtistAggregates(sourceId)
|
||||||
|
recomputeArtistArt(sourceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
@@ -235,11 +507,16 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"UPDATE artists SET playCount = playCount + 1, played = MAX(COALESCE(played, :at), :at) " +
|
"UPDATE artists SET playCount = playCount + 1, played = MAX(COALESCE(played, :at), :at) " +
|
||||||
"WHERE sourceId = :sourceId AND id = (SELECT artistId FROM albums WHERE sourceId = :sourceId AND id = :albumId)",
|
"WHERE sourceId = :sourceId AND (" +
|
||||||
|
"id = (SELECT artistId FROM albums WHERE sourceId = :sourceId AND id = :albumId) " +
|
||||||
|
"OR id IN (SELECT artistId FROM album_artists WHERE sourceId = :sourceId AND albumId = :albumId) " +
|
||||||
|
"OR id = (SELECT artistId FROM songs WHERE sourceId = :sourceId AND id = :songId) " +
|
||||||
|
"OR id IN (SELECT artistId FROM song_artists WHERE sourceId = :sourceId AND songId = :songId))",
|
||||||
)
|
)
|
||||||
suspend fun bumpArtistPlay(
|
suspend fun bumpArtistPlay(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
albumId: String?,
|
||||||
|
songId: String,
|
||||||
at: Long,
|
at: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -249,6 +526,12 @@ interface LibraryDao {
|
|||||||
songId: String,
|
songId: String,
|
||||||
): String?
|
): String?
|
||||||
|
|
||||||
|
@Query("SELECT DISTINCT artistId FROM ($CREDITED_SONG_ROWS) WHERE songId IN (:songIds)")
|
||||||
|
suspend fun creditedArtistIdsForSongs(
|
||||||
|
sourceId: Long,
|
||||||
|
songIds: List<String>,
|
||||||
|
): List<String>
|
||||||
|
|
||||||
// Counted locally when a scrobble is submitted, so the play sorts move before the next sync; the
|
// Counted locally when a scrobble is submitted, so the play sorts move before the next sync; the
|
||||||
// sync then overwrites with the server's own number.
|
// sync then overwrites with the server's own number.
|
||||||
@Transaction
|
@Transaction
|
||||||
@@ -258,9 +541,9 @@ interface LibraryDao {
|
|||||||
at: Long,
|
at: Long,
|
||||||
) {
|
) {
|
||||||
bumpSongPlay(sourceId, songId, at)
|
bumpSongPlay(sourceId, songId, at)
|
||||||
val albumId = albumIdForSong(sourceId, songId) ?: return
|
val albumId = albumIdForSong(sourceId, songId)
|
||||||
bumpAlbumPlay(sourceId, albumId, at)
|
if (albumId != null) bumpAlbumPlay(sourceId, albumId, at)
|
||||||
bumpArtistPlay(sourceId, albumId, at)
|
bumpArtistPlay(sourceId, albumId, songId, at)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Albums, not songs: these flows re-run on every write to their table, and songs are written
|
// Albums, not songs: these flows re-run on every write to their table, and songs are written
|
||||||
@@ -273,7 +556,7 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL " +
|
"SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL " +
|
||||||
"ORDER BY played DESC, name COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY played DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun recentlyPlayedAlbums(
|
fun recentlyPlayedAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -281,8 +564,8 @@ interface LibraryDao {
|
|||||||
): Flow<List<Album>>
|
): Flow<List<Album>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL " +
|
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
|
||||||
"ORDER BY played DESC, name COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY played DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun recentlyPlayedArtists(
|
fun recentlyPlayedArtists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -291,7 +574,7 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 " +
|
"SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 " +
|
||||||
"ORDER BY playCount DESC, name COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun mostPlayedAlbums(
|
fun mostPlayedAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -299,8 +582,8 @@ interface LibraryDao {
|
|||||||
): Flow<List<Album>>
|
): Flow<List<Album>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 " +
|
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
|
||||||
"ORDER BY playCount DESC, name COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun mostPlayedArtists(
|
fun mostPlayedArtists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -309,7 +592,7 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId " +
|
"SELECT * FROM albums WHERE sourceId = :sourceId " +
|
||||||
"ORDER BY created DESC, name COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY created DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun recentlyAddedAlbums(
|
fun recentlyAddedAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -318,7 +601,7 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL AND played < :cutoff " +
|
"SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL AND played < :cutoff " +
|
||||||
"ORDER BY played ASC, name COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY played ASC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun rediscoverAlbums(
|
fun rediscoverAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -330,7 +613,7 @@ interface LibraryDao {
|
|||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
||||||
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit",
|
"ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun recentlyStarredSongs(
|
fun recentlyStarredSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -339,22 +622,24 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " +
|
"SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " +
|
||||||
"ORDER BY name COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun searchAlbums(
|
fun searchAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
search: String,
|
search: String,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
sortMode: String = "",
|
||||||
): Flow<List<Album>>
|
): Flow<List<Album>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER " +
|
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER AND $ARTIST_CREDITED " +
|
||||||
"ORDER BY name COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun searchArtists(
|
fun searchArtists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
search: String,
|
search: String,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
sortMode: String = "",
|
||||||
): Flow<List<Artist>>
|
): Flow<List<Artist>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
@@ -371,12 +656,13 @@ interface LibraryDao {
|
|||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND $SONG_SEARCH_FILTER " +
|
"WHERE songs.sourceId = :sourceId AND $SONG_SEARCH_FILTER " +
|
||||||
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit",
|
"ORDER BY $SONG_SEARCH_ORDER_SORT_MODE LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun searchSongs(
|
fun searchSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
search: String,
|
search: String,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
sortMode: String = "",
|
||||||
): Flow<List<AlbumSongItem>>
|
): Flow<List<AlbumSongItem>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
@@ -510,42 +796,48 @@ interface LibraryDao {
|
|||||||
@RawQuery(observedEntities = [Album::class])
|
@RawQuery(observedEntities = [Album::class])
|
||||||
fun albumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
|
fun albumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
|
||||||
|
|
||||||
@RawQuery(observedEntities = [Artist::class])
|
// The fallback art reads albums and credits, but a successful sync writes sources after the
|
||||||
|
// library changes, so observing artists and sources avoids invalidating on every song/album write.
|
||||||
|
@RawQuery(observedEntities = [Artist::class, Source::class])
|
||||||
fun artistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
|
fun artistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@RawQuery(observedEntities = [Playlist::class])
|
@RawQuery(observedEntities = [Playlist::class])
|
||||||
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME")
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE")
|
||||||
fun albumsByName(
|
fun albumsByName(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
|
sortMode: String = "",
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_REVERSED")
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED")
|
||||||
fun albumsByNameReversed(
|
fun albumsByNameReversed(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
|
sortMode: String = "",
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST")
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_SORT_MODE")
|
||||||
fun albumsByArtist(
|
fun albumsByArtist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
|
sortMode: String = "",
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_REVERSED")
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_SORT_MODE_REVERSED")
|
||||||
fun albumsByArtistReversed(
|
fun albumsByArtistReversed(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
|
sortMode: String = "",
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR")
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR")
|
||||||
@@ -628,20 +920,22 @@ interface LibraryDao {
|
|||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME")
|
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE")
|
||||||
fun artistsByName(
|
fun artistsByName(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
|
sortMode: String = "",
|
||||||
): PagingSource<Int, Artist>
|
): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_REVERSED")
|
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE_REVERSED")
|
||||||
fun artistsByNameReversed(
|
fun artistsByNameReversed(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
|
sortMode: String = "",
|
||||||
): PagingSource<Int, Artist>
|
): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT")
|
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT")
|
||||||
@@ -750,32 +1044,38 @@ interface LibraryDao {
|
|||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
): PagingSource<Int, Playlist>
|
): PagingSource<Int, Playlist>
|
||||||
|
|
||||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, name COLLATE NOCASE, id")
|
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, sortName COLLATE NOCASE, id")
|
||||||
fun homeRecentlyPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
|
fun homeRecentlyPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, name COLLATE NOCASE, id")
|
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, sortName COLLATE NOCASE, id")
|
||||||
fun homeMostPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
|
fun homeMostPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE, id")
|
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, sortName COLLATE NOCASE, id")
|
||||||
fun homeRecentlyAddedAlbums(sourceId: Long): PagingSource<Int, Album>
|
fun homeRecentlyAddedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, name COLLATE NOCASE, id")
|
@Query(
|
||||||
|
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
|
||||||
|
"ORDER BY played DESC, sortName COLLATE NOCASE, id",
|
||||||
|
)
|
||||||
fun homeRecentlyPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
|
fun homeRecentlyPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, name COLLATE NOCASE, id")
|
@Query(
|
||||||
|
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
|
||||||
|
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id",
|
||||||
|
)
|
||||||
fun homeMostPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
|
fun homeMostPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
||||||
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id",
|
"ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id",
|
||||||
)
|
)
|
||||||
fun starredSongs(sourceId: Long): PagingSource<Int, AlbumSongItem>
|
fun starredSongs(sourceId: Long): PagingSource<Int, AlbumSongItem>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
|
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
|
||||||
"ORDER BY starred DESC, title COLLATE NOCASE, id",
|
"ORDER BY starred DESC, sortName COLLATE NOCASE, id",
|
||||||
)
|
)
|
||||||
suspend fun starredSongIds(sourceId: Long): List<String>
|
suspend fun starredSongIds(sourceId: Long): List<String>
|
||||||
|
|
||||||
@@ -784,7 +1084,7 @@ interface LibraryDao {
|
|||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
|
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
|
||||||
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
|
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||||
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit",
|
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun downloadedSongs(
|
fun downloadedSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -795,7 +1095,7 @@ interface LibraryDao {
|
|||||||
"SELECT songs.id FROM songs " +
|
"SELECT songs.id FROM songs " +
|
||||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
|
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
|
||||||
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
|
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||||
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id",
|
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
|
||||||
)
|
)
|
||||||
suspend fun downloadedSongIds(sourceId: Long): List<String>
|
suspend fun downloadedSongIds(sourceId: Long): List<String>
|
||||||
|
|
||||||
@@ -804,7 +1104,7 @@ interface LibraryDao {
|
|||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
|
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
|
||||||
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
|
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||||
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id",
|
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
|
||||||
)
|
)
|
||||||
fun homeDownloadedSongs(sourceId: Long): PagingSource<Int, AlbumSongItem>
|
fun homeDownloadedSongs(sourceId: Long): PagingSource<Int, AlbumSongItem>
|
||||||
|
|
||||||
@@ -814,28 +1114,16 @@ interface LibraryDao {
|
|||||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
|
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
|
||||||
"WHERE albums.sourceId = :sourceId " +
|
"WHERE albums.sourceId = :sourceId " +
|
||||||
"GROUP BY albums.sourceId, albums.id " +
|
"GROUP BY albums.sourceId, albums.id " +
|
||||||
"ORDER BY MAX(d.downloadedAt) DESC, albums.name COLLATE NOCASE, albums.id",
|
"ORDER BY MAX(d.downloadedAt) DESC, albums.sortName COLLATE NOCASE, albums.id",
|
||||||
)
|
)
|
||||||
fun downloadedAlbums(sourceId: Long): PagingSource<Int, Album>
|
fun downloadedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT artists.* FROM artists " +
|
"SELECT $ARTIST_COLUMNS FROM artists " +
|
||||||
"JOIN (" +
|
"JOIN (SELECT id, MAX(downloadedAt) AS lastDownloadedAt FROM ($DOWNLOADED_ARTIST_ROWS) " +
|
||||||
"SELECT albums.artistId AS artistId, d.downloadedAt AS downloadedAt " +
|
"WHERE status = 'Completed' GROUP BY id) dl ON dl.id = artists.id " +
|
||||||
"FROM songs " +
|
"WHERE artists.sourceId = :sourceId AND $ARTIST_CREDITED " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"ORDER BY dl.lastDownloadedAt DESC, artists.sortName COLLATE NOCASE, artists.id",
|
||||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT songs.artistId AS artistId, d.downloadedAt AS downloadedAt " +
|
|
||||||
"FROM songs " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL" +
|
|
||||||
") dl ON dl.artistId = artists.id " +
|
|
||||||
"WHERE artists.sourceId = :sourceId " +
|
|
||||||
"GROUP BY artists.sourceId, artists.id " +
|
|
||||||
"ORDER BY MAX(dl.downloadedAt) DESC, artists.name COLLATE NOCASE, artists.id",
|
|
||||||
)
|
)
|
||||||
fun downloadedArtists(sourceId: Long): PagingSource<Int, Artist>
|
fun downloadedArtists(sourceId: Long): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@@ -844,7 +1132,7 @@ interface LibraryDao {
|
|||||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
||||||
"ORDER BY songs.title COLLATE NOCASE, songs.id",
|
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
|
||||||
)
|
)
|
||||||
fun songsByGenre(
|
fun songsByGenre(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -855,7 +1143,7 @@ interface LibraryDao {
|
|||||||
"SELECT songs.id FROM song_genres " +
|
"SELECT songs.id FROM song_genres " +
|
||||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
||||||
"ORDER BY songs.title COLLATE NOCASE, songs.id",
|
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
|
||||||
)
|
)
|
||||||
suspend fun songsByGenreIds(
|
suspend fun songsByGenreIds(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -866,12 +1154,12 @@ interface LibraryDao {
|
|||||||
"SELECT COUNT(*) FROM song_genres " +
|
"SELECT COUNT(*) FROM song_genres " +
|
||||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
||||||
"AND (songs.title COLLATE NOCASE < :title OR (songs.title COLLATE NOCASE = :title AND songs.id < :id))",
|
"AND (songs.sortName COLLATE NOCASE < :sortName OR (songs.sortName COLLATE NOCASE = :sortName AND songs.id < :id))",
|
||||||
)
|
)
|
||||||
suspend fun genreSongOrdinal(
|
suspend fun genreSongOrdinal(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
genre: String,
|
genre: String,
|
||||||
title: String,
|
sortName: String,
|
||||||
id: String,
|
id: String,
|
||||||
): Long
|
): Long
|
||||||
|
|
||||||
@@ -881,8 +1169,8 @@ interface LibraryDao {
|
|||||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||||
"AND (d.downloadedAt > (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " +
|
"AND (d.downloadedAt > (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " +
|
||||||
"OR (d.downloadedAt = (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " +
|
"OR (d.downloadedAt = (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " +
|
||||||
"AND (s.title COLLATE NOCASE < (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
"AND (s.sortName COLLATE NOCASE < (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
||||||
"OR (s.title COLLATE NOCASE = (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
|
"OR (s.sortName COLLATE NOCASE = (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
|
||||||
)
|
)
|
||||||
suspend fun downloadedSongOrdinal(
|
suspend fun downloadedSongOrdinal(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -894,8 +1182,8 @@ interface LibraryDao {
|
|||||||
"WHERE s.sourceId = :sourceId AND s.starred IS NOT NULL " +
|
"WHERE s.sourceId = :sourceId AND s.starred IS NOT NULL " +
|
||||||
"AND (s.starred > (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
"AND (s.starred > (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
||||||
"OR (s.starred = (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
"OR (s.starred = (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
||||||
"AND (s.title COLLATE NOCASE < (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
"AND (s.sortName COLLATE NOCASE < (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
||||||
"OR (s.title COLLATE NOCASE = (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
|
"OR (s.sortName COLLATE NOCASE = (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
|
||||||
)
|
)
|
||||||
suspend fun starredSongOrdinal(
|
suspend fun starredSongOrdinal(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -904,7 +1192,7 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND year >= :start AND year < :end " +
|
"SELECT * FROM albums WHERE sourceId = :sourceId AND year >= :start AND year < :end " +
|
||||||
"ORDER BY year DESC, name COLLATE NOCASE, id",
|
"ORDER BY year DESC, sortName COLLATE NOCASE, id",
|
||||||
)
|
)
|
||||||
fun albumsByDecade(
|
fun albumsByDecade(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -930,22 +1218,40 @@ interface LibraryDao {
|
|||||||
albumId: String,
|
albumId: String,
|
||||||
): Album?
|
): Album?
|
||||||
|
|
||||||
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND id = :artistId")
|
@Query("SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND id = :artistId")
|
||||||
suspend fun artistOnce(
|
suspend fun artistOnce(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
): Artist?
|
): Artist?
|
||||||
|
|
||||||
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND id = :artistId")
|
@Query("SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND id = :artistId")
|
||||||
fun artist(
|
fun artist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
): Flow<Artist?>
|
): Flow<Artist?>
|
||||||
|
|
||||||
// A credited track artist may not have an artists row; its name only lives on the songs.
|
@Query("SELECT * FROM artist_bios WHERE sourceId = :sourceId AND artistId = :artistId")
|
||||||
|
fun artistBio(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Flow<ArtistBio?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM artist_bios WHERE sourceId = :sourceId AND artistId = :artistId")
|
||||||
|
suspend fun artistBioOnce(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): ArtistBio?
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsertArtistBio(item: ArtistBio)
|
||||||
|
|
||||||
|
// A credited track artist may not have an artists row; its name lives on song_artists or the songs.
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT artist FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
|
"SELECT COALESCE(" +
|
||||||
"AND artist IS NOT NULL AND artist != '' ORDER BY id LIMIT 1",
|
"(SELECT name FROM song_artists WHERE sourceId = :sourceId AND artistId = :artistId " +
|
||||||
|
"AND name != '' LIMIT 1), " +
|
||||||
|
"(SELECT artist FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
|
||||||
|
"AND artist IS NOT NULL AND artist != '' LIMIT 1))",
|
||||||
)
|
)
|
||||||
fun artistName(
|
fun artistName(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -970,16 +1276,18 @@ interface LibraryDao {
|
|||||||
songId: String,
|
songId: String,
|
||||||
): Flow<List<String>>
|
): Flow<List<String>>
|
||||||
|
|
||||||
@Query(
|
@Query(ALBUMS_FOR_ARTIST)
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND (artistId = :artistId " +
|
|
||||||
"OR id IN (SELECT albumId FROM songs WHERE sourceId = :sourceId AND artistId = :artistId)) " +
|
|
||||||
"ORDER BY year DESC, name COLLATE NOCASE, id",
|
|
||||||
)
|
|
||||||
fun albumsForArtist(
|
fun albumsForArtist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
): Flow<List<Album>>
|
): Flow<List<Album>>
|
||||||
|
|
||||||
|
@Query(ALBUM_CREDIT_ALBUM_IDS)
|
||||||
|
fun albumCreditAlbumIds(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Flow<List<String>>
|
||||||
|
|
||||||
@Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
|
@Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
|
||||||
fun discs(
|
fun discs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -1015,15 +1323,33 @@ interface LibraryDao {
|
|||||||
playlistId: String,
|
playlistId: String,
|
||||||
): PagingSource<Int, PlaylistSongItem>
|
): PagingSource<Int, PlaylistSongItem>
|
||||||
|
|
||||||
|
// Download visibility follows the credit path: see DOWNLOADED_ARTIST_ALBUMS.
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND (" +
|
"SELECT * FROM (" +
|
||||||
"(artistId = :artistId AND EXISTS (SELECT 1 FROM songs " +
|
"SELECT albums.* FROM albums " +
|
||||||
|
"WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
||||||
|
"AND EXISTS (SELECT 1 FROM songs " +
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')) " +
|
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||||
"OR id IN (SELECT songs.albumId FROM songs " +
|
"UNION " +
|
||||||
|
"SELECT albums.* FROM album_artists " +
|
||||||
|
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||||
|
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
|
||||||
|
"AND EXISTS (SELECT 1 FROM songs " +
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId AND sd.status = 'Completed')) " +
|
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||||
"ORDER BY year DESC, name COLLATE NOCASE, id",
|
"UNION " +
|
||||||
|
"SELECT albums.* FROM song_artists " +
|
||||||
|
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||||
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
|
||||||
|
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT albums.* FROM songs " +
|
||||||
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
|
||||||
|
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" +
|
||||||
|
") ORDER BY year DESC, sortName COLLATE NOCASE, id",
|
||||||
)
|
)
|
||||||
fun albumsForArtistDownloaded(
|
fun albumsForArtistDownloaded(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.sqlite.SQLiteStatement
|
|||||||
import com.subtracks.data.prefs.AlbumSort
|
import com.subtracks.data.prefs.AlbumSort
|
||||||
import com.subtracks.data.prefs.ArtistSort
|
import com.subtracks.data.prefs.ArtistSort
|
||||||
import com.subtracks.data.prefs.PlaylistSort
|
import com.subtracks.data.prefs.PlaylistSort
|
||||||
|
import com.subtracks.data.prefs.SortNameMode
|
||||||
|
|
||||||
// These filters mirror the download-aware ones in LibraryDao but never mention song_downloads, so
|
// These filters mirror the download-aware ones in LibraryDao but never mention song_downloads, so
|
||||||
// the resulting PagingSource is not invalidated by download writes (including progress updates).
|
// the resulting PagingSource is not invalidated by download writes (including progress updates).
|
||||||
@@ -15,15 +16,19 @@ internal const val ALBUMS_FILTER_BASE =
|
|||||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
||||||
"WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
"WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
||||||
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
||||||
"OR instr(lower(albumArtist), lower(?)) > 0))) "
|
"OR instr(lower(albumArtist), lower(?)) > 0 " +
|
||||||
|
"OR instr(lower(sortName), lower(?)) > 0 " +
|
||||||
|
"OR instr(lower(albumArtistSort), lower(?)) > 0))) "
|
||||||
|
|
||||||
internal const val ARTISTS_FILTER_BASE =
|
internal const val ARTISTS_FILTER_BASE =
|
||||||
"FROM artists WHERE sourceId = ? " +
|
"FROM artists WHERE sourceId = ? " +
|
||||||
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
||||||
|
"AND $ARTIST_CREDITED " +
|
||||||
"AND (? = '' " +
|
"AND (? = '' " +
|
||||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
||||||
"WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
"WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
||||||
"OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) "
|
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
||||||
|
"OR instr(lower(sortName), lower(?)) > 0))) "
|
||||||
|
|
||||||
internal const val PLAYLISTS_FILTER_BASE =
|
internal const val PLAYLISTS_FILTER_BASE =
|
||||||
"FROM playlists WHERE sourceId = ? " +
|
"FROM playlists WHERE sourceId = ? " +
|
||||||
@@ -38,8 +43,9 @@ internal fun albumsDefaultQuery(
|
|||||||
search: String,
|
search: String,
|
||||||
sort: AlbumSort,
|
sort: AlbumSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
|
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||||
): RoomRawQuery {
|
): RoomRawQuery {
|
||||||
val order = albumOrder(sort, descending)
|
val order = albumOrder(sort, descending, sortNameMode)
|
||||||
return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt ->
|
return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
||||||
}
|
}
|
||||||
@@ -53,10 +59,10 @@ internal fun rediscoverAlbumsQuery(
|
|||||||
): RoomRawQuery =
|
): RoomRawQuery =
|
||||||
RoomRawQuery(
|
RoomRawQuery(
|
||||||
"SELECT * $ALBUMS_FILTER_BASE AND played IS NOT NULL AND played < ? " +
|
"SELECT * $ALBUMS_FILTER_BASE AND played IS NOT NULL AND played < ? " +
|
||||||
"ORDER BY played ASC, name COLLATE NOCASE, id",
|
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
|
||||||
) { stmt ->
|
) { stmt ->
|
||||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
||||||
stmt.bindLong(11, cutoff)
|
stmt.bindLong(13, cutoff)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun SQLiteStatement.bindAlbumFilters(
|
private fun SQLiteStatement.bindAlbumFilters(
|
||||||
@@ -74,6 +80,8 @@ private fun SQLiteStatement.bindAlbumFilters(
|
|||||||
bindText(8, search)
|
bindText(8, search)
|
||||||
bindText(9, search)
|
bindText(9, search)
|
||||||
bindText(10, search)
|
bindText(10, search)
|
||||||
|
bindText(11, search)
|
||||||
|
bindText(12, search)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun artistsDefaultQuery(
|
internal fun artistsDefaultQuery(
|
||||||
@@ -82,9 +90,10 @@ internal fun artistsDefaultQuery(
|
|||||||
search: String,
|
search: String,
|
||||||
sort: ArtistSort,
|
sort: ArtistSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
|
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||||
): RoomRawQuery {
|
): RoomRawQuery {
|
||||||
val order = artistOrder(sort, descending)
|
val order = artistOrder(sort, descending, sortNameMode)
|
||||||
return RoomRawQuery("SELECT * $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
return RoomRawQuery("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||||
stmt.bindLong(1, sourceId)
|
stmt.bindLong(1, sourceId)
|
||||||
stmt.bindInt(2, starredFilter)
|
stmt.bindInt(2, starredFilter)
|
||||||
stmt.bindInt(3, starredFilter)
|
stmt.bindInt(3, starredFilter)
|
||||||
@@ -94,6 +103,7 @@ internal fun artistsDefaultQuery(
|
|||||||
stmt.bindText(7, search)
|
stmt.bindText(7, search)
|
||||||
stmt.bindText(8, search)
|
stmt.bindText(8, search)
|
||||||
stmt.bindText(9, search)
|
stmt.bindText(9, search)
|
||||||
|
stmt.bindText(10, search)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,27 +124,82 @@ internal fun playlistsDefaultQuery(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun albumNameSortKey(mode: SortNameMode): String =
|
||||||
|
when (mode) {
|
||||||
|
SortNameMode.SortName -> "sortName COLLATE NOCASE"
|
||||||
|
SortNameMode.Name -> "name COLLATE NOCASE"
|
||||||
|
SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun albumArtistSortKey(mode: SortNameMode): String =
|
||||||
|
when (mode) {
|
||||||
|
SortNameMode.SortName -> {
|
||||||
|
"albumArtistSort COLLATE NOCASE"
|
||||||
|
}
|
||||||
|
|
||||||
|
SortNameMode.Name -> {
|
||||||
|
"albumArtist COLLATE NOCASE"
|
||||||
|
}
|
||||||
|
|
||||||
|
SortNameMode.NonLatin -> {
|
||||||
|
"(CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) COLLATE NOCASE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun artistNameSortKey(mode: SortNameMode): String =
|
||||||
|
when (mode) {
|
||||||
|
SortNameMode.SortName -> "sortName COLLATE NOCASE"
|
||||||
|
SortNameMode.Name -> "name COLLATE NOCASE"
|
||||||
|
SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE"
|
||||||
|
}
|
||||||
|
|
||||||
private fun albumOrder(
|
private fun albumOrder(
|
||||||
sort: AlbumSort,
|
sort: AlbumSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
|
mode: SortNameMode,
|
||||||
): String =
|
): String =
|
||||||
when (sort) {
|
when (sort) {
|
||||||
AlbumSort.Name -> if (descending) ALBUM_ORDER_BY_NAME_REVERSED else ALBUM_ORDER_BY_NAME
|
AlbumSort.Name -> {
|
||||||
AlbumSort.Artist -> if (descending) ALBUM_ORDER_BY_ARTIST_REVERSED else ALBUM_ORDER_BY_ARTIST
|
val name = albumNameSortKey(mode)
|
||||||
AlbumSort.Year -> if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR
|
if (descending) "$name DESC, id DESC" else "$name, id"
|
||||||
AlbumSort.Added -> if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED
|
}
|
||||||
AlbumSort.Starred -> if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED
|
|
||||||
AlbumSort.Frequent -> if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT
|
AlbumSort.Artist -> {
|
||||||
AlbumSort.Recent -> if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT
|
val artist = albumArtistSortKey(mode)
|
||||||
|
val name = albumNameSortKey(mode)
|
||||||
|
if (descending) "$artist DESC, year DESC, $name DESC, id DESC" else "$artist, year, $name, id"
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Year -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Added -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Starred -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Frequent -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Recent -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun artistOrder(
|
private fun artistOrder(
|
||||||
sort: ArtistSort,
|
sort: ArtistSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
|
mode: SortNameMode,
|
||||||
): String =
|
): String =
|
||||||
when (sort) {
|
when (sort) {
|
||||||
ArtistSort.Name -> {
|
ArtistSort.Name -> {
|
||||||
if (descending) ARTIST_ORDER_BY_NAME_REVERSED else ARTIST_ORDER_BY_NAME
|
val name = artistNameSortKey(mode)
|
||||||
|
if (descending) "$name DESC, id DESC" else "$name, id"
|
||||||
}
|
}
|
||||||
|
|
||||||
ArtistSort.AlbumCount -> {
|
ArtistSort.AlbumCount -> {
|
||||||
|
|||||||
@@ -384,6 +384,285 @@ val MIGRATION_26_27 =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_27_28 =
|
||||||
|
object : Migration(27, 28) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `trackGain` REAL")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `albumGain` REAL")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `trackPeak` REAL")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `albumPeak` REAL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_28_29 =
|
||||||
|
object : Migration(28, 29) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `artists` TEXT NOT NULL DEFAULT ''")
|
||||||
|
connection.execSQL("ALTER TABLE `albums` ADD COLUMN `artists` TEXT NOT NULL DEFAULT ''")
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `album_artists` (`sourceId` INTEGER NOT NULL, `albumId` TEXT NOT NULL, " +
|
||||||
|
"`position` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
|
||||||
|
"PRIMARY KEY(`sourceId`, `albumId`, `position`), " +
|
||||||
|
"FOREIGN KEY(`sourceId`, `albumId`) REFERENCES `albums`(`sourceId`, `id`) " +
|
||||||
|
"ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_album_artists_sourceId_artistId` ON `album_artists` (`sourceId`, `artistId`)",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `song_artists` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, " +
|
||||||
|
"`position` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
|
||||||
|
"PRIMARY KEY(`sourceId`, `songId`, `position`), " +
|
||||||
|
"FOREIGN KEY(`sourceId`, `songId`) REFERENCES `songs`(`sourceId`, `id`) " +
|
||||||
|
"ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_song_artists_sourceId_artistId` ON `song_artists` (`sourceId`, `artistId`)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_29_30 =
|
||||||
|
object : Migration(29, 30) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `songCount` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
connection.execSQL(
|
||||||
|
"WITH credited AS (" +
|
||||||
|
"SELECT sourceId, artistId, songId FROM song_artists " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT sourceId, artistId, id AS songId FROM songs WHERE artistId IS NOT NULL " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT album_artists.sourceId, album_artists.artistId, songs.id " +
|
||||||
|
"FROM album_artists JOIN songs " +
|
||||||
|
"ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT albums.sourceId, albums.artistId, songs.id " +
|
||||||
|
"FROM albums JOIN songs ON songs.sourceId = albums.sourceId AND songs.albumId = albums.id " +
|
||||||
|
"WHERE albums.artistId IS NOT NULL" +
|
||||||
|
"), counts AS (" +
|
||||||
|
"SELECT sourceId, artistId AS id, COUNT(*) AS songCount FROM credited GROUP BY sourceId, artistId" +
|
||||||
|
") " +
|
||||||
|
"UPDATE artists SET songCount = c.songCount " +
|
||||||
|
"FROM (SELECT artists.sourceId AS sourceId, artists.id AS id, " +
|
||||||
|
"COALESCE(counts.songCount, 0) AS songCount " +
|
||||||
|
"FROM artists LEFT JOIN counts " +
|
||||||
|
"ON counts.sourceId = artists.sourceId AND counts.id = artists.id) c " +
|
||||||
|
"WHERE artists.sourceId = c.sourceId AND artists.id = c.id " +
|
||||||
|
"AND artists.songCount != c.songCount",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_30_31 =
|
||||||
|
object : Migration(30, 31) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
|
||||||
|
connection.execSQL("ALTER TABLE `albums` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
|
||||||
|
connection.execSQL("ALTER TABLE `albums` ADD COLUMN `albumArtistSort` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `artistSort` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
|
||||||
|
dropSearchIndex(connection, "artist_search")
|
||||||
|
dropSearchIndex(connection, "album_search")
|
||||||
|
dropSearchIndex(connection, "song_search")
|
||||||
|
connection.execSQL("UPDATE `artists` SET `sortName` = `name`")
|
||||||
|
connection.execSQL("UPDATE `albums` SET `sortName` = `name`")
|
||||||
|
connection.execSQL("UPDATE `songs` SET `sortName` = `title`")
|
||||||
|
connection.execSQL(
|
||||||
|
"UPDATE `albums` SET `albumArtistSort` = COALESCE(" +
|
||||||
|
"(SELECT a.`sortName` FROM `artists` a " +
|
||||||
|
"WHERE a.`sourceId` = `albums`.`sourceId` AND a.`id` = `albums`.`artistId`), " +
|
||||||
|
"(SELECT a.`sortName` FROM `album_artists` aa JOIN `artists` a " +
|
||||||
|
"ON a.`sourceId` = aa.`sourceId` AND a.`id` = aa.`artistId` " +
|
||||||
|
"WHERE aa.`sourceId` = `albums`.`sourceId` AND aa.`albumId` = `albums`.`id` " +
|
||||||
|
"ORDER BY aa.`position` LIMIT 1), " +
|
||||||
|
"`albumArtist`, '')",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"UPDATE `songs` SET `artistSort` = COALESCE(" +
|
||||||
|
"(SELECT a.`sortName` FROM `artists` a " +
|
||||||
|
"WHERE a.`sourceId` = `songs`.`sourceId` AND a.`id` = `songs`.`artistId`), " +
|
||||||
|
"(SELECT a.`sortName` FROM `song_artists` sa JOIN `artists` a " +
|
||||||
|
"ON a.`sourceId` = sa.`sourceId` AND a.`id` = sa.`artistId` " +
|
||||||
|
"WHERE sa.`sourceId` = `songs`.`sourceId` AND sa.`songId` = `songs`.`id` " +
|
||||||
|
"ORDER BY sa.`position` LIMIT 1), " +
|
||||||
|
"`artist`, '')",
|
||||||
|
)
|
||||||
|
ARTIST_INDEXES.forEach { connection.execSQL("DROP INDEX IF EXISTS `$it`") }
|
||||||
|
ALBUM_INDEXES.forEach { connection.execSQL("DROP INDEX IF EXISTS `$it`") }
|
||||||
|
ARTIST_INDEX_SQL.forEach { connection.execSQL(it) }
|
||||||
|
ALBUM_INDEX_SQL.forEach { connection.execSQL(it) }
|
||||||
|
createSearchIndex(connection, "artist_search", "artists", listOf("name", "sortName"))
|
||||||
|
createSearchIndex(connection, "album_search", "albums", listOf("name", "albumArtist", "sortName", "albumArtistSort"))
|
||||||
|
createSearchIndex(connection, "song_search", "songs", listOf("title", "artist", "sortName", "artistSort"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ARTIST_INDEXES =
|
||||||
|
listOf("index_artists_name", "index_artists_albumCount", "index_artists_starred", "index_artists_frequent", "index_artists_recent")
|
||||||
|
|
||||||
|
private val ALBUM_INDEXES =
|
||||||
|
listOf(
|
||||||
|
"index_albums_name",
|
||||||
|
"index_albums_artist",
|
||||||
|
"index_albums_year",
|
||||||
|
"index_albums_added",
|
||||||
|
"index_albums_starred",
|
||||||
|
"index_albums_frequent",
|
||||||
|
"index_albums_recent",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val ARTIST_INDEX_SQL =
|
||||||
|
listOf(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artists_name` ON `artists` (`sourceId`, `sortName`, `id`)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artists_albumCount` ON `artists` " +
|
||||||
|
"(`sourceId` ASC, `albumCount` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artists_starred` ON `artists` " +
|
||||||
|
"(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artists_frequent` ON `artists` " +
|
||||||
|
"(`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artists_recent` ON `artists` " +
|
||||||
|
"(`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val ALBUM_INDEX_SQL =
|
||||||
|
listOf(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_name` ON `albums` (`sourceId`, `sortName`, `id`)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_artist` ON `albums` " +
|
||||||
|
"(`sourceId`, `albumArtistSort`, `year`, `sortName`, `id`)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_year` ON `albums` " +
|
||||||
|
"(`sourceId` ASC, `year` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_added` ON `albums` " +
|
||||||
|
"(`sourceId` ASC, `created` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_starred` ON `albums` " +
|
||||||
|
"(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_frequent` ON `albums` " +
|
||||||
|
"(`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_recent` ON `albums` " +
|
||||||
|
"(`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun dropSearchIndex(
|
||||||
|
connection: SQLiteConnection,
|
||||||
|
table: String,
|
||||||
|
) {
|
||||||
|
listOf("BEFORE_UPDATE", "BEFORE_DELETE", "AFTER_UPDATE", "AFTER_INSERT").forEach {
|
||||||
|
connection.execSQL("DROP TRIGGER IF EXISTS `room_fts_content_sync_${table}_$it`")
|
||||||
|
}
|
||||||
|
connection.execSQL("DROP TABLE IF EXISTS `$table`")
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_31_32 =
|
||||||
|
object : Migration(31, 32) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `credited` INTEGER NOT NULL DEFAULT 1")
|
||||||
|
connection.execSQL(
|
||||||
|
"UPDATE `artists` SET `credited` = 0 " +
|
||||||
|
"WHERE EXISTS (SELECT 1 FROM `album_artists` WHERE `sourceId` = `artists`.`sourceId`) " +
|
||||||
|
"AND EXISTS (SELECT 1 FROM `song_artists` WHERE `sourceId` = `artists`.`sourceId`) " +
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM `album_artists` aa " +
|
||||||
|
"WHERE aa.`sourceId` = `artists`.`sourceId` AND aa.`artistId` = `artists`.`id`) " +
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM `song_artists` sa " +
|
||||||
|
"WHERE sa.`sourceId` = `artists`.`sourceId` AND sa.`artistId` = `artists`.`id`)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_32_33 =
|
||||||
|
object : Migration(32, 33) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `sources` ADD COLUMN `creditsStale` INTEGER NOT NULL DEFAULT 1")
|
||||||
|
connection.execSQL("DROP INDEX IF EXISTS `index_songs_starred`")
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_songs_starred` ON `songs` " +
|
||||||
|
"(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recomputes the artist totals from the same credit unions the sync uses, so badges, the artist
|
||||||
|
// page and the shuffle queue agree for libraries that predate the change.
|
||||||
|
val MIGRATION_33_34 =
|
||||||
|
object : Migration(33, 34) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL(
|
||||||
|
"WITH credited_songs AS (" +
|
||||||
|
"SELECT songs.`sourceId` AS sourceId, songs.`artistId` AS artistId, " +
|
||||||
|
"songs.`id` AS songId, songs.`playCount` AS playCount, songs.`played` AS played " +
|
||||||
|
"FROM songs WHERE songs.`artistId` IS NOT NULL " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT song_artists.`sourceId`, song_artists.`artistId`, songs.`id`, " +
|
||||||
|
"songs.`playCount`, songs.`played` " +
|
||||||
|
"FROM song_artists JOIN songs " +
|
||||||
|
"ON songs.`sourceId` = song_artists.`sourceId` AND songs.`id` = song_artists.`songId` " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT albums.`sourceId`, albums.`artistId`, songs.`id`, " +
|
||||||
|
"songs.`playCount`, songs.`played` " +
|
||||||
|
"FROM albums JOIN songs " +
|
||||||
|
"ON songs.`sourceId` = albums.`sourceId` AND songs.`albumId` = albums.`id` " +
|
||||||
|
"WHERE albums.`artistId` IS NOT NULL " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT album_artists.`sourceId`, album_artists.`artistId`, songs.`id`, " +
|
||||||
|
"songs.`playCount`, songs.`played` " +
|
||||||
|
"FROM album_artists JOIN songs " +
|
||||||
|
"ON songs.`sourceId` = album_artists.`sourceId` AND songs.`albumId` = album_artists.`albumId`" +
|
||||||
|
"), credited_albums AS (" +
|
||||||
|
"SELECT albums.`sourceId` AS sourceId, albums.`artistId` AS artistId, " +
|
||||||
|
"albums.`id` AS albumId " +
|
||||||
|
"FROM albums WHERE albums.`artistId` IS NOT NULL " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT album_artists.`sourceId`, album_artists.`artistId`, albums.`id` " +
|
||||||
|
"FROM album_artists JOIN albums " +
|
||||||
|
"ON albums.`sourceId` = album_artists.`sourceId` AND albums.`id` = album_artists.`albumId` " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT songs.`sourceId`, songs.`artistId`, songs.`albumId` " +
|
||||||
|
"FROM songs JOIN albums " +
|
||||||
|
"ON albums.`sourceId` = songs.`sourceId` AND albums.`id` = songs.`albumId` " +
|
||||||
|
"WHERE songs.`artistId` IS NOT NULL " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT song_artists.`sourceId`, song_artists.`artistId`, songs.`albumId` " +
|
||||||
|
"FROM song_artists JOIN songs " +
|
||||||
|
"ON songs.`sourceId` = song_artists.`sourceId` AND songs.`id` = song_artists.`songId` " +
|
||||||
|
"JOIN albums ON albums.`sourceId` = songs.`sourceId` AND albums.`id` = songs.`albumId`" +
|
||||||
|
"), totals AS (" +
|
||||||
|
"SELECT sourceId, artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
|
||||||
|
"SUM(playCount) AS playCount, MAX(played) AS played " +
|
||||||
|
"FROM credited_songs GROUP BY sourceId, artistId" +
|
||||||
|
"), album_totals AS (" +
|
||||||
|
"SELECT sourceId, artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
|
||||||
|
"FROM credited_albums GROUP BY sourceId, artistId" +
|
||||||
|
") " +
|
||||||
|
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
|
||||||
|
"playCount = c.playCount, played = c.played " +
|
||||||
|
"FROM (SELECT artists.`sourceId` AS sourceId, artists.`id` AS id, " +
|
||||||
|
"COALESCE(totals.songCount, 0) AS songCount, " +
|
||||||
|
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
|
||||||
|
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played " +
|
||||||
|
"FROM artists LEFT JOIN totals " +
|
||||||
|
"ON totals.sourceId = artists.`sourceId` AND totals.id = artists.`id` " +
|
||||||
|
"LEFT JOIN album_totals " +
|
||||||
|
"ON album_totals.sourceId = artists.`sourceId` AND album_totals.id = artists.`id` " +
|
||||||
|
"WHERE EXISTS (SELECT 1 FROM album_artists WHERE album_artists.`sourceId` = artists.`sourceId`) " +
|
||||||
|
"OR EXISTS (SELECT 1 FROM song_artists WHERE song_artists.`sourceId` = artists.`sourceId`)" +
|
||||||
|
") c " +
|
||||||
|
"WHERE artists.`sourceId` = c.sourceId AND artists.`id` = c.id " +
|
||||||
|
"AND (artists.`songCount` != c.songCount OR artists.`albumCount` != c.albumCount " +
|
||||||
|
"OR artists.`playCount` != c.playCount OR artists.`played` IS NOT c.played)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_34_35 =
|
||||||
|
object : Migration(34, 35) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `artist_bios` (" +
|
||||||
|
"`sourceId` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `biography` TEXT, " +
|
||||||
|
"PRIMARY KEY(`sourceId`, `artistId`), " +
|
||||||
|
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
|
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
|
||||||
private suspend fun createSearchIndex(
|
private suspend fun createSearchIndex(
|
||||||
connection: SQLiteConnection,
|
connection: SQLiteConnection,
|
||||||
@@ -416,6 +695,80 @@ private suspend fun createSearchIndex(
|
|||||||
connection.execSQL("INSERT INTO `$table`(`$table`) VALUES('rebuild')")
|
connection.execSQL("INSERT INTO `$table`(`$table`) VALUES('rebuild')")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_35_36 =
|
||||||
|
object : Migration(35, 36) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `artistImageUrl` TEXT")
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `coverArts` TEXT NOT NULL DEFAULT ''")
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `downloadedCoverArts` TEXT NOT NULL DEFAULT ''")
|
||||||
|
optimizeArtistSearchTriggers(connection)
|
||||||
|
backfillArtistArt(connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_36_37 =
|
||||||
|
object : Migration(36, 37) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_name_plain` ON `albums` (`sourceId`, `name`, `id`)",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_artist_plain` " +
|
||||||
|
"ON `albums` (`sourceId`, `albumArtist`, `year`, `name`, `id`)",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artists_name_plain` ON `artists` (`sourceId`, `name`, `id`)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_37_38 =
|
||||||
|
object : Migration(37, 38) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `playlists` ADD COLUMN `owner` TEXT")
|
||||||
|
connection.execSQL("ALTER TABLE `playlists` ADD COLUMN `shared` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun artistSearchTriggersOptimized(connection: SQLiteConnection): Boolean {
|
||||||
|
val triggers = mutableListOf<String>()
|
||||||
|
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?)").use { statement ->
|
||||||
|
statement.bindText(1, "room_fts_content_sync_artist_search_BEFORE_UPDATE")
|
||||||
|
statement.bindText(2, "room_fts_content_sync_artist_search_AFTER_UPDATE")
|
||||||
|
while (statement.step()) triggers += statement.getText(0)
|
||||||
|
}
|
||||||
|
return triggers.size == 2 && triggers.all { it.contains("UPDATE OF `name`, `sortName`") }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun optimizeArtistSearchTriggers(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("DROP TRIGGER IF EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE")
|
||||||
|
connection.execSQL("DROP TRIGGER IF EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE")
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE " +
|
||||||
|
"BEFORE UPDATE OF `name`, `sortName` ON `artists` BEGIN " +
|
||||||
|
"DELETE FROM `artist_search` WHERE `rowid`=OLD.`rowid`; END",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE " +
|
||||||
|
"AFTER UPDATE OF `name`, `sortName` ON `artists` BEGIN " +
|
||||||
|
"INSERT INTO `artist_search`(`rowid`, `name`, `sortName`) " +
|
||||||
|
"VALUES (NEW.`rowid`, NEW.`name`, NEW.`sortName`); END",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun backfillArtistArt(connection: SQLiteConnection) {
|
||||||
|
val sourceIds = mutableListOf<Long>()
|
||||||
|
connection.prepare("SELECT id FROM sources").use { statement ->
|
||||||
|
while (statement.step()) sourceIds += statement.getLong(0)
|
||||||
|
}
|
||||||
|
sourceIds.forEach { sourceId ->
|
||||||
|
connection.prepare(ARTIST_ART_UPDATE_SQL).use { statement ->
|
||||||
|
statement.bindLong(1, sourceId)
|
||||||
|
statement.step()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val MIGRATIONS: Array<Migration> =
|
val MIGRATIONS: Array<Migration> =
|
||||||
arrayOf(
|
arrayOf(
|
||||||
MIGRATION_1_2,
|
MIGRATION_1_2,
|
||||||
@@ -444,4 +797,15 @@ val MIGRATIONS: Array<Migration> =
|
|||||||
MIGRATION_24_25,
|
MIGRATION_24_25,
|
||||||
MIGRATION_25_26,
|
MIGRATION_25_26,
|
||||||
MIGRATION_26_27,
|
MIGRATION_26_27,
|
||||||
|
MIGRATION_27_28,
|
||||||
|
MIGRATION_28_29,
|
||||||
|
MIGRATION_29_30,
|
||||||
|
MIGRATION_30_31,
|
||||||
|
MIGRATION_31_32,
|
||||||
|
MIGRATION_32_33,
|
||||||
|
MIGRATION_33_34,
|
||||||
|
MIGRATION_34_35,
|
||||||
|
MIGRATION_35_36,
|
||||||
|
MIGRATION_36_37,
|
||||||
|
MIGRATION_37_38,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package com.subtracks.data.db
|
|||||||
|
|
||||||
import androidx.room3.Dao
|
import androidx.room3.Dao
|
||||||
import androidx.room3.Insert
|
import androidx.room3.Insert
|
||||||
|
import androidx.room3.OnConflictStrategy
|
||||||
import androidx.room3.Query
|
import androidx.room3.Query
|
||||||
import androidx.room3.Upsert
|
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
import com.subtracks.data.model.PlaybackCursor
|
import com.subtracks.data.model.PlaybackCursor
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
@@ -19,13 +19,16 @@ private const val ALBUM_SONGS_ORDER = " ORDER BY songs.disc, songs.track, songs.
|
|||||||
|
|
||||||
private const val ALBUM_SONGS_SQL = ALBUM_SONGS_SELECT + ALBUM_SONGS_ORDER
|
private const val ALBUM_SONGS_SQL = ALBUM_SONGS_SELECT + ALBUM_SONGS_ORDER
|
||||||
|
|
||||||
|
private const val ARTIST_SONG_IDS =
|
||||||
|
"(SELECT songId FROM ($CREDITED_SONG_ROWS) WHERE artistId = :artistId)"
|
||||||
|
|
||||||
private const val ARTIST_SONGS_SELECT =
|
private const val ARTIST_SONGS_SELECT =
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)"
|
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS"
|
||||||
|
|
||||||
private const val ARTIST_SONGS_ORDER =
|
private const val ARTIST_SONGS_ORDER =
|
||||||
" ORDER BY albums.year DESC, albums.name COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
|
" ORDER BY albums.id IS NULL, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
|
||||||
|
|
||||||
private const val SONG_SQL =
|
private const val SONG_SQL =
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
@@ -55,7 +58,7 @@ interface QueueDao {
|
|||||||
@Query("SELECT * FROM playback_cursor WHERE id = 1")
|
@Query("SELECT * FROM playback_cursor WHERE id = 1")
|
||||||
suspend fun cursor(): PlaybackCursor?
|
suspend fun cursor(): PlaybackCursor?
|
||||||
|
|
||||||
@Upsert
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun setCursor(cursor: PlaybackCursor)
|
suspend fun setCursor(cursor: PlaybackCursor)
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
@@ -82,8 +85,8 @@ interface QueueDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT COUNT(*) FROM songs " +
|
"SELECT COUNT(*) FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)",
|
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS",
|
||||||
)
|
)
|
||||||
suspend fun artistLength(
|
suspend fun artistLength(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -190,8 +193,8 @@ interface QueueDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.id FROM songs " +
|
"SELECT songs.id FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)" +
|
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS" +
|
||||||
ARTIST_SONGS_ORDER,
|
ARTIST_SONGS_ORDER,
|
||||||
)
|
)
|
||||||
suspend fun artistSongIds(
|
suspend fun artistSongIds(
|
||||||
@@ -231,7 +234,7 @@ interface QueueDao {
|
|||||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
||||||
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
"ORDER BY songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||||
)
|
)
|
||||||
suspend fun genreSongs(
|
suspend fun genreSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -244,7 +247,7 @@ interface QueueDao {
|
|||||||
"SELECT songs.id FROM song_genres " +
|
"SELECT songs.id FROM song_genres " +
|
||||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
||||||
"ORDER BY songs.title COLLATE NOCASE, songs.id",
|
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
|
||||||
)
|
)
|
||||||
suspend fun genreSongIds(
|
suspend fun genreSongIds(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -261,7 +264,7 @@ interface QueueDao {
|
|||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
||||||
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
"ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||||
)
|
)
|
||||||
suspend fun starredSongs(
|
suspend fun starredSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -271,7 +274,7 @@ interface QueueDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
|
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
|
||||||
"ORDER BY starred DESC, title COLLATE NOCASE, id",
|
"ORDER BY starred DESC, sortName COLLATE NOCASE, id",
|
||||||
)
|
)
|
||||||
suspend fun starredSongIds(sourceId: Long): List<String>
|
suspend fun starredSongIds(sourceId: Long): List<String>
|
||||||
|
|
||||||
@@ -280,7 +283,7 @@ interface QueueDao {
|
|||||||
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||||
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||||
)
|
)
|
||||||
suspend fun downloadedSongs(
|
suspend fun downloadedSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -292,7 +295,7 @@ interface QueueDao {
|
|||||||
"SELECT songs.id FROM song_downloads d " +
|
"SELECT songs.id FROM song_downloads d " +
|
||||||
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||||
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id",
|
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
|
||||||
)
|
)
|
||||||
suspend fun downloadedSongIds(sourceId: Long): List<String>
|
suspend fun downloadedSongIds(sourceId: Long): List<String>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,22 @@ interface SourcesDao {
|
|||||||
@Query("SELECT id FROM sources WHERE isActive = 1 LIMIT 1")
|
@Query("SELECT id FROM sources WHERE isActive = 1 LIMIT 1")
|
||||||
suspend fun activeSourceIdOnce(): Long?
|
suspend fun activeSourceIdOnce(): Long?
|
||||||
|
|
||||||
|
@Query("SELECT creditsStale FROM sources WHERE id = :sourceId")
|
||||||
|
suspend fun creditsStale(sourceId: Long): Boolean?
|
||||||
|
|
||||||
|
@Query("UPDATE sources SET creditsStale = :stale WHERE id = :sourceId AND creditsStale != :stale")
|
||||||
|
suspend fun setCreditsStale(
|
||||||
|
sourceId: Long,
|
||||||
|
stale: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Query("UPDATE sources SET name = :name, address = :address WHERE id = :sourceId")
|
||||||
|
suspend fun updateSourceDetails(
|
||||||
|
sourceId: Long,
|
||||||
|
name: String,
|
||||||
|
address: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Insert
|
@Insert
|
||||||
suspend fun insertSource(source: Source): Long
|
suspend fun insertSource(source: Source): Long
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import androidx.room3.ColumnTypeConverters
|
|||||||
import androidx.room3.Database
|
import androidx.room3.Database
|
||||||
import androidx.room3.RoomDatabase
|
import androidx.room3.RoomDatabase
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.AlbumArtistRef
|
||||||
import com.subtracks.data.model.AlbumSearch
|
import com.subtracks.data.model.AlbumSearch
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistBio
|
||||||
|
import com.subtracks.data.model.ArtistRefsConverter
|
||||||
import com.subtracks.data.model.ArtistSearch
|
import com.subtracks.data.model.ArtistSearch
|
||||||
import com.subtracks.data.model.ArtworkSeed
|
import com.subtracks.data.model.ArtworkSeed
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
@@ -20,10 +23,12 @@ import com.subtracks.data.model.PlaylistSong
|
|||||||
import com.subtracks.data.model.QueueEntry
|
import com.subtracks.data.model.QueueEntry
|
||||||
import com.subtracks.data.model.QueueKindConverter
|
import com.subtracks.data.model.QueueKindConverter
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.model.SongArtistRef
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.model.SongGenre
|
import com.subtracks.data.model.SongGenre
|
||||||
import com.subtracks.data.model.SongSearch
|
import com.subtracks.data.model.SongSearch
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
|
import com.subtracks.data.model.StringListConverter
|
||||||
import com.subtracks.data.model.SubsonicSource
|
import com.subtracks.data.model.SubsonicSource
|
||||||
import com.subtracks.data.model.UpNextEntry
|
import com.subtracks.data.model.UpNextEntry
|
||||||
|
|
||||||
@@ -32,15 +37,18 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
Source::class,
|
Source::class,
|
||||||
SubsonicSource::class,
|
SubsonicSource::class,
|
||||||
Artist::class,
|
Artist::class,
|
||||||
|
ArtistBio::class,
|
||||||
ArtistSearch::class,
|
ArtistSearch::class,
|
||||||
Album::class,
|
Album::class,
|
||||||
AlbumSearch::class,
|
AlbumSearch::class,
|
||||||
Disc::class,
|
Disc::class,
|
||||||
|
AlbumArtistRef::class,
|
||||||
Playlist::class,
|
Playlist::class,
|
||||||
PlaylistSearch::class,
|
PlaylistSearch::class,
|
||||||
PlaylistSong::class,
|
PlaylistSong::class,
|
||||||
Song::class,
|
Song::class,
|
||||||
SongGenre::class,
|
SongGenre::class,
|
||||||
|
SongArtistRef::class,
|
||||||
SongSearch::class,
|
SongSearch::class,
|
||||||
QueueEntry::class,
|
QueueEntry::class,
|
||||||
UpNextEntry::class,
|
UpNextEntry::class,
|
||||||
@@ -49,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
SongDownload::class,
|
SongDownload::class,
|
||||||
PendingAction::class,
|
PendingAction::class,
|
||||||
],
|
],
|
||||||
version = 27,
|
version = 38,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@ColumnTypeConverters(
|
@ColumnTypeConverters(
|
||||||
@@ -57,6 +65,8 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
DownloadStatusConverter::class,
|
DownloadStatusConverter::class,
|
||||||
DownloadErrorConverter::class,
|
DownloadErrorConverter::class,
|
||||||
PendingActionKindConverter::class,
|
PendingActionKindConverter::class,
|
||||||
|
ArtistRefsConverter::class,
|
||||||
|
StringListConverter::class,
|
||||||
)
|
)
|
||||||
abstract class SubtracksDatabase : RoomDatabase() {
|
abstract class SubtracksDatabase : RoomDatabase() {
|
||||||
abstract fun libraryDao(): LibraryDao
|
abstract fun libraryDao(): LibraryDao
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.app.DownloadManager
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import com.subtracks.UserAgent
|
||||||
import com.subtracks.data.model.DownloadError
|
import com.subtracks.data.model.DownloadError
|
||||||
|
|
||||||
class SystemDownloadEngine(
|
class SystemDownloadEngine(
|
||||||
@@ -16,6 +17,7 @@ class SystemDownloadEngine(
|
|||||||
.setTitle(request.title)
|
.setTitle(request.title)
|
||||||
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path)
|
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path)
|
||||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
|
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
|
||||||
|
.addRequestHeader("User-Agent", UserAgent.value)
|
||||||
// Wi-Fi only unless the preference allows a metered network, and roaming counts as
|
// Wi-Fi only unless the preference allows a metered network, and roaming counts as
|
||||||
// one of those; the platform otherwise permits it by default.
|
// one of those; the platform otherwise permits it by default.
|
||||||
.setAllowedOverMetered(request.allowMetered)
|
.setAllowedOverMetered(request.allowMetered)
|
||||||
|
|||||||
@@ -10,3 +10,8 @@ fun coverArtKey(
|
|||||||
coverArt: String,
|
coverArt: String,
|
||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): String = "$sourceId:$coverArt:$thumbnail"
|
): String = "$sourceId:$coverArt:$thumbnail"
|
||||||
|
|
||||||
|
fun artistImageKey(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): String = "$sourceId:artist-image:$artistId"
|
||||||
|
|||||||
@@ -58,9 +58,14 @@ data class SongDownload(
|
|||||||
@ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0,
|
@ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0,
|
||||||
) {
|
) {
|
||||||
val progress: Float?
|
val progress: Float?
|
||||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
get() = fractionOf(bytes, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun fractionOf(
|
||||||
|
bytes: Long,
|
||||||
|
total: Long,
|
||||||
|
): Float? = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||||
|
|
||||||
enum class DownloadList { Album, Playlist, Artist }
|
enum class DownloadList { Album, Playlist, Artist }
|
||||||
|
|
||||||
enum class BulkDownloadAction { Download, Cancel, Delete }
|
enum class BulkDownloadAction { Download, Cancel, Delete }
|
||||||
@@ -94,19 +99,24 @@ data class DownloadedSong(
|
|||||||
val size: Long = 0,
|
val size: Long = 0,
|
||||||
val bytes: Long = 0,
|
val bytes: Long = 0,
|
||||||
val total: Long = 0,
|
val total: Long = 0,
|
||||||
|
val artistSort: String = artistName.orEmpty(),
|
||||||
|
val albumSort: String = albumName.orEmpty(),
|
||||||
|
val songSort: String = title,
|
||||||
) {
|
) {
|
||||||
val progress: Float?
|
val progress: Float?
|
||||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
get() = fractionOf(bytes, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class DownloadArtwork(
|
data class DownloadArtwork(
|
||||||
val sourceId: Long,
|
val sourceId: Long,
|
||||||
val albumId: String?,
|
val albumId: String?,
|
||||||
val artistId: String?,
|
val artistId: String?,
|
||||||
|
val albumArtistId: String?,
|
||||||
val albumRow: String?,
|
val albumRow: String?,
|
||||||
val artistRow: String?,
|
val artistRow: String?,
|
||||||
val albumCoverArt: String?,
|
val albumCoverArt: String?,
|
||||||
val artistCoverArt: String?,
|
val artistCoverArt: String?,
|
||||||
|
val albumArtistCoverArt: String?,
|
||||||
) {
|
) {
|
||||||
val hasMissingLibraryRow: Boolean
|
val hasMissingLibraryRow: Boolean
|
||||||
get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
|
get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ data class Source(
|
|||||||
val address: String,
|
val address: String,
|
||||||
val isActive: Boolean = false,
|
val isActive: Boolean = false,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
|
@ColumnInfo(defaultValue = "1") val creditsStale: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -39,6 +40,11 @@ data class SubsonicSource(
|
|||||||
val useTokenAuth: Boolean = true,
|
val useTokenAuth: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class ArtistRef(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "artists",
|
tableName = "artists",
|
||||||
primaryKeys = ["sourceId", "id"],
|
primaryKeys = ["sourceId", "id"],
|
||||||
@@ -52,25 +58,26 @@ data class SubsonicSource(
|
|||||||
],
|
],
|
||||||
indices = [
|
indices = [
|
||||||
Index("sourceId"),
|
Index("sourceId"),
|
||||||
Index(name = "index_artists_name", value = ["sourceId", "name", "id"]),
|
Index(name = "index_artists_name", value = ["sourceId", "sortName", "id"]),
|
||||||
|
Index(name = "index_artists_name_plain", value = ["sourceId", "name", "id"]),
|
||||||
Index(
|
Index(
|
||||||
name = "index_artists_albumCount",
|
name = "index_artists_albumCount",
|
||||||
value = ["sourceId", "albumCount", "name", "id"],
|
value = ["sourceId", "albumCount", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_artists_starred",
|
name = "index_artists_starred",
|
||||||
value = ["sourceId", "starred", "name", "id"],
|
value = ["sourceId", "starred", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_artists_frequent",
|
name = "index_artists_frequent",
|
||||||
value = ["sourceId", "playCount", "name", "id"],
|
value = ["sourceId", "playCount", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_artists_recent",
|
name = "index_artists_recent",
|
||||||
value = ["sourceId", "played", "name", "id"],
|
value = ["sourceId", "played", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -82,8 +89,32 @@ data class Artist(
|
|||||||
val albumCount: Long,
|
val albumCount: Long,
|
||||||
val starred: Long?,
|
val starred: Long?,
|
||||||
val coverArt: String? = null,
|
val coverArt: String? = null,
|
||||||
|
val artistImageUrl: String? = null,
|
||||||
|
@ColumnInfo(defaultValue = "''") val coverArts: List<String> = emptyList(),
|
||||||
|
@ColumnInfo(defaultValue = "''") val downloadedCoverArts: List<String> = emptyList(),
|
||||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||||
val played: Long? = null,
|
val played: Long? = null,
|
||||||
|
@ColumnInfo(defaultValue = "0") val songCount: Long = 0,
|
||||||
|
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = name,
|
||||||
|
@ColumnInfo(defaultValue = "1") val credited: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "artist_bios",
|
||||||
|
primaryKeys = ["sourceId", "artistId"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = Source::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["sourceId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
data class ArtistBio(
|
||||||
|
val sourceId: Long,
|
||||||
|
val artistId: String,
|
||||||
|
val biography: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -100,31 +131,33 @@ data class Artist(
|
|||||||
indices = [
|
indices = [
|
||||||
Index("sourceId"),
|
Index("sourceId"),
|
||||||
Index("sourceId", "artistId"),
|
Index("sourceId", "artistId"),
|
||||||
Index(name = "index_albums_name", value = ["sourceId", "name", "id"]),
|
Index(name = "index_albums_name", value = ["sourceId", "sortName", "id"]),
|
||||||
Index(name = "index_albums_artist", value = ["sourceId", "albumArtist", "year", "name", "id"]),
|
Index(name = "index_albums_name_plain", value = ["sourceId", "name", "id"]),
|
||||||
|
Index(name = "index_albums_artist", value = ["sourceId", "albumArtistSort", "year", "sortName", "id"]),
|
||||||
|
Index(name = "index_albums_artist_plain", value = ["sourceId", "albumArtist", "year", "name", "id"]),
|
||||||
Index(
|
Index(
|
||||||
name = "index_albums_year",
|
name = "index_albums_year",
|
||||||
value = ["sourceId", "year", "name", "id"],
|
value = ["sourceId", "year", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_albums_added",
|
name = "index_albums_added",
|
||||||
value = ["sourceId", "created", "name", "id"],
|
value = ["sourceId", "created", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_albums_starred",
|
name = "index_albums_starred",
|
||||||
value = ["sourceId", "starred", "name", "id"],
|
value = ["sourceId", "starred", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_albums_frequent",
|
name = "index_albums_frequent",
|
||||||
value = ["sourceId", "playCount", "name", "id"],
|
value = ["sourceId", "playCount", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_albums_recent",
|
name = "index_albums_recent",
|
||||||
value = ["sourceId", "played", "name", "id"],
|
value = ["sourceId", "played", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -144,7 +177,26 @@ data class Album(
|
|||||||
@Ignore val discTitles: Map<Long, String> = emptyMap(),
|
@Ignore val discTitles: Map<Long, String> = emptyMap(),
|
||||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||||
val played: Long? = null,
|
val played: Long? = null,
|
||||||
)
|
@ColumnInfo(defaultValue = "''") val artists: List<ArtistRef> = emptyList(),
|
||||||
|
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = name,
|
||||||
|
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val albumArtistSort: String = albumArtist.orEmpty(),
|
||||||
|
) {
|
||||||
|
val artistRefs: List<ArtistRef>
|
||||||
|
get() =
|
||||||
|
artists.ifEmpty {
|
||||||
|
artistId
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { listOf(ArtistRef(it, albumArtist.orEmpty())) }
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ArtistAlbums(
|
||||||
|
val albumCredits: List<Album> = emptyList(),
|
||||||
|
val appearances: List<Album> = emptyList(),
|
||||||
|
) {
|
||||||
|
val count: Int get() = albumCredits.size + appearances.size
|
||||||
|
}
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "discs",
|
tableName = "discs",
|
||||||
@@ -202,6 +254,8 @@ data class Playlist(
|
|||||||
val created: Long,
|
val created: Long,
|
||||||
@ColumnInfo(defaultValue = "0") val changed: Long = 0,
|
@ColumnInfo(defaultValue = "0") val changed: Long = 0,
|
||||||
@ColumnInfo(defaultValue = "0") val duration: Long = 0,
|
@ColumnInfo(defaultValue = "0") val duration: Long = 0,
|
||||||
|
val owner: String? = null,
|
||||||
|
@ColumnInfo(defaultValue = "0") val shared: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -243,8 +297,8 @@ data class PlaylistSong(
|
|||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_songs_starred",
|
name = "index_songs_starred",
|
||||||
value = ["sourceId", "starred", "id"],
|
value = ["sourceId", "starred", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -264,8 +318,24 @@ data class Song(
|
|||||||
@ColumnInfo(defaultValue = "0") val created: Long = 0,
|
@ColumnInfo(defaultValue = "0") val created: Long = 0,
|
||||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||||
val played: Long? = null,
|
val played: Long? = null,
|
||||||
|
val trackGain: Float? = null,
|
||||||
|
val albumGain: Float? = null,
|
||||||
|
val trackPeak: Float? = null,
|
||||||
|
val albumPeak: Float? = null,
|
||||||
@Ignore val genres: List<String> = emptyList(),
|
@Ignore val genres: List<String> = emptyList(),
|
||||||
)
|
@ColumnInfo(defaultValue = "''") val artists: List<ArtistRef> = emptyList(),
|
||||||
|
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = title,
|
||||||
|
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val artistSort: String = artist.orEmpty(),
|
||||||
|
) {
|
||||||
|
val artistRefs: List<ArtistRef>
|
||||||
|
get() =
|
||||||
|
artists.ifEmpty {
|
||||||
|
artistId
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { listOf(ArtistRef(it, artist.orEmpty())) }
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "song_genres",
|
tableName = "song_genres",
|
||||||
@@ -287,6 +357,48 @@ data class SongGenre(
|
|||||||
val genre: String,
|
val genre: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "album_artists",
|
||||||
|
primaryKeys = ["sourceId", "albumId", "position"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = Album::class,
|
||||||
|
parentColumns = ["sourceId", "id"],
|
||||||
|
childColumns = ["sourceId", "albumId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index("sourceId", "artistId")],
|
||||||
|
)
|
||||||
|
data class AlbumArtistRef(
|
||||||
|
val sourceId: Long,
|
||||||
|
val albumId: String,
|
||||||
|
val position: Long,
|
||||||
|
val artistId: String,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "song_artists",
|
||||||
|
primaryKeys = ["sourceId", "songId", "position"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = Song::class,
|
||||||
|
parentColumns = ["sourceId", "id"],
|
||||||
|
childColumns = ["sourceId", "songId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index("sourceId", "artistId")],
|
||||||
|
)
|
||||||
|
data class SongArtistRef(
|
||||||
|
val sourceId: Long,
|
||||||
|
val songId: String,
|
||||||
|
val position: Long,
|
||||||
|
val artistId: String,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
enum class PendingActionKind { Scrobble, Star, Unstar }
|
enum class PendingActionKind { Scrobble, Star, Unstar }
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -434,6 +546,8 @@ data class AlbumSearch(
|
|||||||
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
||||||
val name: String,
|
val name: String,
|
||||||
val albumArtist: String?,
|
val albumArtist: String?,
|
||||||
|
val sortName: String,
|
||||||
|
val albumArtistSort: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(tableName = "artist_search")
|
@Entity(tableName = "artist_search")
|
||||||
@@ -441,6 +555,7 @@ data class AlbumSearch(
|
|||||||
data class ArtistSearch(
|
data class ArtistSearch(
|
||||||
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
||||||
val name: String,
|
val name: String,
|
||||||
|
val sortName: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(tableName = "playlist_search")
|
@Entity(tableName = "playlist_search")
|
||||||
@@ -456,6 +571,8 @@ data class SongSearch(
|
|||||||
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
||||||
val title: String,
|
val title: String,
|
||||||
val artist: String?,
|
val artist: String?,
|
||||||
|
val sortName: String,
|
||||||
|
val artistSort: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
class QueueKindConverter {
|
class QueueKindConverter {
|
||||||
@@ -474,3 +591,53 @@ class PendingActionKindConverter {
|
|||||||
fun toPendingActionKind(value: String): PendingActionKind =
|
fun toPendingActionKind(value: String): PendingActionKind =
|
||||||
PendingActionKind.entries.firstOrNull { it.name == value } ?: PendingActionKind.Scrobble
|
PendingActionKind.entries.firstOrNull { it.name == value } ?: PendingActionKind.Scrobble
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ArtistRefsConverter {
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun fromArtistRefs(refs: List<ArtistRef>): String = ArtistRefsCodec.encode(refs)
|
||||||
|
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun toArtistRefs(value: String): List<ArtistRef> = ArtistRefsCodec.decode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
class StringListConverter {
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun fromList(values: List<String>): String = values.joinToString(LIST_SEPARATOR)
|
||||||
|
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun toList(value: String): List<String> = if (value.isEmpty()) emptyList() else value.split(LIST_SEPARATOR)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val LIST_SEPARATOR = "\u001F"
|
||||||
|
|
||||||
|
object ArtistRefsCodec {
|
||||||
|
fun encode(refs: List<ArtistRef>): String =
|
||||||
|
buildString {
|
||||||
|
refs.forEach { ref ->
|
||||||
|
append(ref.id.length).append(':').append(ref.id)
|
||||||
|
append(ref.name.length).append(':').append(ref.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decode(value: String): List<ArtistRef> {
|
||||||
|
val refs = mutableListOf<ArtistRef>()
|
||||||
|
var index = 0
|
||||||
|
while (index < value.length) {
|
||||||
|
val idLengthEnd = value.indexOf(':', index)
|
||||||
|
if (idLengthEnd < 0) return refs
|
||||||
|
val idLength = value.substring(index, idLengthEnd).toIntOrNull() ?: return refs
|
||||||
|
val idStart = idLengthEnd + 1
|
||||||
|
val idEnd = idStart + idLength
|
||||||
|
if (idLength < 0 || idEnd > value.length) return refs
|
||||||
|
val nameLengthEnd = value.indexOf(':', idEnd)
|
||||||
|
if (nameLengthEnd < 0) return refs
|
||||||
|
val nameLength = value.substring(idEnd, nameLengthEnd).toIntOrNull() ?: return refs
|
||||||
|
val nameStart = nameLengthEnd + 1
|
||||||
|
val nameEnd = nameStart + nameLength
|
||||||
|
if (nameLength < 0 || nameEnd > value.length) return refs
|
||||||
|
refs += ArtistRef(value.substring(idStart, idEnd), value.substring(nameStart, nameEnd))
|
||||||
|
index = nameEnd
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import androidx.datastore.core.DataStore
|
|||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
@@ -14,6 +16,8 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
enum class SortNameMode { SortName, Name, NonLatin }
|
||||||
|
|
||||||
enum class AlbumSort { Name, Artist, Year, Added, Starred, Frequent, Recent }
|
enum class AlbumSort { Name, Artist, Year, Added, Starred, Frequent, Recent }
|
||||||
|
|
||||||
enum class ArtistSort { Name, AlbumCount, Starred, Frequent, Recent }
|
enum class ArtistSort { Name, AlbumCount, Starred, Frequent, Recent }
|
||||||
@@ -48,6 +52,15 @@ data class StreamQuality(
|
|||||||
|
|
||||||
private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
|
private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
|
||||||
|
|
||||||
|
const val DEFAULT_SYNC_INTERVAL_MINUTES = 24 * 60
|
||||||
|
|
||||||
|
const val MIN_REPLAY_GAIN_PREAMP_DB = -15f
|
||||||
|
const val MAX_REPLAY_GAIN_PREAMP_DB = 15f
|
||||||
|
|
||||||
|
enum class SyncMode { Off, WifiOnly, Always }
|
||||||
|
|
||||||
|
enum class ReplayGainMode { Off, Track, Album, Auto }
|
||||||
|
|
||||||
data class ArtworkSeedValue(
|
data class ArtworkSeedValue(
|
||||||
val cacheKey: String,
|
val cacheKey: String,
|
||||||
val primary: Int,
|
val primary: Int,
|
||||||
@@ -105,6 +118,34 @@ class UserPreferences(
|
|||||||
|
|
||||||
private val syncConcurrencyKey = intPreferencesKey("sync_concurrency")
|
private val syncConcurrencyKey = intPreferencesKey("sync_concurrency")
|
||||||
|
|
||||||
|
fun syncMode(): Flow<SyncMode> =
|
||||||
|
store.data.map { prefs ->
|
||||||
|
prefs[syncModeKey]?.let { stored -> SyncMode.entries.firstOrNull { it.name == stored } } ?: SyncMode.WifiOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSyncMode(mode: SyncMode) {
|
||||||
|
store.edit { prefs -> prefs[syncModeKey] = mode.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val syncModeKey = stringPreferencesKey("sync_mode")
|
||||||
|
|
||||||
|
fun lastSyncAt(): Flow<Long> = store.data.map { prefs -> prefs[lastSyncAtKey] ?: 0L }
|
||||||
|
|
||||||
|
suspend fun setLastSyncAt(value: Long) {
|
||||||
|
store.edit { prefs -> prefs[lastSyncAtKey] = value }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val lastSyncAtKey = longPreferencesKey("last_sync_at")
|
||||||
|
|
||||||
|
fun syncIntervalMinutes(): Flow<Int> =
|
||||||
|
store.data.map { prefs -> (prefs[syncIntervalKey] ?: DEFAULT_SYNC_INTERVAL_MINUTES).coerceAtLeast(1) }
|
||||||
|
|
||||||
|
suspend fun setSyncIntervalMinutes(value: Int) {
|
||||||
|
store.edit { prefs -> prefs[syncIntervalKey] = value.coerceAtLeast(1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val syncIntervalKey = intPreferencesKey("sync_interval_minutes")
|
||||||
|
|
||||||
fun scrobbling(): Flow<Boolean> = store.data.map { prefs -> prefs[scrobblingKey] ?: true }
|
fun scrobbling(): Flow<Boolean> = store.data.map { prefs -> prefs[scrobblingKey] ?: true }
|
||||||
|
|
||||||
suspend fun setScrobbling(enabled: Boolean) {
|
suspend fun setScrobbling(enabled: Boolean) {
|
||||||
@@ -113,6 +154,47 @@ class UserPreferences(
|
|||||||
|
|
||||||
private val scrobblingKey = booleanPreferencesKey("scrobbling")
|
private val scrobblingKey = booleanPreferencesKey("scrobbling")
|
||||||
|
|
||||||
|
fun replayGainMode(): Flow<ReplayGainMode> =
|
||||||
|
store.data.map { prefs ->
|
||||||
|
prefs[replayGainModeKey]?.let { stored -> ReplayGainMode.entries.firstOrNull { it.name == stored } } ?: ReplayGainMode.Auto
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setReplayGainMode(mode: ReplayGainMode) {
|
||||||
|
store.edit { prefs -> prefs[replayGainModeKey] = mode.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val replayGainModeKey = stringPreferencesKey("replay_gain_mode")
|
||||||
|
|
||||||
|
fun replayGainPreampDb(): Flow<Float> = store.data.map { prefs -> prefs[replayGainPreampKey].sanitizedPreampDb() }
|
||||||
|
|
||||||
|
suspend fun setReplayGainPreampDb(value: Float) {
|
||||||
|
store.edit { prefs -> prefs[replayGainPreampKey] = value.sanitizedPreampDb() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Float?.sanitizedPreampDb(): Float =
|
||||||
|
this?.takeIf { it.isFinite() }?.coerceIn(MIN_REPLAY_GAIN_PREAMP_DB, MAX_REPLAY_GAIN_PREAMP_DB) ?: 0f
|
||||||
|
|
||||||
|
private val replayGainPreampKey = floatPreferencesKey("replay_gain_preamp_db")
|
||||||
|
|
||||||
|
fun keepScreenOn(): Flow<Boolean> = store.data.map { prefs -> prefs[keepScreenOnKey] ?: false }
|
||||||
|
|
||||||
|
suspend fun setKeepScreenOn(enabled: Boolean) {
|
||||||
|
store.edit { prefs -> prefs[keepScreenOnKey] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val keepScreenOnKey = booleanPreferencesKey("keep_screen_on")
|
||||||
|
|
||||||
|
fun sortNameMode(): Flow<SortNameMode> =
|
||||||
|
store.data.map { prefs ->
|
||||||
|
prefs[sortNameModeKey]?.let { stored -> SortNameMode.entries.firstOrNull { it.name == stored } } ?: SortNameMode.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSortNameMode(mode: SortNameMode) {
|
||||||
|
store.edit { prefs -> prefs[sortNameModeKey] = mode.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sortNameModeKey = stringPreferencesKey("sort_name_mode")
|
||||||
|
|
||||||
fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
|
fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
|
||||||
|
|
||||||
suspend fun setOfflineMode(enabled: Boolean) {
|
suspend fun setOfflineMode(enabled: Boolean) {
|
||||||
@@ -121,6 +203,14 @@ class UserPreferences(
|
|||||||
|
|
||||||
private val offlineModeKey = booleanPreferencesKey("offline_mode")
|
private val offlineModeKey = booleanPreferencesKey("offline_mode")
|
||||||
|
|
||||||
|
fun verboseLogging(): Flow<Boolean> = store.data.map { prefs -> prefs[verboseLoggingKey] ?: false }
|
||||||
|
|
||||||
|
suspend fun setVerboseLogging(enabled: Boolean) {
|
||||||
|
store.edit { prefs -> prefs[verboseLoggingKey] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val verboseLoggingKey = booleanPreferencesKey("verbose_logging")
|
||||||
|
|
||||||
suspend fun lastSeed(): ArtworkSeedValue? {
|
suspend fun lastSeed(): ArtworkSeedValue? {
|
||||||
val prefs = store.data.first()
|
val prefs = store.data.first()
|
||||||
val cacheKey = prefs[lastSeedKey] ?: return null
|
val cacheKey = prefs[lastSeedKey] ?: return null
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import com.subtracks.data.download.EngineDownload
|
|||||||
import com.subtracks.data.download.EngineRequest
|
import com.subtracks.data.download.EngineRequest
|
||||||
import com.subtracks.data.download.EngineStatus
|
import com.subtracks.data.download.EngineStatus
|
||||||
import com.subtracks.data.download.looksLikeAudio
|
import com.subtracks.data.download.looksLikeAudio
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistBio
|
||||||
import com.subtracks.data.model.BulkDownloadAction
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.DownloadError
|
import com.subtracks.data.model.DownloadError
|
||||||
import com.subtracks.data.model.DownloadList
|
import com.subtracks.data.model.DownloadList
|
||||||
@@ -18,7 +20,9 @@ import com.subtracks.data.model.DownloadStatus
|
|||||||
import com.subtracks.data.model.DownloadedSong
|
import com.subtracks.data.model.DownloadedSong
|
||||||
import com.subtracks.data.model.ListDownloadStatus
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
|
import com.subtracks.data.model.artistImageKey
|
||||||
import com.subtracks.data.model.coverArtKey
|
import com.subtracks.data.model.coverArtKey
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -42,6 +46,7 @@ import kotlinx.coroutines.sync.withLock
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
class DownloadRepository(
|
class DownloadRepository(
|
||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
@@ -62,6 +67,8 @@ class DownloadRepository(
|
|||||||
private var started = false
|
private var started = false
|
||||||
|
|
||||||
private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap())
|
private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap())
|
||||||
|
private val attemptedBios = ConcurrentHashMap.newKeySet<Pair<Long, String>>()
|
||||||
|
private val failedArtistImages = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
if (started) return
|
if (started) return
|
||||||
@@ -79,7 +86,10 @@ class DownloadRepository(
|
|||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
sourceRepository.offline.collect { offline ->
|
sourceRepository.offline.collect { offline ->
|
||||||
if (offline) cancelActive()
|
if (offline) {
|
||||||
|
Log.i("download", "offline: cancelling active downloads")
|
||||||
|
cancelActive()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,6 +150,7 @@ class DownloadRepository(
|
|||||||
dir(sourceId).mkdirs()
|
dir(sourceId).mkdirs()
|
||||||
file(sourceId, songId).delete()
|
file(sourceId, songId).delete()
|
||||||
db.downloadDao().upsert(SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued))
|
db.downloadDao().upsert(SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued))
|
||||||
|
Log.i("download", "queued song=$songId (source $sourceId)")
|
||||||
ensurePolling()
|
ensurePolling()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -336,11 +347,15 @@ class DownloadRepository(
|
|||||||
|
|
||||||
private suspend fun removeRows(rows: List<SongDownload>) {
|
private suspend fun removeRows(rows: List<SongDownload>) {
|
||||||
if (rows.isEmpty()) return
|
if (rows.isEmpty()) return
|
||||||
|
Log.i("download", "removed ${rows.size} download(s)")
|
||||||
val engineIds = rows.mapNotNull { it.engineId }
|
val engineIds = rows.mapNotNull { it.engineId }
|
||||||
if (engineIds.isNotEmpty()) engine.cancel(engineIds)
|
if (engineIds.isNotEmpty()) engine.cancel(engineIds)
|
||||||
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
|
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
|
||||||
group.forEach { file(sourceId, it.songId).delete() }
|
group.forEach { file(sourceId, it.songId).delete() }
|
||||||
group.map { it.songId }.chunked(DELETE_CHUNK).forEach { db.downloadDao().deleteSongs(sourceId, it) }
|
group.map { it.songId }.chunked(DELETE_CHUNK).forEach { db.downloadDao().deleteSongs(sourceId, it) }
|
||||||
|
if (group.any { it.status == DownloadStatus.Completed }) {
|
||||||
|
refreshDownloadedArtistArt(sourceId, group.map { it.songId })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sweep(db.downloadDao().all())
|
sweep(db.downloadDao().all())
|
||||||
}
|
}
|
||||||
@@ -363,6 +378,7 @@ class DownloadRepository(
|
|||||||
.mapNotNull { it.engineId }
|
.mapNotNull { it.engineId }
|
||||||
.let(engine::cancel)
|
.let(engine::cancel)
|
||||||
db.downloadDao().deleteSource(sourceId)
|
db.downloadDao().deleteSource(sourceId)
|
||||||
|
db.libraryDao().clearDownloadedArtistArt(sourceId)
|
||||||
dir(sourceId).deleteRecursively()
|
dir(sourceId).deleteRecursively()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,14 +416,18 @@ class DownloadRepository(
|
|||||||
private suspend fun fetchArtwork(
|
private suspend fun fetchArtwork(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
songId: String,
|
songId: String,
|
||||||
|
artistIds: MutableSet<String>,
|
||||||
) {
|
) {
|
||||||
val song = db.libraryDao().songOnce(sourceId, songId)
|
val song = db.libraryDao().songOnce(sourceId, songId)
|
||||||
|
val album = song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it) }
|
||||||
|
val songArtist = song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it) }
|
||||||
|
val albumArtist = album?.artistId?.let { db.libraryDao().artistOnce(sourceId, it) }
|
||||||
|
listOfNotNull(song?.artistId, album?.artistId).forEach(artistIds::add)
|
||||||
|
listOfNotNull(songArtist, albumArtist).forEach { storeArtistImage(sourceId, it) }
|
||||||
val coverArt =
|
val coverArt =
|
||||||
(
|
(
|
||||||
listOfNotNull(
|
listOfNotNull(album?.coverArt) +
|
||||||
song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it)?.coverArt },
|
db.downloadDao().playlistCoversForSong(sourceId, songId)
|
||||||
song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it)?.coverArt },
|
|
||||||
) + db.downloadDao().playlistCoversForSong(sourceId, songId)
|
|
||||||
).distinct()
|
).distinct()
|
||||||
coverArt.forEach { cover ->
|
coverArt.forEach { cover ->
|
||||||
storeArtwork(sourceId, cover, thumbnail = false)
|
storeArtwork(sourceId, cover, thumbnail = false)
|
||||||
@@ -415,6 +435,67 @@ class DownloadRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun storeArtistImage(
|
||||||
|
sourceId: Long,
|
||||||
|
artist: Artist,
|
||||||
|
) {
|
||||||
|
val imageUrl = artist.artistImageUrl
|
||||||
|
if (!imageUrl.isNullOrBlank()) {
|
||||||
|
val cacheKey = artistImageKey(sourceId, artist.id)
|
||||||
|
if (artworkStore.file(sourceId, cacheKey).exists()) return
|
||||||
|
val attemptKey = "$sourceId:${artist.id}:$imageUrl"
|
||||||
|
if (attemptKey !in failedArtistImages) {
|
||||||
|
val bytes =
|
||||||
|
try {
|
||||||
|
artworkFetcher.fetch(imageUrl)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (bytes != null && bytes.isNotEmpty()) {
|
||||||
|
artworkStore.write(sourceId, cacheKey, bytes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
failedArtistImages += attemptKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artist.coverArt?.let {
|
||||||
|
storeArtwork(sourceId, it, thumbnail = false)
|
||||||
|
storeArtwork(sourceId, it, thumbnail = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchArtistBio(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
) {
|
||||||
|
if (db.libraryDao().artistBioOnce(sourceId, artistId) != null) return
|
||||||
|
val key = sourceId to artistId
|
||||||
|
if (!attemptedBios.add(key)) return
|
||||||
|
try {
|
||||||
|
val biography = sourceRepository.artistBiography(sourceId, artistId)
|
||||||
|
db.libraryDao().upsertArtistBio(
|
||||||
|
ArtistBio(sourceId, artistId, normalizeBiography(biography)),
|
||||||
|
)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
} finally {
|
||||||
|
attemptedBios.remove(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchArtistBiosSafely(
|
||||||
|
sourceId: Long,
|
||||||
|
artistIds: Set<String>,
|
||||||
|
) {
|
||||||
|
if (artistIds.isEmpty()) return
|
||||||
|
withTimeoutOrNull(BIO_TIMEOUT_MS) {
|
||||||
|
artistIds.forEach { fetchArtistBio(sourceId, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun storeArtwork(
|
private suspend fun storeArtwork(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
coverArt: String,
|
coverArt: String,
|
||||||
@@ -449,11 +530,15 @@ class DownloadRepository(
|
|||||||
.mapValues { (sourceId, rows) ->
|
.mapValues { (sourceId, rows) ->
|
||||||
if (rows.any { it.hasMissingLibraryRow }) return@mapValues null
|
if (rows.any { it.hasMissingLibraryRow }) return@mapValues null
|
||||||
val covers =
|
val covers =
|
||||||
rows.flatMap { row -> listOfNotNull(row.albumCoverArt, row.artistCoverArt) } +
|
rows.flatMap { row ->
|
||||||
playlistCovers[sourceId].orEmpty()
|
listOfNotNull(row.albumCoverArt, row.artistCoverArt, row.albumArtistCoverArt)
|
||||||
covers
|
} + playlistCovers[sourceId].orEmpty()
|
||||||
.flatMap { cover -> listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) } }
|
val artistImages = rows.flatMap { row -> listOfNotNull(row.artistId, row.albumArtistId) }
|
||||||
.toSet()
|
(
|
||||||
|
covers.flatMap { cover ->
|
||||||
|
listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) }
|
||||||
|
} + artistImages.map { artistImageKey(sourceId, it) }
|
||||||
|
).toSet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,6 +588,7 @@ class DownloadRepository(
|
|||||||
row.engineId?.let { engine.cancel(listOf(it)) }
|
row.engineId?.let { engine.cancel(listOf(it)) }
|
||||||
file(sourceId, songId).delete()
|
file(sourceId, songId).delete()
|
||||||
db.downloadDao().delete(sourceId, songId)
|
db.downloadDao().delete(sourceId, songId)
|
||||||
|
if (row.status == DownloadStatus.Completed) refreshDownloadedArtistArt(sourceId, listOf(songId))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,11 +617,12 @@ class DownloadRepository(
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
songIds: List<String>,
|
songIds: List<String>,
|
||||||
) {
|
) {
|
||||||
|
val artistIds = mutableSetOf<String>()
|
||||||
try {
|
try {
|
||||||
withTimeoutOrNull(ART_TIMEOUT_MS) {
|
withTimeoutOrNull(ART_TIMEOUT_MS) {
|
||||||
songIds.forEach { songId ->
|
songIds.forEach { songId ->
|
||||||
// A cancel between the queue and this pass takes the row away; nothing to warm.
|
// A cancel between the queue and this pass takes the row away; nothing to warm.
|
||||||
if (db.downloadDao().find(sourceId, songId) != null) fetchArtwork(sourceId, songId)
|
if (db.downloadDao().find(sourceId, songId) != null) fetchArtwork(sourceId, songId, artistIds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
@@ -543,6 +630,7 @@ class DownloadRepository(
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Artwork is best effort and must never disturb a download that is already running.
|
// Artwork is best effort and must never disturb a download that is already running.
|
||||||
}
|
}
|
||||||
|
fetchArtistBiosSafely(sourceId, artistIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun apply(
|
private suspend fun apply(
|
||||||
@@ -581,6 +669,19 @@ class DownloadRepository(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (updated != row) db.downloadDao().upsert(updated)
|
if (updated != row) db.downloadDao().upsert(updated)
|
||||||
|
if (updated.status != row.status &&
|
||||||
|
(updated.status == DownloadStatus.Completed || row.status == DownloadStatus.Completed)
|
||||||
|
) {
|
||||||
|
refreshDownloadedArtistArt(row.sourceId, listOf(row.songId))
|
||||||
|
}
|
||||||
|
if (updated.status != row.status) {
|
||||||
|
when (updated.status) {
|
||||||
|
DownloadStatus.Completed -> Log.i("download", "completed ${row.songId} (${updated.total} bytes)")
|
||||||
|
DownloadStatus.Failed -> Log.w("download", "failed ${row.songId}: ${updated.error}")
|
||||||
|
DownloadStatus.Running -> Log.i("download", "started ${row.songId}")
|
||||||
|
DownloadStatus.Queued -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun markFailed(
|
private suspend fun markFailed(
|
||||||
@@ -590,6 +691,20 @@ class DownloadRepository(
|
|||||||
row.engineId?.let { engine.cancel(listOf(it)) }
|
row.engineId?.let { engine.cancel(listOf(it)) }
|
||||||
file(row.sourceId, row.songId).delete()
|
file(row.sourceId, row.songId).delete()
|
||||||
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
|
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
|
||||||
|
if (row.status == DownloadStatus.Completed) {
|
||||||
|
refreshDownloadedArtistArt(row.sourceId, listOf(row.songId))
|
||||||
|
}
|
||||||
|
Log.w("download", "failed ${row.songId}: $error")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshDownloadedArtistArt(
|
||||||
|
sourceId: Long,
|
||||||
|
songIds: Collection<String>,
|
||||||
|
) {
|
||||||
|
songIds.chunked(500).forEach { songs ->
|
||||||
|
val artistIds = db.libraryDao().creditedArtistIdsForSongs(sourceId, songs).distinct()
|
||||||
|
artistIds.chunked(500).forEach { db.libraryDao().recomputeDownloadedArtistArt(sourceId, it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensurePolling() {
|
private fun ensurePolling() {
|
||||||
@@ -658,5 +773,6 @@ class DownloadRepository(
|
|||||||
const val IN_FLIGHT_LIMIT = 8
|
const val IN_FLIGHT_LIMIT = 8
|
||||||
const val DELETE_CHUNK = 900
|
const val DELETE_CHUNK = 900
|
||||||
const val ART_TIMEOUT_MS = 60_000L
|
const val ART_TIMEOUT_MS = 60_000L
|
||||||
|
const val BIO_TIMEOUT_MS = 30_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import com.subtracks.data.db.rediscoverAlbumsQuery
|
|||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistAlbums
|
||||||
|
import com.subtracks.data.model.ArtistBio
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
@@ -22,24 +24,32 @@ import com.subtracks.data.prefs.AlbumSort
|
|||||||
import com.subtracks.data.prefs.ArtistSort
|
import com.subtracks.data.prefs.ArtistSort
|
||||||
import com.subtracks.data.prefs.PlaylistSort
|
import com.subtracks.data.prefs.PlaylistSort
|
||||||
import com.subtracks.data.prefs.StarredFilter
|
import com.subtracks.data.prefs.StarredFilter
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class LibraryRepository(
|
class LibraryRepository(
|
||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val serverActions: ServerActionSink,
|
private val serverActions: ServerActionSink,
|
||||||
|
private val userPreferences: UserPreferences,
|
||||||
private val showMessage: (UiMessage) -> Unit = {},
|
private val showMessage: (UiMessage) -> Unit = {},
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
) {
|
) {
|
||||||
@@ -106,12 +116,18 @@ class LibraryRepository(
|
|||||||
fun searchAlbums(
|
fun searchAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
query: String,
|
query: String,
|
||||||
): Flow<List<Album>> = db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT)
|
): Flow<List<Album>> =
|
||||||
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
|
db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||||
|
}
|
||||||
|
|
||||||
fun searchArtists(
|
fun searchArtists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
query: String,
|
query: String,
|
||||||
): Flow<List<Artist>> = db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT)
|
): Flow<List<Artist>> =
|
||||||
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
|
db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||||
|
}
|
||||||
|
|
||||||
fun searchPlaylists(
|
fun searchPlaylists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -121,7 +137,10 @@ class LibraryRepository(
|
|||||||
fun searchSongs(
|
fun searchSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
query: String,
|
query: String,
|
||||||
): Flow<List<AlbumSongItem>> = db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT)
|
): Flow<List<AlbumSongItem>> =
|
||||||
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
|
db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||||
|
}
|
||||||
|
|
||||||
fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
|
fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
|
||||||
|
|
||||||
@@ -135,9 +154,9 @@ class LibraryRepository(
|
|||||||
suspend fun genreSongOrdinal(
|
suspend fun genreSongOrdinal(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
genre: String,
|
genre: String,
|
||||||
title: String,
|
sortName: String,
|
||||||
id: String,
|
id: String,
|
||||||
): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, title, id)
|
): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, sortName, id)
|
||||||
|
|
||||||
suspend fun downloadedSongOrdinal(
|
suspend fun downloadedSongOrdinal(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -173,66 +192,68 @@ class LibraryRepository(
|
|||||||
search: String,
|
search: String,
|
||||||
downloaded: Boolean,
|
downloaded: Boolean,
|
||||||
): Flow<PagingData<Album>> =
|
): Flow<PagingData<Album>> =
|
||||||
pager(40) {
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
val dao = db.libraryDao()
|
pager(40, enablePlaceholders = true) {
|
||||||
if (!downloaded) {
|
val dao = db.libraryDao()
|
||||||
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
if (!downloaded) {
|
||||||
} else {
|
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
|
||||||
val downloadedFilter = 1
|
} else {
|
||||||
when (sort) {
|
val downloadedFilter = 1
|
||||||
AlbumSort.Name -> {
|
when (sort) {
|
||||||
if (descending) {
|
AlbumSort.Name -> {
|
||||||
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
if (descending) {
|
||||||
} else {
|
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||||
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
} else {
|
||||||
|
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
AlbumSort.Artist -> {
|
AlbumSort.Artist -> {
|
||||||
if (descending) {
|
if (descending) {
|
||||||
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||||
} else {
|
} else {
|
||||||
dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
AlbumSort.Year -> {
|
AlbumSort.Year -> {
|
||||||
if (descending) {
|
if (descending) {
|
||||||
dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||||
} else {
|
} else {
|
||||||
dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
AlbumSort.Added -> {
|
AlbumSort.Added -> {
|
||||||
if (descending) {
|
if (descending) {
|
||||||
dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||||
} else {
|
} else {
|
||||||
dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
AlbumSort.Starred -> {
|
AlbumSort.Starred -> {
|
||||||
if (descending) {
|
if (descending) {
|
||||||
dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||||
} else {
|
} else {
|
||||||
dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
AlbumSort.Frequent -> {
|
AlbumSort.Frequent -> {
|
||||||
if (descending) {
|
if (descending) {
|
||||||
dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||||
} else {
|
} else {
|
||||||
dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
AlbumSort.Recent -> {
|
AlbumSort.Recent -> {
|
||||||
if (descending) {
|
if (descending) {
|
||||||
dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||||
} else {
|
} else {
|
||||||
dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,50 +268,52 @@ class LibraryRepository(
|
|||||||
search: String,
|
search: String,
|
||||||
downloaded: Boolean,
|
downloaded: Boolean,
|
||||||
): Flow<PagingData<Artist>> =
|
): Flow<PagingData<Artist>> =
|
||||||
pager(60) {
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
val dao = db.libraryDao()
|
pager(60) {
|
||||||
if (!downloaded) {
|
val dao = db.libraryDao()
|
||||||
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
if (!downloaded) {
|
||||||
} else {
|
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
|
||||||
val downloadedFilter = 1
|
} else {
|
||||||
when (sort) {
|
val downloadedFilter = 1
|
||||||
ArtistSort.Name -> {
|
when (sort) {
|
||||||
if (descending) {
|
ArtistSort.Name -> {
|
||||||
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
if (descending) {
|
||||||
} else {
|
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||||
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
} else {
|
||||||
|
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ArtistSort.AlbumCount -> {
|
ArtistSort.AlbumCount -> {
|
||||||
if (descending) {
|
if (descending) {
|
||||||
dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||||
} else {
|
} else {
|
||||||
dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ArtistSort.Starred -> {
|
ArtistSort.Starred -> {
|
||||||
if (descending) {
|
if (descending) {
|
||||||
dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||||
} else {
|
} else {
|
||||||
dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ArtistSort.Frequent -> {
|
ArtistSort.Frequent -> {
|
||||||
if (descending) {
|
if (descending) {
|
||||||
dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||||
} else {
|
} else {
|
||||||
dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ArtistSort.Recent -> {
|
ArtistSort.Recent -> {
|
||||||
if (descending) {
|
if (descending) {
|
||||||
dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||||
} else {
|
} else {
|
||||||
dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,6 +376,36 @@ class LibraryRepository(
|
|||||||
artistId: String,
|
artistId: String,
|
||||||
): Flow<String?> = db.libraryDao().artistName(sourceId, artistId)
|
): Flow<String?> = db.libraryDao().artistName(sourceId, artistId)
|
||||||
|
|
||||||
|
fun artistBio(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Flow<ArtistBio?> =
|
||||||
|
db
|
||||||
|
.libraryDao()
|
||||||
|
.artistBio(sourceId, artistId)
|
||||||
|
.map { bio -> bio?.copy(biography = normalizeBiography(bio.biography)) }
|
||||||
|
|
||||||
|
suspend fun refreshArtistBio(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Result<Unit> =
|
||||||
|
try {
|
||||||
|
val fetched = normalizeBiography(sourceRepository.artistBiography(sourceId, artistId))
|
||||||
|
val current = db.libraryDao().artistBioOnce(sourceId, artistId)
|
||||||
|
// An empty response can mean the metadata provider is temporarily down, so keep the
|
||||||
|
// cached biography instead of erasing it.
|
||||||
|
val biography = fetched ?: current?.biography
|
||||||
|
if (current == null || current.biography != biography) {
|
||||||
|
db.libraryDao().upsertArtistBio(ArtistBio(sourceId, artistId, biography))
|
||||||
|
}
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (failure: Exception) {
|
||||||
|
Log.w("artist", "failed to refresh bio for $artistId", failure)
|
||||||
|
Result.failure(failure)
|
||||||
|
}
|
||||||
|
|
||||||
fun song(
|
fun song(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
songId: String,
|
songId: String,
|
||||||
@@ -375,12 +428,24 @@ class LibraryRepository(
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
downloaded: Boolean = false,
|
downloaded: Boolean = false,
|
||||||
): Flow<List<Album>> =
|
): Flow<ArtistAlbums> {
|
||||||
if (downloaded) {
|
val albums =
|
||||||
db.libraryDao().albumsForArtistDownloaded(sourceId, artistId)
|
if (downloaded) {
|
||||||
} else {
|
db.libraryDao().albumsForArtistDownloaded(sourceId, artistId)
|
||||||
db.libraryDao().albumsForArtist(sourceId, artistId)
|
} else {
|
||||||
|
db.libraryDao().albumsForArtist(sourceId, artistId)
|
||||||
|
}
|
||||||
|
return combine(albums, db.libraryDao().albumCreditAlbumIds(sourceId, artistId)) { list, creditIds ->
|
||||||
|
val credits = creditIds.toHashSet()
|
||||||
|
val (albumCredits, appearances) = list.partition { it.id in credits }
|
||||||
|
ArtistAlbums(albumCredits = albumCredits, appearances = appearances)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun artistCoverArts(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Flow<List<String>> = db.libraryDao().artistCoverArts(sourceId, artistId)
|
||||||
|
|
||||||
fun albumDiscs(
|
fun albumDiscs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -464,14 +529,17 @@ class LibraryRepository(
|
|||||||
}
|
}
|
||||||
val previous = starredValue(sourceId, type, id)
|
val previous = starredValue(sourceId, type, id)
|
||||||
updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null)
|
updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null)
|
||||||
|
val action = if (starred) "star" else "unstar"
|
||||||
return try {
|
return try {
|
||||||
serverActions.setStar(type, id, starred)
|
serverActions.setStar(type, id, starred)
|
||||||
|
Log.i("star", "$action ${type.name.lowercase()} $id")
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (failure: Exception) {
|
} catch (failure: Exception) {
|
||||||
withContext(NonCancellable) { updateStarred(sourceId, type, id, previous) }
|
withContext(NonCancellable) { updateStarred(sourceId, type, id, previous) }
|
||||||
showMessage(UiMessage(R.string.star_update_failed))
|
showMessage(UiMessage(R.string.star_update_failed))
|
||||||
|
Log.w("star", "failed to $action ${type.name.lowercase()} $id", failure)
|
||||||
Result.failure(failure)
|
Result.failure(failure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -503,10 +571,11 @@ class LibraryRepository(
|
|||||||
|
|
||||||
private fun <T : Any> pager(
|
private fun <T : Any> pager(
|
||||||
pageSize: Int,
|
pageSize: Int,
|
||||||
|
enablePlaceholders: Boolean = false,
|
||||||
source: () -> PagingSource<Int, T>,
|
source: () -> PagingSource<Int, T>,
|
||||||
): Flow<PagingData<T>> =
|
): Flow<PagingData<T>> =
|
||||||
Pager(
|
Pager(
|
||||||
config = PagingConfig(pageSize = pageSize, enablePlaceholders = false),
|
config = PagingConfig(pageSize = pageSize, enablePlaceholders = enablePlaceholders),
|
||||||
pagingSourceFactory = source,
|
pagingSourceFactory = source,
|
||||||
).flow
|
).flow
|
||||||
}
|
}
|
||||||
@@ -515,4 +584,12 @@ const val REDISCOVER_WINDOW_MS = 60L * 24 * 60 * 60 * 1000
|
|||||||
|
|
||||||
const val SEARCH_RESULT_LIMIT = 50
|
const val SEARCH_RESULT_LIMIT = 50
|
||||||
|
|
||||||
|
internal fun normalizeBiography(value: String?): String? =
|
||||||
|
value
|
||||||
|
?.replace("\r\n", "\n")
|
||||||
|
?.replace('\r', '\n')
|
||||||
|
?.replace(Regex(" {3,}"), "\n\n")
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS
|
fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.subtracks.data.model.PendingActionKind
|
|||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.data.source.subsonic.SubsonicException
|
import com.subtracks.data.source.subsonic.SubsonicException
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -29,8 +30,13 @@ class PendingServerActionSink(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
var lastReady: Boolean? = null
|
||||||
combine(offlinePreferences, networkAvailable) { isOffline, online -> !isOffline && online }
|
combine(offlinePreferences, networkAvailable) { isOffline, online -> !isOffline && online }
|
||||||
.collect { ready ->
|
.collect { ready ->
|
||||||
|
if (ready && lastReady == false) {
|
||||||
|
Log.i("net", "connection available, replaying pending server actions")
|
||||||
|
}
|
||||||
|
lastReady = ready
|
||||||
// The persisted flag and connectivity are authoritative, unlike the process
|
// The persisted flag and connectivity are authoritative, unlike the process
|
||||||
// flag's default at cold start, so wait for that flag to agree before replaying.
|
// flag's default at cold start, so wait for that flag to agree before replaying.
|
||||||
if (ready) {
|
if (ready) {
|
||||||
@@ -96,12 +102,14 @@ class PendingServerActionSink(
|
|||||||
for (action in dao.pending(sourceId)) {
|
for (action in dao.pending(sourceId)) {
|
||||||
try {
|
try {
|
||||||
deliver(action)
|
deliver(action)
|
||||||
|
Log.i("net", "replayed ${action.kind.name.lowercase()} ${action.targetId}")
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (failure: Exception) {
|
} catch (failure: Exception) {
|
||||||
// Still unreachable: keep this and the remaining actions for the next attempt.
|
// Still unreachable: keep this and the remaining actions for the next attempt.
|
||||||
if (isRetryable(failure)) return
|
if (isRetryable(failure)) return
|
||||||
// The server answered and refused; drop the action rather than block the queue.
|
// The server answered and refused; drop the action rather than block the queue.
|
||||||
|
Log.w("net", "dropped ${action.kind.name.lowercase()} ${action.targetId}", failure)
|
||||||
}
|
}
|
||||||
dao.delete(action.id)
|
dao.delete(action.id)
|
||||||
}
|
}
|
||||||
@@ -139,6 +147,7 @@ class PendingServerActionSink(
|
|||||||
db.pendingActionDao().replace(
|
db.pendingActionDao().replace(
|
||||||
PendingAction(sourceId = sourceId, kind = kind, targetId = targetId, starType = starType, time = time),
|
PendingAction(sourceId = sourceId, kind = kind, targetId = targetId, starType = starType, time = time),
|
||||||
)
|
)
|
||||||
|
Log.i("net", "queued ${kind.name.lowercase()} $targetId")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,8 @@ data class QueueSnapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun locateContext(contextPlay: Long): Pair<QueueEntry, Long>? = locateIn(entries, flatContext(contextPlay) ?: return null)
|
fun locateContext(contextPlay: Long): Pair<QueueEntry, Long>? = locateIn(entries, flatContext(contextPlay) ?: return null)
|
||||||
|
|
||||||
|
fun isAlbumContext(position: Long): Boolean = locate(position)?.first?.kind == QueueKind.Album
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun entryIndexAfter(
|
private fun entryIndexAfter(
|
||||||
@@ -295,8 +297,8 @@ class QueueRepository(
|
|||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// An explicit song entry in the middle of a queue is a reordered context track, so
|
// A song entry can be a single or a track reordered out of a playlist, artist or
|
||||||
// label it by its album rather than losing the context name.
|
// genre, so label it by its album rather than losing the context name.
|
||||||
QueueKind.Song -> {
|
QueueKind.Song -> {
|
||||||
library
|
library
|
||||||
.song(sourceId, refId)
|
.song(sourceId, refId)
|
||||||
@@ -581,9 +583,9 @@ class QueueRepository(
|
|||||||
val toUpNext = snapshot.isUpNext(to)
|
val toUpNext = snapshot.isUpNext(to)
|
||||||
if (fromUpNext && toUpNext) {
|
if (fromUpNext && toUpNext) {
|
||||||
val song = itemAt(snapshot, from) ?: return false
|
val song = itemAt(snapshot, from) ?: return false
|
||||||
val sourceId = snapshot.locate(from)?.first?.sourceId ?: return false
|
val located = snapshot.locate(from) ?: return false
|
||||||
val removed = removeEntry(snapshot.upNext, snapshot.upNextIndex(from))
|
val removed = removeEntry(snapshot.upNext, snapshot.upNextIndex(from))
|
||||||
val moved = ResolvedQueueEntry(songEntry(sourceId, song.song.id), 1L)
|
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L)
|
||||||
writeUpNext(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry })
|
writeUpNext(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -597,7 +599,7 @@ class QueueRepository(
|
|||||||
val located = snapshot.locateContext(playFrom) ?: return false
|
val located = snapshot.locateContext(playFrom) ?: return false
|
||||||
val song = itemAt(snapshot, from) ?: return false
|
val song = itemAt(snapshot, from) ?: return false
|
||||||
val removed = removeEntry(snapshot.entries, playFrom)
|
val removed = removeEntry(snapshot.entries, playFrom)
|
||||||
val moved = ResolvedQueueEntry(songEntry(located.first.sourceId, song.song.id), 1L)
|
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L)
|
||||||
val insertAt = playTo.coerceIn(0, removed.sumOf { it.length })
|
val insertAt = playTo.coerceIn(0, removed.sumOf { it.length })
|
||||||
write(compact(insertEntry(removed, insertAt, moved)).map { it.entry })
|
write(compact(insertEntry(removed, insertAt, moved)).map { it.entry })
|
||||||
adjustAnchorOnMove(playFrom, insertAt)
|
adjustAnchorOnMove(playFrom, insertAt)
|
||||||
@@ -929,6 +931,18 @@ class QueueRepository(
|
|||||||
return count?.coerceAtMost(available) ?: available
|
return count?.coerceAtMost(available) ?: available
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun movedEntry(
|
||||||
|
source: QueueEntry,
|
||||||
|
songId: String,
|
||||||
|
offsetInEntry: Long,
|
||||||
|
): QueueEntry =
|
||||||
|
if (source.kind == QueueKind.Album) {
|
||||||
|
val offset = source.offset + offsetInEntry
|
||||||
|
source.range(offset, offset)
|
||||||
|
} else {
|
||||||
|
songEntry(source.sourceId, songId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun QueueEntry.range(
|
private fun QueueEntry.range(
|
||||||
start: Long,
|
start: Long,
|
||||||
end: Long,
|
end: Long,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.subtracks.data.model.CoverArtRef
|
|||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.model.SubsonicConfig
|
import com.subtracks.data.model.SubsonicConfig
|
||||||
import com.subtracks.data.model.SubsonicSource
|
import com.subtracks.data.model.SubsonicSource
|
||||||
|
import com.subtracks.data.model.artistImageKey
|
||||||
import com.subtracks.data.model.coverArtKey
|
import com.subtracks.data.model.coverArtKey
|
||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
@@ -17,6 +18,7 @@ import com.subtracks.data.source.MusicSource
|
|||||||
import com.subtracks.data.source.declaredStreamLength
|
import com.subtracks.data.source.declaredStreamLength
|
||||||
import com.subtracks.data.source.streamLengthSuffix
|
import com.subtracks.data.source.streamLengthSuffix
|
||||||
import com.subtracks.data.source.subsonic.SubsonicClient
|
import com.subtracks.data.source.subsonic.SubsonicClient
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -46,6 +48,13 @@ class SourceRepository(
|
|||||||
val source: SubsonicMusicSource,
|
val source: SubsonicMusicSource,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class ResolvedSource(
|
||||||
|
val config: SubsonicConfig?,
|
||||||
|
val quality: StreamQuality,
|
||||||
|
val mode: NetworkMode,
|
||||||
|
val concurrency: Int,
|
||||||
|
)
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var active: ActiveSource? = null
|
private var active: ActiveSource? = null
|
||||||
|
|
||||||
@@ -61,9 +70,13 @@ class SourceRepository(
|
|||||||
private val _offline = MutableStateFlow(false)
|
private val _offline = MutableStateFlow(false)
|
||||||
val offline: StateFlow<Boolean> = _offline
|
val offline: StateFlow<Boolean> = _offline
|
||||||
|
|
||||||
|
private val _activeSource = MutableStateFlow<Long?>(null)
|
||||||
|
val activeSource: StateFlow<Long?> = _activeSource
|
||||||
|
|
||||||
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
||||||
|
|
||||||
fun setOfflineMode(enabled: Boolean) {
|
fun setOfflineMode(enabled: Boolean) {
|
||||||
|
Log.i("net", "offline mode -> $enabled")
|
||||||
scope.launch { prefs.setOfflineMode(enabled) }
|
scope.launch { prefs.setOfflineMode(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +94,7 @@ class SourceRepository(
|
|||||||
// Read the persisted flag before the source config can build a source or probe it, so a
|
// Read the persisted flag before the source config can build a source or probe it, so a
|
||||||
// cold start with offline on never touches the network.
|
// cold start with offline on never touches the network.
|
||||||
_offline.value = prefs.offlineMode().first()
|
_offline.value = prefs.offlineMode().first()
|
||||||
|
var lastMode: NetworkMode? = null
|
||||||
combine(
|
combine(
|
||||||
db.sourcesDao().activeSubsonicConfig(),
|
db.sourcesDao().activeSubsonicConfig(),
|
||||||
networkMode,
|
networkMode,
|
||||||
@@ -88,13 +102,24 @@ class SourceRepository(
|
|||||||
prefs.streamQuality(NetworkMode.Mobile),
|
prefs.streamQuality(NetworkMode.Mobile),
|
||||||
prefs.syncConcurrency(),
|
prefs.syncConcurrency(),
|
||||||
) { config, mode, wifi, mobile, concurrency ->
|
) { config, mode, wifi, mobile, concurrency ->
|
||||||
Triple(config, if (mode == NetworkMode.Wifi) wifi else mobile, concurrency)
|
ResolvedSource(config, if (mode == NetworkMode.Wifi) wifi else mobile, mode, concurrency)
|
||||||
}.collect { (config, quality, concurrency) ->
|
}.collect { (config, quality, mode, concurrency) ->
|
||||||
|
if (mode != lastMode) {
|
||||||
|
Log.i("net", "network mode -> ${mode.key}")
|
||||||
|
lastMode = mode
|
||||||
|
}
|
||||||
val sourceChanged = config?.id != active?.id
|
val sourceChanged = config?.id != active?.id
|
||||||
|
if (sourceChanged) {
|
||||||
|
Log.i("net", "active source -> ${config?.name ?: "none"}")
|
||||||
|
}
|
||||||
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
|
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
|
||||||
|
_activeSource.value = config?.id
|
||||||
_quality.value = quality
|
_quality.value = quality
|
||||||
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
|
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
|
||||||
scope.launch { runCatching { config.toClient().check("ping") } }
|
scope.launch {
|
||||||
|
runCatching { config.toClient().check("ping") }
|
||||||
|
.onSuccess { Log.i("net", "connected (ping ok)") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,6 +148,18 @@ class SourceRepository(
|
|||||||
return CoverArtRef(url = url, cacheKey = cacheKey)
|
return CoverArtRef(url = url, cacheKey = cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun artistImage(
|
||||||
|
artistId: String,
|
||||||
|
url: String?,
|
||||||
|
): CoverArtRef? {
|
||||||
|
val current = active ?: return null
|
||||||
|
if (url.isNullOrBlank()) return null
|
||||||
|
val cacheKey = artistImageKey(current.id, artistId)
|
||||||
|
artworkStore.uri(current.id, cacheKey)?.let { return CoverArtRef(url = it, cacheKey = cacheKey) }
|
||||||
|
if (_offline.value) return null
|
||||||
|
return CoverArtRef(url = url, cacheKey = cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
fun networkCoverArt(
|
fun networkCoverArt(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
coverArt: String,
|
coverArt: String,
|
||||||
@@ -200,8 +237,8 @@ class SourceRepository(
|
|||||||
useTokenAuth: Boolean,
|
useTokenAuth: Boolean,
|
||||||
) {
|
) {
|
||||||
val dao = db.sourcesDao()
|
val dao = db.sourcesDao()
|
||||||
val source = dao.sourceOnce(id) ?: return
|
if (dao.sourceOnce(id) == null) return
|
||||||
dao.upsertSource(source.copy(name = name.trim(), address = normalizeAddress(address)))
|
dao.updateSourceDetails(id, name.trim(), normalizeAddress(address))
|
||||||
dao.upsertSubsonicSource(
|
dao.upsertSubsonicSource(
|
||||||
SubsonicSource(
|
SubsonicSource(
|
||||||
sourceId = id,
|
sourceId = id,
|
||||||
@@ -241,6 +278,15 @@ class SourceRepository(
|
|||||||
return config.toMusicSource(quality.value, prefs.syncConcurrency().first())
|
return config.toMusicSource(quality.value, prefs.syncConcurrency().first())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun artistBiography(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): String? {
|
||||||
|
check(!_offline.value) { "Offline mode is on" }
|
||||||
|
val config = db.sourcesDao().subsonicConfigOnce(sourceId) ?: error("No configured source")
|
||||||
|
return config.toMusicSource(quality.value, prefs.syncConcurrency().first()).artistBiography(artistId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun SubsonicConfig.toClient(): SubsonicClient =
|
private fun SubsonicConfig.toClient(): SubsonicClient =
|
||||||
client(
|
client(
|
||||||
address = address,
|
address = address,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ interface MusicSource {
|
|||||||
time: Long? = null,
|
time: Long? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
suspend fun artistBiography(artistId: String): String? = null
|
||||||
|
|
||||||
fun artists(): Flow<List<Artist>>
|
fun artists(): Flow<List<Artist>>
|
||||||
|
|
||||||
fun albums(): Flow<List<Album>>
|
fun albums(): Flow<List<Album>>
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package com.subtracks.data.source.subsonic
|
|||||||
|
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
|
import com.subtracks.log.Log
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
open class SubsonicException(
|
open class SubsonicException(
|
||||||
val code: Int,
|
val code: Int,
|
||||||
@@ -20,7 +22,6 @@ class SubsonicClient(
|
|||||||
private val password: String,
|
private val password: String,
|
||||||
useTokenAuth: Boolean,
|
useTokenAuth: Boolean,
|
||||||
private val http: OkHttpClient,
|
private val http: OkHttpClient,
|
||||||
private val userAgent: String = "subtracks/android",
|
|
||||||
private val onTokenAuthUnsupported: () -> Unit = {},
|
private val onTokenAuthUnsupported: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -55,30 +56,37 @@ class SubsonicClient(
|
|||||||
fun <T> stream(
|
fun <T> stream(
|
||||||
method: String,
|
method: String,
|
||||||
params: Map<String, String> = emptyMap(),
|
params: Map<String, String> = emptyMap(),
|
||||||
|
timeoutMs: Long? = null,
|
||||||
body: (InputStream) -> T,
|
body: (InputStream) -> T,
|
||||||
): T {
|
): T {
|
||||||
val usedTokenAuth = useTokenAuth
|
val usedTokenAuth = useTokenAuth
|
||||||
return try {
|
return try {
|
||||||
execute(method, params, body)
|
execute(method, params, timeoutMs, body)
|
||||||
} catch (failure: SubsonicException) {
|
} catch (failure: SubsonicException) {
|
||||||
if (failure.code !in TOKEN_AUTH_UNSUPPORTED_CODES || !usedTokenAuth) throw failure
|
if (failure.code !in TOKEN_AUTH_UNSUPPORTED_CODES || !usedTokenAuth) {
|
||||||
|
Log.w("http", "$method failed: server code ${failure.code} \"${failure.message}\"")
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
Log.w("http", "$method: token auth unsupported (server code ${failure.code}), retrying with plaintext")
|
||||||
disableTokenAuth()
|
disableTokenAuth()
|
||||||
execute(method, params, body)
|
execute(method, params, timeoutMs, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T> execute(
|
private fun <T> execute(
|
||||||
method: String,
|
method: String,
|
||||||
params: Map<String, String>,
|
params: Map<String, String>,
|
||||||
|
timeoutMs: Long?,
|
||||||
body: (InputStream) -> T,
|
body: (InputStream) -> T,
|
||||||
): T {
|
): T {
|
||||||
val request =
|
val request =
|
||||||
Request
|
Request
|
||||||
.Builder()
|
.Builder()
|
||||||
.url(uri(method, params))
|
.url(uri(method, params))
|
||||||
.header("User-Agent", userAgent)
|
|
||||||
.build()
|
.build()
|
||||||
http.newCall(request).execute().use { response ->
|
val call = http.newCall(request)
|
||||||
|
timeoutMs?.let { call.timeout().timeout(it, TimeUnit.MILLISECONDS) }
|
||||||
|
call.execute().use { response ->
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
throw SubsonicException(-1, "HTTP ${response.code}", UiMessage(R.string.error_http, listOf(response.code)))
|
throw SubsonicException(-1, "HTTP ${response.code}", UiMessage(R.string.error_http, listOf(response.code)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ import kotlinx.coroutines.flow.flow
|
|||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.xml.sax.Attributes
|
import org.xml.sax.Attributes
|
||||||
@@ -83,6 +85,13 @@ class SubsonicSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun artistBiography(artistId: String): String? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
client.stream("getArtistInfo2", mapOf("id" to artistId), timeoutMs = ARTIST_BIO_TIMEOUT_MS) { input ->
|
||||||
|
SubsonicXml.artistBiography(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun artists(): Flow<List<Artist>> =
|
override fun artists(): Flow<List<Artist>> =
|
||||||
entityBatches(
|
entityBatches(
|
||||||
method = "getArtists",
|
method = "getArtists",
|
||||||
@@ -101,7 +110,7 @@ class SubsonicSource(
|
|||||||
params = page("newest", offset),
|
params = page("newest", offset),
|
||||||
tag = "album",
|
tag = "album",
|
||||||
create = { SubsonicXml.albumDraft(id, it) },
|
create = { SubsonicXml.albumDraft(id, it) },
|
||||||
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
|
onChild = SubsonicXml::albumChild,
|
||||||
).collect { batch ->
|
).collect { batch ->
|
||||||
raw += batch.size
|
raw += batch.size
|
||||||
val accepted = batch.filter { it.id.isNotEmpty() }
|
val accepted = batch.filter { it.id.isNotEmpty() }
|
||||||
@@ -205,7 +214,7 @@ class SubsonicSource(
|
|||||||
),
|
),
|
||||||
tag = "song",
|
tag = "song",
|
||||||
create = { SubsonicXml.songDraft(id, it) },
|
create = { SubsonicXml.songDraft(id, it) },
|
||||||
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) },
|
onChild = SubsonicXml::songChild,
|
||||||
).collect { batch ->
|
).collect { batch ->
|
||||||
raw += batch.size
|
raw += batch.size
|
||||||
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
||||||
@@ -224,6 +233,23 @@ class SubsonicSource(
|
|||||||
private fun albumSongs(): Flow<List<Song>> =
|
private fun albumSongs(): Flow<List<Song>> =
|
||||||
channelFlow {
|
channelFlow {
|
||||||
val permits = Semaphore(maxConcurrentFetches)
|
val permits = Semaphore(maxConcurrentFetches)
|
||||||
|
val lock = Mutex()
|
||||||
|
val pending = ArrayList<Song>(PAGE_SIZE)
|
||||||
|
|
||||||
|
suspend fun accept(songs: List<Song>) {
|
||||||
|
lock
|
||||||
|
.withLock {
|
||||||
|
pending.addAll(songs)
|
||||||
|
if (pending.size < PAGE_SIZE) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val batch = ArrayList(pending.subList(0, PAGE_SIZE))
|
||||||
|
pending.subList(0, PAGE_SIZE).clear()
|
||||||
|
batch
|
||||||
|
}
|
||||||
|
}?.let { send(it) }
|
||||||
|
}
|
||||||
|
|
||||||
var offset = 0
|
var offset = 0
|
||||||
var pages = 0
|
var pages = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -248,10 +274,10 @@ class SubsonicSource(
|
|||||||
params = mapOf("id" to albumId),
|
params = mapOf("id" to albumId),
|
||||||
tag = "song",
|
tag = "song",
|
||||||
create = { SubsonicXml.songDraft(id, it) },
|
create = { SubsonicXml.songDraft(id, it) },
|
||||||
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) },
|
onChild = SubsonicXml::songChild,
|
||||||
).collect { batch ->
|
).collect { batch ->
|
||||||
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
||||||
if (accepted.isNotEmpty()) send(accepted)
|
accept(accepted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,7 +291,14 @@ class SubsonicSource(
|
|||||||
if (pages > maxPages) throw pageCapExceeded()
|
if (pages > maxPages) throw pageCapExceeded()
|
||||||
offset += PAGE_SIZE
|
offset += PAGE_SIZE
|
||||||
}
|
}
|
||||||
}
|
val tail =
|
||||||
|
lock.withLock {
|
||||||
|
val held = pending.toList()
|
||||||
|
pending.clear()
|
||||||
|
held
|
||||||
|
}
|
||||||
|
if (tail.isNotEmpty()) send(tail)
|
||||||
|
}.buffer(1)
|
||||||
|
|
||||||
private fun supportsEmptyQuerySearch(): Boolean {
|
private fun supportsEmptyQuerySearch(): Boolean {
|
||||||
emptyQuerySearchSupported?.let { return it }
|
emptyQuerySearchSupported?.let { return it }
|
||||||
@@ -297,7 +330,7 @@ class SubsonicSource(
|
|||||||
params: Map<String, String> = emptyMap(),
|
params: Map<String, String> = emptyMap(),
|
||||||
tag: String,
|
tag: String,
|
||||||
create: (Attributes) -> T,
|
create: (Attributes) -> T,
|
||||||
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
|
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
|
||||||
): Flow<List<T>> =
|
): Flow<List<T>> =
|
||||||
channelFlow {
|
channelFlow {
|
||||||
val buffer = ArrayList<T>(PAGE_SIZE)
|
val buffer = ArrayList<T>(PAGE_SIZE)
|
||||||
@@ -339,6 +372,7 @@ class SubsonicSource(
|
|||||||
const val PAGE_SIZE = 500
|
const val PAGE_SIZE = 500
|
||||||
const val MAX_PAGES = 20_000
|
const val MAX_PAGES = 20_000
|
||||||
const val THUMBNAIL_SIZE = 384
|
const val THUMBNAIL_SIZE = 384
|
||||||
|
const val ARTIST_BIO_TIMEOUT_MS = 15_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.subtracks.R
|
|||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
@@ -31,6 +32,8 @@ object SubsonicXml {
|
|||||||
albumCount = attrs.longAttr("albumCount") ?: 0L,
|
albumCount = attrs.longAttr("albumCount") ?: 0L,
|
||||||
starred = attrs.dateAttr("starred"),
|
starred = attrs.dateAttr("starred"),
|
||||||
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
||||||
|
artistImageUrl = attrs.attr("artistImageUrl").ifEmpty { null },
|
||||||
|
sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun albumDraft(
|
internal fun albumDraft(
|
||||||
@@ -43,6 +46,8 @@ object SubsonicXml {
|
|||||||
artistId = attrs.attr("artistId").ifEmpty { null },
|
artistId = attrs.attr("artistId").ifEmpty { null },
|
||||||
name = attrs.attr("name"),
|
name = attrs.attr("name"),
|
||||||
albumArtist = attrs.attr("artist").ifEmpty { null },
|
albumArtist = attrs.attr("artist").ifEmpty { null },
|
||||||
|
displayArtist = attrs.attr("displayArtist").ifEmpty { null },
|
||||||
|
sortName = attrs.attr("sortName").ifEmpty { null },
|
||||||
created = attrs.dateAttr("created") ?: 0L,
|
created = attrs.dateAttr("created") ?: 0L,
|
||||||
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
||||||
genre = attrs.attr("genre").ifEmpty { null },
|
genre = attrs.attr("genre").ifEmpty { null },
|
||||||
@@ -68,6 +73,8 @@ object SubsonicXml {
|
|||||||
title = attrs.attr("title"),
|
title = attrs.attr("title"),
|
||||||
album = attrs.attr("album").ifEmpty { null },
|
album = attrs.attr("album").ifEmpty { null },
|
||||||
artist = attrs.attr("artist").ifEmpty { null },
|
artist = attrs.attr("artist").ifEmpty { null },
|
||||||
|
displayArtist = attrs.attr("displayArtist").ifEmpty { null },
|
||||||
|
sortName = attrs.attr("sortName").ifEmpty { null },
|
||||||
duration = attrs.longAttr("duration"),
|
duration = attrs.longAttr("duration"),
|
||||||
track = attrs.longAttr("track"),
|
track = attrs.longAttr("track"),
|
||||||
disc = attrs.longAttr("discNumber"),
|
disc = attrs.longAttr("discNumber"),
|
||||||
@@ -78,6 +85,36 @@ object SubsonicXml {
|
|||||||
played = attrs.dateAttr("played"),
|
played = attrs.dateAttr("played"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
internal fun songChild(
|
||||||
|
entity: SongDraft,
|
||||||
|
name: String,
|
||||||
|
attrs: Attributes,
|
||||||
|
level: Int,
|
||||||
|
) {
|
||||||
|
when (name) {
|
||||||
|
"genres" -> if (level == 1) entity.addGenre(attrs)
|
||||||
|
"replayGain" -> if (level == 1) entity.addReplayGain(attrs)
|
||||||
|
"artists" -> if (level == 1) entity.addArtists(attrs)
|
||||||
|
"albumArtists" -> entity.clearNestedArtists()
|
||||||
|
"artist" -> if (level == 2) entity.addNestedArtist(attrs)
|
||||||
|
else -> if (level == 1) entity.clearNestedArtists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun albumChild(
|
||||||
|
entity: AlbumDraft,
|
||||||
|
name: String,
|
||||||
|
attrs: Attributes,
|
||||||
|
level: Int,
|
||||||
|
) {
|
||||||
|
when (name) {
|
||||||
|
"discTitles" -> if (level == 1) entity.addDiscTitle(attrs)
|
||||||
|
"artists" -> if (level == 1) entity.addArtists(attrs)
|
||||||
|
"artist" -> if (level == 2) entity.addNestedArtist(attrs)
|
||||||
|
else -> if (level == 1) entity.clearArtists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal fun playlist(
|
internal fun playlist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
attrs: Attributes,
|
attrs: Attributes,
|
||||||
@@ -92,6 +129,8 @@ object SubsonicXml {
|
|||||||
created = attrs.dateAttr("created") ?: 0L,
|
created = attrs.dateAttr("created") ?: 0L,
|
||||||
changed = attrs.dateAttr("changed") ?: 0L,
|
changed = attrs.dateAttr("changed") ?: 0L,
|
||||||
duration = attrs.longAttr("duration") ?: 0L,
|
duration = attrs.longAttr("duration") ?: 0L,
|
||||||
|
owner = attrs.attr("owner").ifEmpty { null },
|
||||||
|
shared = attrs.boolAttr("public"),
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun playlistSong(
|
internal fun playlistSong(
|
||||||
@@ -115,7 +154,7 @@ object SubsonicXml {
|
|||||||
input: InputStream,
|
input: InputStream,
|
||||||
entityTag: String,
|
entityTag: String,
|
||||||
create: (Attributes) -> T,
|
create: (Attributes) -> T,
|
||||||
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
|
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
|
||||||
onEnd: (T) -> Unit,
|
onEnd: (T) -> Unit,
|
||||||
) {
|
) {
|
||||||
parse(input, EntityHandler(entityTag, create, onChild, onEnd, stopAfterFirst = false))
|
parse(input, EntityHandler(entityTag, create, onChild, onEnd, stopAfterFirst = false))
|
||||||
@@ -125,10 +164,16 @@ object SubsonicXml {
|
|||||||
input: InputStream,
|
input: InputStream,
|
||||||
entityTag: String,
|
entityTag: String,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val handler = EntityHandler(entityTag, { Unit }, { _, _, _ -> }, { }, stopAfterFirst = true)
|
val handler = EntityHandler(entityTag, { Unit }, { _, _, _, _ -> }, { }, stopAfterFirst = true)
|
||||||
parse(input, handler)
|
parse(input, handler)
|
||||||
return handler.found
|
return handler.found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun artistBiography(input: InputStream): String? {
|
||||||
|
val handler = ArtistBiographyHandler()
|
||||||
|
parse(input, handler)
|
||||||
|
return handler.biography
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class AlbumDraft(
|
internal class AlbumDraft(
|
||||||
@@ -137,6 +182,8 @@ internal class AlbumDraft(
|
|||||||
var artistId: String? = null,
|
var artistId: String? = null,
|
||||||
var name: String = "",
|
var name: String = "",
|
||||||
var albumArtist: String? = null,
|
var albumArtist: String? = null,
|
||||||
|
var displayArtist: String? = null,
|
||||||
|
var sortName: String? = null,
|
||||||
var created: Long = 0L,
|
var created: Long = 0L,
|
||||||
var coverArt: String? = null,
|
var coverArt: String? = null,
|
||||||
var genre: String? = null,
|
var genre: String? = null,
|
||||||
@@ -145,19 +192,41 @@ internal class AlbumDraft(
|
|||||||
var songCount: Long = 0L,
|
var songCount: Long = 0L,
|
||||||
val discTitles: MutableMap<Long, String> = mutableMapOf(),
|
val discTitles: MutableMap<Long, String> = mutableMapOf(),
|
||||||
) {
|
) {
|
||||||
|
private val artists = mutableListOf<ArtistRef>()
|
||||||
|
private var artistContainer = false
|
||||||
|
|
||||||
fun addDiscTitle(attrs: Attributes) {
|
fun addDiscTitle(attrs: Attributes) {
|
||||||
val disc = attrs.longAttr("disc") ?: return
|
val disc = attrs.longAttr("disc") ?: return
|
||||||
val title = attrs.attr("title").takeIf { it.isNotBlank() } ?: return
|
val title = attrs.attr("title").takeIf { it.isNotBlank() } ?: return
|
||||||
discTitles[disc] = title
|
discTitles[disc] = title
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toAlbum(): Album =
|
fun addArtists(attrs: Attributes) {
|
||||||
Album(
|
val ref = attrs.artistRef()
|
||||||
|
if (ref == null) {
|
||||||
|
artistContainer = true
|
||||||
|
} else {
|
||||||
|
artists += ref
|
||||||
|
artistContainer = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addNestedArtist(attrs: Attributes) {
|
||||||
|
if (artistContainer) attrs.artistRef()?.let { artists += it }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearArtists() {
|
||||||
|
artistContainer = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toAlbum(): Album {
|
||||||
|
val refs = artistFallback(artists, artistId, albumArtist)
|
||||||
|
return Album(
|
||||||
sourceId = sourceId,
|
sourceId = sourceId,
|
||||||
id = id,
|
id = id,
|
||||||
artistId = artistId,
|
artistId = artistId,
|
||||||
name = name,
|
name = name,
|
||||||
albumArtist = albumArtist,
|
albumArtist = displayName(albumArtist, displayArtist, refs),
|
||||||
created = created,
|
created = created,
|
||||||
coverArt = coverArt,
|
coverArt = coverArt,
|
||||||
genre = genre,
|
genre = genre,
|
||||||
@@ -165,7 +234,10 @@ internal class AlbumDraft(
|
|||||||
starred = starred,
|
starred = starred,
|
||||||
songCount = songCount,
|
songCount = songCount,
|
||||||
discTitles = discTitles.toMap(),
|
discTitles = discTitles.toMap(),
|
||||||
|
artists = refs,
|
||||||
|
sortName = sortName?.takeIf { it.isNotBlank() } ?: name,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class SongDraft(
|
internal class SongDraft(
|
||||||
@@ -176,6 +248,8 @@ internal class SongDraft(
|
|||||||
private val title: String = "",
|
private val title: String = "",
|
||||||
private val album: String? = null,
|
private val album: String? = null,
|
||||||
private val artist: String? = null,
|
private val artist: String? = null,
|
||||||
|
private val displayArtist: String? = null,
|
||||||
|
private val sortName: String? = null,
|
||||||
private val duration: Long? = null,
|
private val duration: Long? = null,
|
||||||
private val track: Long? = null,
|
private val track: Long? = null,
|
||||||
private val disc: Long? = null,
|
private val disc: Long? = null,
|
||||||
@@ -184,8 +258,15 @@ internal class SongDraft(
|
|||||||
private val created: Long = 0L,
|
private val created: Long = 0L,
|
||||||
private val playCount: Long = 0L,
|
private val playCount: Long = 0L,
|
||||||
private val played: Long? = null,
|
private val played: Long? = null,
|
||||||
|
private var trackGain: Float? = null,
|
||||||
|
private var albumGain: Float? = null,
|
||||||
|
private var trackPeak: Float? = null,
|
||||||
|
private var albumPeak: Float? = null,
|
||||||
private val genres: MutableList<String> = mutableListOf(),
|
private val genres: MutableList<String> = mutableListOf(),
|
||||||
) {
|
) {
|
||||||
|
private val artists = mutableListOf<ArtistRef>()
|
||||||
|
private var nestedArtists: MutableList<ArtistRef>? = null
|
||||||
|
|
||||||
fun addGenre(attrs: Attributes) {
|
fun addGenre(attrs: Attributes) {
|
||||||
attrs
|
attrs
|
||||||
.attr("name")
|
.attr("name")
|
||||||
@@ -194,15 +275,41 @@ internal class SongDraft(
|
|||||||
?.let { genres += it }
|
?.let { genres += it }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toSong(): Song =
|
fun addReplayGain(attrs: Attributes) {
|
||||||
Song(
|
attrs.floatAttr("trackGain")?.let { trackGain = it }
|
||||||
|
attrs.floatAttr("albumGain")?.let { albumGain = it }
|
||||||
|
attrs.floatAttr("trackPeak")?.let { trackPeak = it }
|
||||||
|
attrs.floatAttr("albumPeak")?.let { albumPeak = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addArtists(attrs: Attributes) {
|
||||||
|
val ref = attrs.artistRef()
|
||||||
|
if (ref == null) {
|
||||||
|
nestedArtists = artists
|
||||||
|
} else {
|
||||||
|
artists += ref
|
||||||
|
nestedArtists = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearNestedArtists() {
|
||||||
|
nestedArtists = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addNestedArtist(attrs: Attributes) {
|
||||||
|
nestedArtists?.let { list -> attrs.artistRef()?.let { list += it } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toSong(): Song {
|
||||||
|
val refs = artistFallback(artists, artistId, artist)
|
||||||
|
return Song(
|
||||||
sourceId = sourceId,
|
sourceId = sourceId,
|
||||||
id = id,
|
id = id,
|
||||||
albumId = albumId,
|
albumId = albumId,
|
||||||
artistId = artistId,
|
artistId = artistId,
|
||||||
title = title,
|
title = title,
|
||||||
album = album,
|
album = album,
|
||||||
artist = artist,
|
artist = displayName(artist, displayArtist, refs),
|
||||||
duration = duration,
|
duration = duration,
|
||||||
track = track,
|
track = track,
|
||||||
disc = disc,
|
disc = disc,
|
||||||
@@ -211,14 +318,59 @@ internal class SongDraft(
|
|||||||
created = created,
|
created = created,
|
||||||
playCount = playCount,
|
playCount = playCount,
|
||||||
played = played,
|
played = played,
|
||||||
|
trackGain = trackGain,
|
||||||
|
albumGain = albumGain,
|
||||||
|
trackPeak = trackPeak,
|
||||||
|
albumPeak = albumPeak,
|
||||||
genres = genres.toList(),
|
genres = genres.toList(),
|
||||||
|
artists = refs,
|
||||||
|
sortName = sortName?.takeIf { it.isNotBlank() } ?: title,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Attributes.artistRef(): ArtistRef? {
|
||||||
|
val id = attr("id")
|
||||||
|
val name = attr("name")
|
||||||
|
if (id.isEmpty() && name.isEmpty()) return null
|
||||||
|
return ArtistRef(id, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun artistFallback(
|
||||||
|
parsed: List<ArtistRef>,
|
||||||
|
artistId: String?,
|
||||||
|
name: String?,
|
||||||
|
): List<ArtistRef> {
|
||||||
|
val refs = parsed.filter { it.id.isNotBlank() }.distinctBy { it.id }
|
||||||
|
if (refs.isNotEmpty()) return refs
|
||||||
|
if (artistId.isNullOrBlank()) return emptyList()
|
||||||
|
return listOf(ArtistRef(artistId, name.orEmpty()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val ARTIST_JOINER = " • "
|
||||||
|
|
||||||
|
private fun displayName(
|
||||||
|
legacy: String?,
|
||||||
|
openSubsonic: String?,
|
||||||
|
refs: List<ArtistRef>,
|
||||||
|
): String? =
|
||||||
|
legacy?.takeIf { it.isNotBlank() }
|
||||||
|
?: openSubsonic?.takeIf { it.isNotBlank() }
|
||||||
|
?: refs
|
||||||
|
.map { it.name }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
.joinToString(ARTIST_JOINER)
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
|
internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
|
||||||
|
|
||||||
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
|
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
|
||||||
|
|
||||||
|
internal fun Attributes.boolAttr(name: String): Boolean = attr(name).let { it == "1" || it.equals("true", ignoreCase = true) }
|
||||||
|
|
||||||
|
internal fun Attributes.floatAttr(name: String): Float? = attr(name).takeIf { it.isNotEmpty() }?.toFloatOrNull()?.takeIf { it.isFinite() }
|
||||||
|
|
||||||
internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
|
internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
|
||||||
|
|
||||||
private const val ROOT_TAG = "subsonic-response"
|
private const val ROOT_TAG = "subsonic-response"
|
||||||
@@ -280,10 +432,44 @@ private open class SubsonicResponseHandler : DefaultHandler() {
|
|||||||
) = Unit
|
) = Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class ArtistBiographyHandler : SubsonicResponseHandler() {
|
||||||
|
private var biographyDepth = -1
|
||||||
|
private val text = StringBuilder()
|
||||||
|
|
||||||
|
var biography: String? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun characters(
|
||||||
|
ch: CharArray,
|
||||||
|
start: Int,
|
||||||
|
length: Int,
|
||||||
|
) {
|
||||||
|
if (biographyDepth >= 0) text.append(ch, start, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onElementStart(
|
||||||
|
name: String,
|
||||||
|
attributes: Attributes,
|
||||||
|
depth: Int,
|
||||||
|
) {
|
||||||
|
if (name == "biography" && biographyDepth < 0) biographyDepth = depth
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onElementEnd(
|
||||||
|
name: String,
|
||||||
|
depth: Int,
|
||||||
|
) {
|
||||||
|
if (name == "biography" && depth == biographyDepth) {
|
||||||
|
biography = text.toString().trim().takeIf { it.isNotEmpty() }
|
||||||
|
biographyDepth = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class EntityHandler<T>(
|
private class EntityHandler<T>(
|
||||||
private val entityTag: String,
|
private val entityTag: String,
|
||||||
private val create: (Attributes) -> T,
|
private val create: (Attributes) -> T,
|
||||||
private val onChild: (T, String, Attributes) -> Unit,
|
private val onChild: (T, String, Attributes, Int) -> Unit,
|
||||||
private val onEnd: (T) -> Unit,
|
private val onEnd: (T) -> Unit,
|
||||||
private val stopAfterFirst: Boolean,
|
private val stopAfterFirst: Boolean,
|
||||||
) : SubsonicResponseHandler() {
|
) : SubsonicResponseHandler() {
|
||||||
@@ -304,8 +490,8 @@ private class EntityHandler<T>(
|
|||||||
current = create(attributes)
|
current = create(attributes)
|
||||||
entityDepth = depth
|
entityDepth = depth
|
||||||
}
|
}
|
||||||
} else if (depth == entityDepth + 1) {
|
} else if (depth > entityDepth) {
|
||||||
onChild(active, name, attributes)
|
onChild(active, name, attributes, depth - entityDepth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
|
import com.subtracks.data.net.NetworkMode
|
||||||
|
import com.subtracks.data.prefs.SyncMode
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
internal const val AUTO_SYNC_MAX_POLL_MS = 15L * 60 * 1000
|
||||||
|
|
||||||
|
internal fun shouldAutoSync(
|
||||||
|
mode: SyncMode,
|
||||||
|
network: NetworkMode,
|
||||||
|
offline: Boolean,
|
||||||
|
hasSource: Boolean,
|
||||||
|
lastSyncAt: Long,
|
||||||
|
now: Long,
|
||||||
|
intervalMs: Long,
|
||||||
|
): Boolean {
|
||||||
|
val allowed =
|
||||||
|
when (mode) {
|
||||||
|
SyncMode.Off -> false
|
||||||
|
SyncMode.WifiOnly -> network == NetworkMode.Wifi
|
||||||
|
SyncMode.Always -> true
|
||||||
|
}
|
||||||
|
return allowed && !offline && hasSource && (lastSyncAt > now || now - lastSyncAt >= intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
class AutoSync(
|
||||||
|
private val preferences: UserPreferences,
|
||||||
|
private val networkMode: Flow<NetworkMode>,
|
||||||
|
private val hasSource: suspend () -> Boolean,
|
||||||
|
private val requestSync: () -> Unit,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val now: () -> Long = System::currentTimeMillis,
|
||||||
|
private val maxPollMs: Long = AUTO_SYNC_MAX_POLL_MS,
|
||||||
|
) {
|
||||||
|
@Volatile
|
||||||
|
private var lastNetwork: NetworkMode? = null
|
||||||
|
private var loop: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch { networkMode.collect { lastNetwork = it } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setForeground(active: Boolean) {
|
||||||
|
if (active) start() else stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun start() {
|
||||||
|
if (loop?.isActive == true) return
|
||||||
|
loop =
|
||||||
|
scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
val intervalMs =
|
||||||
|
try {
|
||||||
|
checkAndRequest()
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (failure: Exception) {
|
||||||
|
Log.w("sync", "auto-sync check failed", failure)
|
||||||
|
maxPollMs
|
||||||
|
}
|
||||||
|
delay(intervalMs.coerceAtMost(maxPollMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stop() {
|
||||||
|
loop?.cancel()
|
||||||
|
loop = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun checkAndRequest(): Long {
|
||||||
|
val mode = preferences.syncMode().first()
|
||||||
|
val network = lastNetwork ?: networkMode.first()
|
||||||
|
val offline = preferences.offlineMode().first()
|
||||||
|
val hasSource = hasSource()
|
||||||
|
val lastSyncAt = preferences.lastSyncAt().first()
|
||||||
|
val intervalMs = preferences.syncIntervalMinutes().first() * 60_000L
|
||||||
|
val time = now()
|
||||||
|
val due =
|
||||||
|
shouldAutoSync(
|
||||||
|
mode = mode,
|
||||||
|
network = network,
|
||||||
|
offline = offline,
|
||||||
|
hasSource = hasSource,
|
||||||
|
lastSyncAt = lastSyncAt,
|
||||||
|
now = time,
|
||||||
|
intervalMs = intervalMs,
|
||||||
|
)
|
||||||
|
Log.d(
|
||||||
|
"sync",
|
||||||
|
"auto-sync due=$due (mode=$mode, network=$network, offline=$offline, hasSource=$hasSource, " +
|
||||||
|
"sinceLast=${time - lastSyncAt}ms, interval=${intervalMs}ms)",
|
||||||
|
)
|
||||||
|
if (due && currentCoroutineContext().isActive) requestSync()
|
||||||
|
return intervalMs
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,31 @@
|
|||||||
package com.subtracks.data.sync
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
import com.subtracks.data.source.subsonic.SubsonicException
|
import com.subtracks.data.source.subsonic.SubsonicException
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
sealed interface SyncStatus {
|
sealed interface SyncStatus {
|
||||||
data object Idle : SyncStatus
|
data object Idle : SyncStatus
|
||||||
|
|
||||||
data object Running : SyncStatus
|
data class Running(
|
||||||
|
val silent: Boolean,
|
||||||
|
) : SyncStatus
|
||||||
|
|
||||||
data object Success : SyncStatus
|
data object Success : SyncStatus
|
||||||
|
|
||||||
@@ -34,41 +39,65 @@ class SyncManager(
|
|||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val queueRepository: QueueRepository,
|
private val queueRepository: QueueRepository,
|
||||||
private val serverActions: ServerActionSink,
|
private val serverActions: ServerActionSink,
|
||||||
|
private val preferences: UserPreferences,
|
||||||
private val showMessage: (UiMessage) -> Unit = {},
|
private val showMessage: (UiMessage) -> Unit = {},
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||||
) {
|
) {
|
||||||
private val requests = Channel<Unit>(Channel.CONFLATED)
|
private val requests = Channel<Boolean>(Channel.CONFLATED)
|
||||||
private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)
|
private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)
|
||||||
val status: StateFlow<SyncStatus> = _status
|
val status: StateFlow<SyncStatus> = _status
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
for (ignored in requests) runSync()
|
for (silent in requests) {
|
||||||
|
try {
|
||||||
|
runSync(silent)
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
if (!currentCoroutineContext().isActive) throw cancellation
|
||||||
|
_status.value = SyncStatus.Idle
|
||||||
|
Log.w("sync", "sync interrupted", cancellation)
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
_status.value = SyncStatus.Idle
|
||||||
|
Log.w("sync", "unexpected failure", failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestSync() {
|
fun requestSync(silent: Boolean = false) {
|
||||||
requests.trySend(Unit)
|
requests.trySend(silent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun runSync() {
|
private suspend fun runSync(silent: Boolean) {
|
||||||
if (sourceRepository.offline.value) {
|
if (sourceRepository.offline.value) {
|
||||||
val offline = UiMessage(R.string.sync_offline)
|
if (!silent) {
|
||||||
showMessage(offline)
|
val offline = UiMessage(R.string.sync_offline)
|
||||||
_status.value = SyncStatus.Failed(offline)
|
showMessage(offline)
|
||||||
|
_status.value = SyncStatus.Failed(offline)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_status.value = SyncStatus.Running
|
_status.value = SyncStatus.Running(silent)
|
||||||
|
runCatching { preferences.setLastSyncAt(System.currentTimeMillis()) }
|
||||||
|
.onFailure { Log.w("sync", "could not record the sync attempt", it) }
|
||||||
|
val startedAt = System.nanoTime()
|
||||||
val result =
|
val result =
|
||||||
try {
|
try {
|
||||||
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
|
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
|
||||||
|
Log.i("sync", "started (source ${source.id})")
|
||||||
serverActions.flush()
|
serverActions.flush()
|
||||||
SyncService(db, source).sync()
|
val summary = SyncService(db, source).sync()
|
||||||
|
Log.i(
|
||||||
|
"sync",
|
||||||
|
"finished in ${elapsedMs(startedAt)}ms: " +
|
||||||
|
"${summary.artists} artists, ${summary.albums} albums, ${summary.songs} songs, " +
|
||||||
|
"${summary.playlists} playlists, ${summary.playlistSongs} playlist tracks, ${summary.pruned} pruned",
|
||||||
|
)
|
||||||
SyncStatus.Success
|
SyncStatus.Success
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (failure: Exception) {
|
} catch (failure: Exception) {
|
||||||
Log.w(TAG, "Sync failed", failure)
|
Log.w("sync", "failed after ${elapsedMs(startedAt)}ms", failure)
|
||||||
val message =
|
val message =
|
||||||
when (failure) {
|
when (failure) {
|
||||||
is NoServerException -> {
|
is NoServerException -> {
|
||||||
@@ -80,16 +109,14 @@ class SyncManager(
|
|||||||
?: UiMessage(R.string.sync_failed_detail, listOf(failure.message ?: ""))
|
?: UiMessage(R.string.sync_failed_detail, listOf(failure.message ?: ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
showMessage(message)
|
if (!silent) showMessage(message)
|
||||||
SyncStatus.Failed(message)
|
SyncStatus.Failed(message)
|
||||||
}
|
}
|
||||||
queueRepository.invalidateLibraryCache()
|
queueRepository.invalidateLibraryCache()
|
||||||
_status.value = result
|
_status.value = result
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private fun elapsedMs(startNanos: Long): Long = (System.nanoTime() - startNanos) / 1_000_000
|
||||||
const val TAG = "SubtracksSync"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private class NoServerException : IllegalStateException()
|
private class NoServerException : IllegalStateException()
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.subtracks.R
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class SyncNotifier(
|
||||||
|
private val context: Context,
|
||||||
|
private val status: Flow<SyncStatus>,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||||
|
) {
|
||||||
|
private val manager = NotificationManagerCompat.from(context)
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
createChannel()
|
||||||
|
scope.launch {
|
||||||
|
status.collect { syncStatus ->
|
||||||
|
if (syncStatus is SyncStatus.Running && manager.areNotificationsEnabled()) {
|
||||||
|
manager.notify(ID, build())
|
||||||
|
} else {
|
||||||
|
manager.cancel(ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun build(): Notification =
|
||||||
|
NotificationCompat
|
||||||
|
.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_name)
|
||||||
|
.setContentTitle(context.getString(R.string.sync_notification_title))
|
||||||
|
.setContentText(context.getString(R.string.sync_notification_text))
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setProgress(0, 0, true)
|
||||||
|
.setContentIntent(launchIntent())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun launchIntent(): PendingIntent? =
|
||||||
|
context.packageManager
|
||||||
|
.getLaunchIntentForPackage(context.packageName)
|
||||||
|
?.let { PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE) }
|
||||||
|
|
||||||
|
private fun createChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
context.getString(R.string.sync_notification_channel),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply { setShowBadge(false) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ID = 2
|
||||||
|
private const val CHANNEL_ID = "sync"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,16 @@ import androidx.room3.useWriterConnection
|
|||||||
import androidx.sqlite.SQLiteStatement
|
import androidx.sqlite.SQLiteStatement
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.AlbumArtistRef
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
|
import com.subtracks.data.model.ArtistRefsCodec
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.DiscKey
|
import com.subtracks.data.model.DiscKey
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.model.SongArtistRef
|
||||||
import com.subtracks.data.model.SongGenre
|
import com.subtracks.data.model.SongGenre
|
||||||
import com.subtracks.data.source.MusicSource
|
import com.subtracks.data.source.MusicSource
|
||||||
import java.util.Arrays
|
import java.util.Arrays
|
||||||
@@ -20,66 +24,120 @@ class SyncService(
|
|||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
private val source: MusicSource,
|
private val source: MusicSource,
|
||||||
) {
|
) {
|
||||||
suspend fun sync() {
|
private var artistsSynced = 0
|
||||||
syncArtists()
|
private var albumsSynced = 0
|
||||||
syncAlbums()
|
private var songsSynced = 0
|
||||||
syncSongs()
|
private var playlistsSynced = 0
|
||||||
aggregatePlayData()
|
private var playlistSongsSynced = 0
|
||||||
|
private var pruned = 0
|
||||||
|
|
||||||
|
suspend fun sync(): SyncSummary {
|
||||||
|
val sources = db.sourcesDao()
|
||||||
|
val wasStale = sources.creditsStale(source.id) ?: true
|
||||||
|
val artistsChanged = syncArtists()
|
||||||
|
val albumsChanged = syncAlbums()
|
||||||
|
val songsChanged = syncSongs()
|
||||||
|
optimize()
|
||||||
|
if (artistsChanged || albumsChanged || songsChanged || wasStale) {
|
||||||
|
recomputeAggregates()
|
||||||
|
sources.setCreditsStale(source.id, false)
|
||||||
|
}
|
||||||
syncPlaylists()
|
syncPlaylists()
|
||||||
syncPlaylistSongs()
|
syncPlaylistSongs()
|
||||||
|
return SyncSummary(
|
||||||
|
artists = artistsSynced,
|
||||||
|
albums = albumsSynced,
|
||||||
|
songs = songsSynced,
|
||||||
|
playlists = playlistsSynced,
|
||||||
|
playlistSongs = playlistSongsSynced,
|
||||||
|
pruned = pruned,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncArtists() {
|
private suspend fun syncArtists(): Boolean {
|
||||||
val library = db.libraryDao()
|
val library = db.libraryDao()
|
||||||
val seen = HashedIds()
|
val seen = HashedIds()
|
||||||
|
var changed = false
|
||||||
source.artists().collect { batch ->
|
source.artists().collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
write("artists", artistColumns, primaryKey, batch) { it.values() }
|
changed =
|
||||||
|
write("artists", artistColumns, primaryKey, batch, preserved = setOf("albumCount"), markStale = true) { it.values() } ||
|
||||||
|
changed
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
|
artistsSynced += batch.size
|
||||||
}
|
}
|
||||||
pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteArtists(source.id, it) })
|
val prunedBefore = pruned
|
||||||
|
pruned += pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteArtists(source.id, it) })
|
||||||
|
return changed || pruned > prunedBefore
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncAlbums() {
|
private suspend fun syncAlbums(): Boolean {
|
||||||
val library = db.libraryDao()
|
val library = db.libraryDao()
|
||||||
val seenAlbums = HashedIds()
|
val seenAlbums = HashedIds()
|
||||||
val seenDiscs = HashedIds()
|
val seenDiscs = HashedIds()
|
||||||
|
var changed = false
|
||||||
source.albums().collect { batch ->
|
source.albums().collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
val discs =
|
val discs =
|
||||||
batch.flatMap { album ->
|
batch.flatMap { album ->
|
||||||
album.discTitles.map { (disc, title) -> Disc(album.sourceId, album.id, disc, title) }
|
album.discTitles.map { (disc, title) -> Disc(album.sourceId, album.id, disc, title) }
|
||||||
}
|
}
|
||||||
writeAlbumsAndDiscs(batch, discs)
|
changed = writeAlbumsAndDiscs(batch, discs) || changed
|
||||||
batch.forEach { seenAlbums.add(idHash(it.id)) }
|
batch.forEach { seenAlbums.add(idHash(it.id)) }
|
||||||
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
|
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
|
||||||
|
albumsSynced += batch.size
|
||||||
}
|
}
|
||||||
pruneStaleIds(seenAlbums, { library.albumIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteAlbums(source.id, it) })
|
val prunedBefore = pruned
|
||||||
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
pruned +=
|
||||||
deleteStaleDiscs(it)
|
pruneStaleIds(
|
||||||
}
|
seenAlbums,
|
||||||
|
{ library.albumIdsAfter(source.id, it, PRUNE_PAGE) },
|
||||||
|
{ library.deleteAlbums(source.id, it) },
|
||||||
|
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
|
||||||
|
)
|
||||||
|
pruned +=
|
||||||
|
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
||||||
|
deleteStaleDiscs(it)
|
||||||
|
}
|
||||||
|
return changed || pruned > prunedBefore
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncSongs() {
|
private suspend fun syncSongs(): Boolean {
|
||||||
val library = db.libraryDao()
|
val library = db.libraryDao()
|
||||||
val seen = HashedIds()
|
val seen = HashedIds()
|
||||||
|
var changed = false
|
||||||
source.songs().collect { batch ->
|
source.songs().collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
writeSongs(batch)
|
changed = writeSongs(batch) || changed
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
|
songsSynced += batch.size
|
||||||
}
|
}
|
||||||
pruneStaleIds(seen, { library.songIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteSongs(source.id, it) })
|
val prunedBefore = pruned
|
||||||
|
pruned +=
|
||||||
|
pruneStaleIds(
|
||||||
|
seen,
|
||||||
|
{ library.songIdsAfter(source.id, it, PRUNE_PAGE) },
|
||||||
|
{ library.deleteSongs(source.id, it) },
|
||||||
|
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
|
||||||
|
)
|
||||||
|
return changed || pruned > prunedBefore
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun writeSongs(batch: List<Song>) {
|
private suspend fun writeSongs(batch: List<Song>): Boolean {
|
||||||
val names = batch.associate { it.id to it.genreNames() }
|
val names = batch.associate { it.id to it.genreNames() }
|
||||||
val genres =
|
val genres =
|
||||||
batch.flatMap { song ->
|
batch.flatMap { song ->
|
||||||
names.getValue(song.id).mapIndexed { index, genre -> SongGenre(song.sourceId, song.id, index.toLong(), genre) }
|
names.getValue(song.id).mapIndexed { index, genre -> SongGenre(song.sourceId, song.id, index.toLong(), genre) }
|
||||||
}
|
}
|
||||||
db.useWriterConnection { connection ->
|
val artists =
|
||||||
|
batch.flatMap { song ->
|
||||||
|
song.artistPositions().map { (position, ref) -> SongArtistRef(song.sourceId, song.id, position, ref.id, ref.name) }
|
||||||
|
}
|
||||||
|
return db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
connection.upsertChanged("songs", songColumns, primaryKey, batch) { it.values() }
|
val before = connection.totalChanges()
|
||||||
|
val resolved = connection.resolveSongSortNames(batch)
|
||||||
|
connection.upsertChanged("songs", songColumns, primaryKey, resolved) { it.values() }
|
||||||
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||||
batch.forEach { song ->
|
batch.forEach { song ->
|
||||||
statement.bind(1, song.sourceId)
|
statement.bind(1, song.sourceId)
|
||||||
@@ -91,12 +149,106 @@ class SyncService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() }
|
connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() }
|
||||||
|
connection.usePrepared("DELETE FROM song_artists WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||||
|
batch.forEach { song ->
|
||||||
|
statement.bind(1, song.sourceId)
|
||||||
|
statement.bind(2, song.id)
|
||||||
|
statement.bind(3, song.artistPositions().size.toLong())
|
||||||
|
statement.step()
|
||||||
|
statement.reset()
|
||||||
|
statement.clearBindings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.upsertChanged("song_artists", songArtistColumns, songArtistKey, artists) { it.values() }
|
||||||
|
(connection.totalChanges() != before).also { changed ->
|
||||||
|
if (changed) connection.markCreditsStale(batch.first().sourceId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun aggregatePlayData() {
|
private suspend fun recomputeAggregates() {
|
||||||
db.libraryDao().recomputePlayData(source.id)
|
db.libraryDao().recomputeAggregates(source.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.totalChanges(): Long {
|
||||||
|
var value = 0L
|
||||||
|
usePrepared("SELECT total_changes()") { statement ->
|
||||||
|
statement.step()
|
||||||
|
value = statement.getLong(0)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.markCreditsStale(sourceId: Long) {
|
||||||
|
usePrepared("UPDATE sources SET creditsStale = 1 WHERE id = ? AND creditsStale = 0") { statement ->
|
||||||
|
statement.bindLong(1, sourceId)
|
||||||
|
statement.step()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.resolveSongSortNames(batch: List<Song>): List<Song> {
|
||||||
|
if (batch.isEmpty()) return batch
|
||||||
|
val sortNames =
|
||||||
|
artistSortNames(
|
||||||
|
batch.first().sourceId,
|
||||||
|
batch
|
||||||
|
.flatMap { listOfNotNull(it.artistId) + it.artistRefs.map { ref -> ref.id } }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.toSet(),
|
||||||
|
)
|
||||||
|
return batch.map { song ->
|
||||||
|
val primary = song.artistId ?: song.artists.firstOrNull()?.id
|
||||||
|
val sortName =
|
||||||
|
primary?.let(sortNames::get)?.takeIf { it.isNotBlank() }
|
||||||
|
?: song.artists.firstNotNullOfOrNull { ref -> sortNames[ref.id]?.takeIf { it.isNotBlank() } }
|
||||||
|
?: song.artistSort
|
||||||
|
if (sortName == song.artistSort) song else song.copy(artistSort = sortName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.resolveAlbumSortNames(batch: List<Album>): List<Album> {
|
||||||
|
if (batch.isEmpty()) return batch
|
||||||
|
val sortNames =
|
||||||
|
artistSortNames(
|
||||||
|
batch.first().sourceId,
|
||||||
|
batch
|
||||||
|
.flatMap { listOfNotNull(it.artistId) + it.artistRefs.map { ref -> ref.id } }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.toSet(),
|
||||||
|
)
|
||||||
|
return batch.map { album ->
|
||||||
|
val primary = album.artistId ?: album.artists.firstOrNull()?.id
|
||||||
|
val sortName =
|
||||||
|
primary?.let(sortNames::get)?.takeIf { it.isNotBlank() }
|
||||||
|
?: album.artists.firstNotNullOfOrNull { ref -> sortNames[ref.id]?.takeIf { it.isNotBlank() } }
|
||||||
|
?: album.albumArtistSort
|
||||||
|
if (sortName == album.albumArtistSort) album else album.copy(albumArtistSort = sortName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.artistSortNames(
|
||||||
|
sourceId: Long,
|
||||||
|
ids: Collection<String>,
|
||||||
|
): Map<String, String> {
|
||||||
|
if (ids.isEmpty()) return emptyMap()
|
||||||
|
val sortNames = HashMap<String, String>(ids.size)
|
||||||
|
ids.toList().chunked(SQL_VARIABLES).forEach { chunk ->
|
||||||
|
val placeholders = chunk.joinToString(",") { "?" }
|
||||||
|
usePrepared("SELECT id, sortName FROM artists WHERE sourceId = ? AND id IN ($placeholders)") { statement ->
|
||||||
|
statement.bindLong(1, sourceId)
|
||||||
|
chunk.forEachIndexed { index, id -> statement.bindText(index + 2, id) }
|
||||||
|
while (statement.step()) sortNames[statement.getText(0)] = statement.getText(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sortNames
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without sqlite_stat1 the artist and relation queries can fall back to whole-table scans.
|
||||||
|
private suspend fun optimize() {
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.usePrepared("PRAGMA optimize") { it.step() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncPlaylists() {
|
private suspend fun syncPlaylists() {
|
||||||
@@ -106,8 +258,9 @@ class SyncService(
|
|||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
write("playlists", playlistColumns, primaryKey, batch) { it.values() }
|
write("playlists", playlistColumns, primaryKey, batch) { it.values() }
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
|
playlistsSynced += batch.size
|
||||||
}
|
}
|
||||||
pruneStaleIds(seen, { library.playlistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deletePlaylists(source.id, it) })
|
pruned += pruneStaleIds(seen, { library.playlistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deletePlaylists(source.id, it) })
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncPlaylistSongs() {
|
private suspend fun syncPlaylistSongs() {
|
||||||
@@ -118,6 +271,7 @@ class SyncService(
|
|||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
write("playlist_songs", playlistSongColumns, playlistSongKey, batch) { it.values() }
|
write("playlist_songs", playlistSongColumns, playlistSongKey, batch) { it.values() }
|
||||||
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
|
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
|
||||||
|
playlistSongsSynced += batch.size
|
||||||
}
|
}
|
||||||
playlistIds.forEach { library.deletePlaylistSongsFrom(source.id, it, counts[it] ?: 0L) }
|
playlistIds.forEach { library.deletePlaylistSongsFrom(source.id, it, counts[it] ?: 0L) }
|
||||||
library.deleteOrphanPlaylistSongs(source.id)
|
library.deleteOrphanPlaylistSongs(source.id)
|
||||||
@@ -129,23 +283,48 @@ class SyncService(
|
|||||||
columns: List<String>,
|
columns: List<String>,
|
||||||
key: List<String>,
|
key: List<String>,
|
||||||
rows: List<T>,
|
rows: List<T>,
|
||||||
|
preserved: Set<String> = emptySet(),
|
||||||
|
markStale: Boolean = false,
|
||||||
values: (T) -> List<Any?>,
|
values: (T) -> List<Any?>,
|
||||||
) {
|
): Boolean =
|
||||||
db.useWriterConnection { connection ->
|
db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
connection.upsertChanged(table, columns, key, rows, values)
|
val before = connection.totalChanges()
|
||||||
|
connection.upsertChanged(table, columns, key, rows, preserved, values)
|
||||||
|
(connection.totalChanges() != before).also { changed ->
|
||||||
|
if (changed && markStale) connection.markCreditsStale(source.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun writeAlbumsAndDiscs(
|
private suspend fun writeAlbumsAndDiscs(
|
||||||
albums: List<Album>,
|
albums: List<Album>,
|
||||||
discs: List<Disc>,
|
discs: List<Disc>,
|
||||||
) {
|
): Boolean {
|
||||||
db.useWriterConnection { connection ->
|
val artists =
|
||||||
|
albums.flatMap { album ->
|
||||||
|
album.artistPositions().map { (position, ref) -> AlbumArtistRef(album.sourceId, album.id, position, ref.id, ref.name) }
|
||||||
|
}
|
||||||
|
return db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
connection.upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() }
|
val before = connection.totalChanges()
|
||||||
|
val resolved = connection.resolveAlbumSortNames(albums)
|
||||||
|
connection.upsertChanged("albums", albumColumns, primaryKey, resolved) { it.values() }
|
||||||
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
|
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
|
||||||
|
connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement ->
|
||||||
|
albums.forEach { album ->
|
||||||
|
statement.bind(1, album.sourceId)
|
||||||
|
statement.bind(2, album.id)
|
||||||
|
statement.bind(3, album.artistPositions().size.toLong())
|
||||||
|
statement.step()
|
||||||
|
statement.reset()
|
||||||
|
statement.clearBindings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.upsertChanged("album_artists", albumArtistColumns, albumArtistKey, artists) { it.values() }
|
||||||
|
(connection.totalChanges() != before).also { changed ->
|
||||||
|
if (changed) connection.markCreditsStale(albums.first().sourceId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,15 +352,19 @@ class SyncService(
|
|||||||
seen: HashedIds,
|
seen: HashedIds,
|
||||||
pageOf: suspend (afterId: String) -> List<String>,
|
pageOf: suspend (afterId: String) -> List<String>,
|
||||||
delete: suspend (Collection<String>) -> Unit,
|
delete: suspend (Collection<String>) -> Unit,
|
||||||
) {
|
markStale: (suspend () -> Unit)? = null,
|
||||||
|
): Int {
|
||||||
seen.freeze()
|
seen.freeze()
|
||||||
var afterId = ""
|
var afterId = ""
|
||||||
|
var pruned = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
val ids = pageOf(afterId)
|
val ids = pageOf(afterId)
|
||||||
if (ids.isEmpty()) return
|
if (ids.isEmpty()) return pruned
|
||||||
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
||||||
|
if (stale.isNotEmpty()) markStale?.invoke()
|
||||||
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
||||||
if (ids.size < PRUNE_PAGE) return
|
pruned += stale.size
|
||||||
|
if (ids.size < PRUNE_PAGE) return pruned
|
||||||
afterId = ids.last()
|
afterId = ids.last()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,23 +373,35 @@ class SyncService(
|
|||||||
seen: HashedIds,
|
seen: HashedIds,
|
||||||
pageOf: suspend (afterAlbumId: String, afterDisc: Long) -> List<DiscKey>,
|
pageOf: suspend (afterAlbumId: String, afterDisc: Long) -> List<DiscKey>,
|
||||||
delete: suspend (Collection<DiscKey>) -> Unit,
|
delete: suspend (Collection<DiscKey>) -> Unit,
|
||||||
) {
|
): Int {
|
||||||
seen.freeze()
|
seen.freeze()
|
||||||
var afterAlbumId = ""
|
var afterAlbumId = ""
|
||||||
var afterDisc = 0L
|
var afterDisc = 0L
|
||||||
|
var pruned = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
val keys = pageOf(afterAlbumId, afterDisc)
|
val keys = pageOf(afterAlbumId, afterDisc)
|
||||||
if (keys.isEmpty()) return
|
if (keys.isEmpty()) return pruned
|
||||||
val stale = keys.filterNot { seen.contains(discHash(it.albumId, it.disc)) }
|
val stale = keys.filterNot { seen.contains(discHash(it.albumId, it.disc)) }
|
||||||
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
||||||
if (keys.size < PRUNE_PAGE) return
|
pruned += stale.size
|
||||||
|
if (keys.size < PRUNE_PAGE) return pruned
|
||||||
afterAlbumId = keys.last().albumId
|
afterAlbumId = keys.last().albumId
|
||||||
afterDisc = keys.last().disc
|
afterDisc = keys.last().disc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class SyncSummary(
|
||||||
|
val artists: Int,
|
||||||
|
val albums: Int,
|
||||||
|
val songs: Int,
|
||||||
|
val playlists: Int,
|
||||||
|
val playlistSongs: Int,
|
||||||
|
val pruned: Int,
|
||||||
|
)
|
||||||
|
|
||||||
private const val DELETE_CHUNK = 500
|
private const val DELETE_CHUNK = 500
|
||||||
|
private const val SQL_VARIABLES = 500
|
||||||
private const val PRUNE_PAGE = 500
|
private const val PRUNE_PAGE = 500
|
||||||
private const val INITIAL_HASH_CAPACITY = 256
|
private const val INITIAL_HASH_CAPACITY = 256
|
||||||
private const val FNV_OFFSET = -3750763034362895579L
|
private const val FNV_OFFSET = -3750763034362895579L
|
||||||
@@ -243,7 +438,7 @@ private fun discHash(
|
|||||||
|
|
||||||
private val primaryKey = listOf("sourceId", "id")
|
private val primaryKey = listOf("sourceId", "id")
|
||||||
|
|
||||||
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt")
|
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "artistImageUrl", "sortName")
|
||||||
|
|
||||||
private val albumColumns =
|
private val albumColumns =
|
||||||
listOf(
|
listOf(
|
||||||
@@ -258,6 +453,9 @@ private val albumColumns =
|
|||||||
"year",
|
"year",
|
||||||
"starred",
|
"starred",
|
||||||
"songCount",
|
"songCount",
|
||||||
|
"artists",
|
||||||
|
"sortName",
|
||||||
|
"albumArtistSort",
|
||||||
)
|
)
|
||||||
|
|
||||||
private val discKey = listOf("sourceId", "albumId", "disc")
|
private val discKey = listOf("sourceId", "albumId", "disc")
|
||||||
@@ -280,9 +478,17 @@ private val songColumns =
|
|||||||
"created",
|
"created",
|
||||||
"playCount",
|
"playCount",
|
||||||
"played",
|
"played",
|
||||||
|
"trackGain",
|
||||||
|
"albumGain",
|
||||||
|
"trackPeak",
|
||||||
|
"albumPeak",
|
||||||
|
"artists",
|
||||||
|
"sortName",
|
||||||
|
"artistSort",
|
||||||
)
|
)
|
||||||
|
|
||||||
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
|
private val playlistColumns =
|
||||||
|
listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration", "owner", "shared")
|
||||||
|
|
||||||
private fun Song.genreNames(): List<String> =
|
private fun Song.genreNames(): List<String> =
|
||||||
genres
|
genres
|
||||||
@@ -298,13 +504,41 @@ private val songGenreColumns = listOf("sourceId", "songId", "position", "genre")
|
|||||||
|
|
||||||
private val songGenreKey = listOf("sourceId", "songId", "position")
|
private val songGenreKey = listOf("sourceId", "songId", "position")
|
||||||
|
|
||||||
|
private val albumArtistColumns = listOf("sourceId", "albumId", "position", "artistId", "name")
|
||||||
|
|
||||||
|
private val albumArtistKey = listOf("sourceId", "albumId", "position")
|
||||||
|
|
||||||
|
private val songArtistColumns = listOf("sourceId", "songId", "position", "artistId", "name")
|
||||||
|
|
||||||
|
private val songArtistKey = listOf("sourceId", "songId", "position")
|
||||||
|
|
||||||
private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
|
private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
|
||||||
|
|
||||||
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
||||||
|
|
||||||
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt)
|
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, artistImageUrl, sortName)
|
||||||
|
|
||||||
private fun Album.values() = listOf<Any?>(sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount)
|
private fun Album.values() =
|
||||||
|
listOf<Any?>(
|
||||||
|
sourceId,
|
||||||
|
id,
|
||||||
|
artistId,
|
||||||
|
name,
|
||||||
|
albumArtist,
|
||||||
|
created,
|
||||||
|
coverArt,
|
||||||
|
genre,
|
||||||
|
year,
|
||||||
|
starred,
|
||||||
|
songCount,
|
||||||
|
ArtistRefsCodec.encode(artistRefs),
|
||||||
|
sortName,
|
||||||
|
albumArtistSort,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun AlbumArtistRef.values() = listOf<Any?>(sourceId, albumId, position, artistId, name)
|
||||||
|
|
||||||
|
private fun SongArtistRef.values() = listOf<Any?>(sourceId, songId, position, artistId, name)
|
||||||
|
|
||||||
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
|
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
|
||||||
|
|
||||||
@@ -324,11 +558,27 @@ private fun Song.values() =
|
|||||||
created,
|
created,
|
||||||
playCount,
|
playCount,
|
||||||
played,
|
played,
|
||||||
|
trackGain,
|
||||||
|
albumGain,
|
||||||
|
trackPeak,
|
||||||
|
albumPeak,
|
||||||
|
ArtistRefsCodec.encode(artistRefs),
|
||||||
|
sortName,
|
||||||
|
artistSort,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun List<ArtistRef>.positions(): List<Pair<Long, ArtistRef>> =
|
||||||
|
filter { it.id.isNotBlank() }
|
||||||
|
.distinctBy { it.id }
|
||||||
|
.mapIndexed { index, ref -> index.toLong() to ref }
|
||||||
|
|
||||||
|
private fun Album.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
|
||||||
|
|
||||||
|
private fun Song.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
|
||||||
|
|
||||||
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
|
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
|
||||||
|
|
||||||
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
|
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration, owner, shared)
|
||||||
|
|
||||||
private fun PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position)
|
private fun PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position)
|
||||||
|
|
||||||
@@ -337,10 +587,12 @@ private suspend fun <T> PooledConnection.upsertChanged(
|
|||||||
columns: List<String>,
|
columns: List<String>,
|
||||||
key: List<String>,
|
key: List<String>,
|
||||||
rows: List<T>,
|
rows: List<T>,
|
||||||
|
preserved: Set<String> = emptySet(),
|
||||||
values: (T) -> List<Any?>,
|
values: (T) -> List<Any?>,
|
||||||
) {
|
) {
|
||||||
if (rows.isEmpty()) return
|
if (rows.isEmpty()) return
|
||||||
val updates = columns.filterNot { it in key }
|
check(values(rows.first()).size == columns.size) { "column/value mismatch for $table" }
|
||||||
|
val updates = columns.filterNot { it in key || it in preserved }
|
||||||
val sql =
|
val sql =
|
||||||
"INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " +
|
"INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " +
|
||||||
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
|
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
|
||||||
@@ -367,6 +619,7 @@ private fun SQLiteStatement.bind(
|
|||||||
is Int -> bindLong(index, value.toLong())
|
is Int -> bindLong(index, value.toLong())
|
||||||
is Boolean -> bindLong(index, if (value) 1L else 0L)
|
is Boolean -> bindLong(index, if (value) 1L else 0L)
|
||||||
is Double -> bindDouble(index, value)
|
is Double -> bindDouble(index, value)
|
||||||
|
is Float -> bindDouble(index, value.toDouble())
|
||||||
is String -> bindText(index, value)
|
is String -> bindText(index, value)
|
||||||
else -> error("Cannot bind ${value::class}")
|
else -> error("Cannot bind ${value::class}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ import com.subtracks.data.repo.PendingServerActionSink
|
|||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
|
import com.subtracks.data.sync.AutoSync
|
||||||
import com.subtracks.data.sync.SyncManager
|
import com.subtracks.data.sync.SyncManager
|
||||||
|
import com.subtracks.data.sync.SyncNotifier
|
||||||
|
import com.subtracks.log.Log
|
||||||
import com.subtracks.playback.MediaSessionConnection
|
import com.subtracks.playback.MediaSessionConnection
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import com.subtracks.playback.PlayerConnection
|
import com.subtracks.playback.PlayerConnection
|
||||||
@@ -48,6 +51,8 @@ import com.subtracks.ui.settings.SettingsViewModel
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koin.core.module.dsl.viewModel
|
import org.koin.core.module.dsl.viewModel
|
||||||
import org.koin.core.qualifier.named
|
import org.koin.core.qualifier.named
|
||||||
@@ -68,6 +73,7 @@ fun appModule(
|
|||||||
single { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
|
single { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
|
||||||
single(named("io")) { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
single(named("io")) { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||||
single(named("playback")) { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) }
|
single(named("playback")) { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) }
|
||||||
|
single(createdAtStart = true) { get<UserPreferences>().verboseLogging().onEach(Log::setVerbose).launchIn(get()) }
|
||||||
single {
|
single {
|
||||||
SourceRepository(
|
SourceRepository(
|
||||||
get(),
|
get(),
|
||||||
@@ -90,9 +96,18 @@ fun appModule(
|
|||||||
scope = get(),
|
scope = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) }
|
single { LibraryRepository(get(), get(), get(), get(), toast, scope = get(named("io"))) }
|
||||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||||
single { SyncManager(get(), get(), get(), get(), showMessage = toast, scope = get()) }
|
single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) }
|
||||||
|
single {
|
||||||
|
AutoSync(
|
||||||
|
preferences = get(),
|
||||||
|
networkMode = networkMode(context.applicationContext),
|
||||||
|
hasSource = { get<SourceRepository>().activeSourceIdOnce() != null },
|
||||||
|
requestSync = { get<SyncManager>().requestSync(silent = true) },
|
||||||
|
scope = get(),
|
||||||
|
)
|
||||||
|
}
|
||||||
single { QueueRepository(get()) }
|
single { QueueRepository(get()) }
|
||||||
single { ArtworkStore(downloadsRoot(context)) }
|
single { ArtworkStore(downloadsRoot(context)) }
|
||||||
single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) }
|
single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) }
|
||||||
@@ -111,6 +126,7 @@ fun appModule(
|
|||||||
).also { it.start() }
|
).also { it.start() }
|
||||||
}
|
}
|
||||||
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } }
|
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } }
|
||||||
|
single(createdAtStart = true) { SyncNotifier(context.applicationContext, get<SyncManager>().status, scope = get()).also { it.start() } }
|
||||||
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
|
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
|
||||||
single { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
|
single { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
|
||||||
single(createdAtStart = true) {
|
single(createdAtStart = true) {
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package com.subtracks.log
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.subtracks.BuildConfig
|
||||||
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.db.SqlExecution
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
import android.util.Log as AndroidLog
|
||||||
|
|
||||||
|
object Log {
|
||||||
|
private const val SLOW_QUERY_MS = 100.0
|
||||||
|
private const val MAX_SQL_CHARS = 500
|
||||||
|
private val WHITESPACE = Regex("\\s+")
|
||||||
|
|
||||||
|
private var fileStore: LogFileStore? = null
|
||||||
|
private var appContext: Context? = null
|
||||||
|
private var consoleLevel = LogLevel.DEBUG
|
||||||
|
private var defaultFileLevel = LogLevel.INFO
|
||||||
|
private var crashHandlerInstalled = false
|
||||||
|
|
||||||
|
fun init(context: Context) {
|
||||||
|
val app = context.applicationContext
|
||||||
|
appContext = app
|
||||||
|
consoleLevel = if (BuildConfig.DEBUG) LogLevel.VERBOSE else LogLevel.INFO
|
||||||
|
defaultFileLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.INFO
|
||||||
|
fileStore?.shutdown()
|
||||||
|
fileStore = LogFileStore(File(app.filesDir, "logs"), defaultFileLevel)
|
||||||
|
installCrashHandler()
|
||||||
|
i("app", "started ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) on Android ${Build.VERSION.SDK_INT} ${Build.MODEL}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setVerbose(enabled: Boolean) {
|
||||||
|
fileStore?.setMinLevel(if (enabled) LogLevel.VERBOSE else defaultFileLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun v(
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
) = log(LogLevel.VERBOSE, tag, message, null)
|
||||||
|
|
||||||
|
fun d(
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
) = log(LogLevel.DEBUG, tag, message, null)
|
||||||
|
|
||||||
|
fun i(
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
) = log(LogLevel.INFO, tag, message, null)
|
||||||
|
|
||||||
|
fun w(
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable? = null,
|
||||||
|
) = log(LogLevel.WARN, tag, message, throwable)
|
||||||
|
|
||||||
|
fun e(
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable? = null,
|
||||||
|
) = log(LogLevel.ERROR, tag, message, throwable)
|
||||||
|
|
||||||
|
fun exportZip(): File? {
|
||||||
|
val store = fileStore ?: return null
|
||||||
|
val context = appContext ?: return null
|
||||||
|
store.flush()
|
||||||
|
return store.exportZip(File(context.cacheDir, "log_dumps"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun share(
|
||||||
|
context: Context,
|
||||||
|
file: File,
|
||||||
|
) {
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
val send =
|
||||||
|
Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "application/zip"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.settings_about_share_logs_subject))
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
context.startActivity(
|
||||||
|
Intent.createChooser(send, context.getString(R.string.settings_about_actions_share_logs)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interceptor(): Interceptor =
|
||||||
|
Interceptor { chain ->
|
||||||
|
val request = chain.request()
|
||||||
|
v("http", "${request.method} ${request.url}")
|
||||||
|
try {
|
||||||
|
val response = chain.proceed(request)
|
||||||
|
if (response.code >= 400) {
|
||||||
|
w("http", "${request.method} ${request.url} -> ${response.code}")
|
||||||
|
}
|
||||||
|
response
|
||||||
|
} catch (failure: IOException) {
|
||||||
|
if (chain.call().isCanceled()) {
|
||||||
|
v("http", "${request.method} ${request.url} cancelled")
|
||||||
|
} else {
|
||||||
|
w("http", "${request.method} ${request.url} failed", failure)
|
||||||
|
}
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sql(execution: SqlExecution) {
|
||||||
|
val failure = execution.error
|
||||||
|
if (failure != null) {
|
||||||
|
if (isUpsertConflict(execution.sql, failure)) {
|
||||||
|
v("sql", "upsert fallback conflict: ${shortSql(execution.sql)}")
|
||||||
|
} else {
|
||||||
|
e("sql", "failed: ${shortSql(execution.sql)}", failure)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isSlowQuery(execution, SLOW_QUERY_MS)) return
|
||||||
|
val rows = if (isReadStatement(execution.sql)) " rows=${execution.rows}" else ""
|
||||||
|
w("sql", "slow ${execution.durationMillis.roundToLong()}ms$rows: ${shortSql(execution.sql)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun log(
|
||||||
|
level: LogLevel,
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable?,
|
||||||
|
) {
|
||||||
|
val toConsole = level.ordinal >= consoleLevel.ordinal
|
||||||
|
val store = fileStore
|
||||||
|
if (!toConsole && store?.willLog(level) != true) return
|
||||||
|
val redacted = redactLogs(message)
|
||||||
|
if (toConsole) {
|
||||||
|
// Logging is best effort: a logcat write must never disturb the caller.
|
||||||
|
runCatching {
|
||||||
|
val text =
|
||||||
|
if (throwable != null) {
|
||||||
|
"$redacted\n${redactLogs(AndroidLog.getStackTraceString(throwable))}"
|
||||||
|
} else {
|
||||||
|
redacted
|
||||||
|
}
|
||||||
|
AndroidLog.println(priority(level), tag.take(23), text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store?.write(level, tag, redacted, throwable)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun priority(level: LogLevel): Int =
|
||||||
|
when (level) {
|
||||||
|
LogLevel.VERBOSE -> AndroidLog.VERBOSE
|
||||||
|
LogLevel.DEBUG -> AndroidLog.DEBUG
|
||||||
|
LogLevel.INFO -> AndroidLog.INFO
|
||||||
|
LogLevel.WARN -> AndroidLog.WARN
|
||||||
|
LogLevel.ERROR -> AndroidLog.ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installCrashHandler() {
|
||||||
|
if (crashHandlerInstalled) return
|
||||||
|
crashHandlerInstalled = true
|
||||||
|
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
|
runCatching {
|
||||||
|
fileStore?.writeImmediately(LogLevel.ERROR, "crash", "uncaught exception on ${thread.name}", throwable)
|
||||||
|
}
|
||||||
|
previous?.uncaughtException(thread, throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shortSql(sql: String): String = sql.replace(WHITESPACE, " ").take(MAX_SQL_CHARS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DATA_KEYWORDS = setOf("SELECT", "INSERT", "UPDATE", "DELETE", "WITH", "REPLACE")
|
||||||
|
private val READ_KEYWORDS = setOf("SELECT", "WITH")
|
||||||
|
private const val SQL_INTERNAL = "room_table_modification_log"
|
||||||
|
|
||||||
|
internal fun isDataStatement(sql: String): Boolean {
|
||||||
|
val normalized = sql.trimStart()
|
||||||
|
if (normalized.contains(SQL_INTERNAL)) return false
|
||||||
|
return normalized.takeWhile { !it.isWhitespace() }.uppercase() in DATA_KEYWORDS
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isReadStatement(sql: String): Boolean = sql.trimStart().takeWhile { !it.isWhitespace() }.uppercase() in READ_KEYWORDS
|
||||||
|
|
||||||
|
internal fun isSlowQuery(
|
||||||
|
execution: SqlExecution,
|
||||||
|
thresholdMs: Double,
|
||||||
|
): Boolean = execution.error == null && execution.durationMillis >= thresholdMs && isDataStatement(execution.sql)
|
||||||
|
|
||||||
|
// Room's EntityUpsertAdapter implements @Upsert as an INSERT and treats a uniqueness conflict as the
|
||||||
|
// signal to UPDATE instead (EntityUpsertAdapter.checkUniquenessException). Only downgrade a failed
|
||||||
|
// INSERT whose error says the conflict was a unique/primary key; anything else stays an ERROR so a
|
||||||
|
// genuine failure isn't hidden in release.
|
||||||
|
private const val CONSTRAINT_UNIQUE_MESSAGE = "unique constraint failed"
|
||||||
|
private const val CONSTRAINT_PRIMARY_KEY_MESSAGE = "primary key constraint failed"
|
||||||
|
|
||||||
|
internal fun isUpsertConflict(
|
||||||
|
sql: String,
|
||||||
|
error: Throwable,
|
||||||
|
): Boolean {
|
||||||
|
if (!sql.trimStart().uppercase().startsWith("INSERT")) return false
|
||||||
|
val message = error.message?.lowercase() ?: return false
|
||||||
|
return message.contains(CONSTRAINT_UNIQUE_MESSAGE) || message.contains(CONSTRAINT_PRIMARY_KEY_MESSAGE)
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package com.subtracks.log
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
enum class LogLevel { VERBOSE, DEBUG, INFO, WARN, ERROR }
|
||||||
|
|
||||||
|
class LogFileStore(
|
||||||
|
private val rootDir: File,
|
||||||
|
@Volatile private var minLevel: LogLevel,
|
||||||
|
private val maxFileBytes: Long = DEFAULT_MAX_FILE_BYTES,
|
||||||
|
private val maxFiles: Int = DEFAULT_MAX_FILES,
|
||||||
|
) {
|
||||||
|
private val executor =
|
||||||
|
Executors.newSingleThreadExecutor { runnable ->
|
||||||
|
Thread(runnable, "log-writer").apply { isDaemon = true }
|
||||||
|
}
|
||||||
|
private val lock = Any()
|
||||||
|
private val pending = AtomicInteger()
|
||||||
|
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||||
|
private val lineFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
|
||||||
|
private var current = currentFile()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Log.init runs in Application.onCreate, so clean up leftovers on the writer thread.
|
||||||
|
runCatching { executor.execute { synchronized(lock) { prune() } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun willLog(level: LogLevel): Boolean = level.ordinal >= minLevel.ordinal
|
||||||
|
|
||||||
|
fun write(
|
||||||
|
level: LogLevel,
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable?,
|
||||||
|
) {
|
||||||
|
if (level.ordinal < minLevel.ordinal) return
|
||||||
|
// Drop under pressure rather than grow the queue without bound (verbose logs every request).
|
||||||
|
if (pending.get() >= MAX_PENDING) return
|
||||||
|
pending.incrementAndGet()
|
||||||
|
runCatching {
|
||||||
|
executor.execute {
|
||||||
|
try {
|
||||||
|
synchronized(lock) { append(level, tag, message, throwable) }
|
||||||
|
} finally {
|
||||||
|
pending.decrementAndGet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { pending.decrementAndGet() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeImmediately(
|
||||||
|
level: LogLevel,
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable?,
|
||||||
|
) {
|
||||||
|
if (level.ordinal < minLevel.ordinal) return
|
||||||
|
runCatching { synchronized(lock) { append(level, tag, message, throwable) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flush() {
|
||||||
|
runCatching { executor.submit {}.get() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMinLevel(level: LogLevel) {
|
||||||
|
minLevel = level
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shutdown() {
|
||||||
|
executor.shutdownNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun files(): List<File> =
|
||||||
|
rootDir
|
||||||
|
.listFiles { file -> file.isFile && file.name.endsWith(LOG_SUFFIX) }
|
||||||
|
?.sortedByDescending { it.lastModified() }
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
fun exportZip(destDir: File): File? =
|
||||||
|
synchronized(lock) {
|
||||||
|
val logs = files()
|
||||||
|
if (logs.isEmpty()) return null
|
||||||
|
destDir.mkdirs()
|
||||||
|
destDir.listFiles()?.forEach { if (it.isFile) it.delete() }
|
||||||
|
val zip = File(destDir, "subtracks-logs-${System.currentTimeMillis()}.zip")
|
||||||
|
ZipOutputStream(zip.outputStream().buffered()).use { out ->
|
||||||
|
logs.forEach { log ->
|
||||||
|
if (!log.isFile) return@forEach
|
||||||
|
out.putNextEntry(ZipEntry(log.name))
|
||||||
|
log.inputStream().use { it.copyTo(out) }
|
||||||
|
out.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zip
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun append(
|
||||||
|
level: LogLevel,
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable?,
|
||||||
|
) {
|
||||||
|
val text =
|
||||||
|
buildString {
|
||||||
|
append(lineFormat.format(Date()))
|
||||||
|
append(' ')
|
||||||
|
append(LEVEL_CHARS[level.ordinal])
|
||||||
|
append(' ')
|
||||||
|
append(tag)
|
||||||
|
append(' ')
|
||||||
|
append(message)
|
||||||
|
throwable?.let {
|
||||||
|
append('\n')
|
||||||
|
append(it.stackTraceToString().trimEnd())
|
||||||
|
}
|
||||||
|
append('\n')
|
||||||
|
}
|
||||||
|
rootDir.mkdirs()
|
||||||
|
var rolled = false
|
||||||
|
val today = currentFile()
|
||||||
|
if (current.name != today.name) {
|
||||||
|
current = today
|
||||||
|
rolled = true
|
||||||
|
}
|
||||||
|
if (current.length() >= maxFileBytes) {
|
||||||
|
roll()
|
||||||
|
rolled = true
|
||||||
|
}
|
||||||
|
current.appendText(redactLogs(text))
|
||||||
|
if (rolled) prune()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rollSequence = 0
|
||||||
|
|
||||||
|
private fun roll() {
|
||||||
|
if (current.exists()) {
|
||||||
|
current.renameTo(uniqueRolledFile())
|
||||||
|
}
|
||||||
|
current = currentFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun uniqueRolledFile(): File {
|
||||||
|
val day = dateFormat.format(Date())
|
||||||
|
while (true) {
|
||||||
|
val candidate = File(rootDir, "$day-${rollSequence++}$LOG_SUFFIX")
|
||||||
|
if (!candidate.exists()) return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prune() {
|
||||||
|
// Never prune the file we are writing to, and never keep fewer than one rolled file slot.
|
||||||
|
val rolled = files().filterNot { it == current }
|
||||||
|
if (rolled.size > maxFiles - 1) rolled.drop(maxFiles - 1).forEach { it.delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentFile(): File = File(rootDir, dateFormat.format(Date()) + LOG_SUFFIX)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_MAX_FILE_BYTES = 1L * 1024 * 1024
|
||||||
|
const val DEFAULT_MAX_FILES = 7
|
||||||
|
private const val MAX_PENDING = 4096
|
||||||
|
private const val LOG_SUFFIX = ".log"
|
||||||
|
private val LEVEL_CHARS = charArrayOf('V', 'D', 'I', 'W', 'E')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val URL_USERINFO = Regex("([a-zA-Z][a-zA-Z0-9+.\\-]*://)[^/@\\s]+@")
|
||||||
|
private val URL_PARAM = Regex("([?&](?:u|p|s|t)=)[^&\\s]+")
|
||||||
|
|
||||||
|
internal fun redactLogs(input: String): String = input.replace(URL_USERINFO, "$1REDACTED@").replace(URL_PARAM, "$1REDACTED")
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.ForwardingPlayer
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports a track whose duration is known from its metadata as finite rather than live.
|
||||||
|
*
|
||||||
|
* Media3 marks a progressive stream live when the data source reports no length and the extractor
|
||||||
|
* no duration. The app declares a length only for a transcode of a track that has one in a
|
||||||
|
* length-estimable format, so a chunked response stays live for an Opus/Ogg/Vorbis transcode, for
|
||||||
|
* a transcode of a track whose library duration is missing, and for a direct play the server does
|
||||||
|
* not give a Content-Length. A live item makes the media session publish no playback position, so
|
||||||
|
* the notification loses its progress even though the item carries a real duration. The stream is
|
||||||
|
* still not seekable; this only restores the position the session reports.
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
internal class KnownDurationPlayer(
|
||||||
|
player: Player,
|
||||||
|
) : ForwardingPlayer(player) {
|
||||||
|
override fun isCurrentMediaItemLive(): Boolean = if (declaredDurationMs() != null) false else super.isCurrentMediaItemLive()
|
||||||
|
|
||||||
|
private fun declaredDurationMs(): Long? =
|
||||||
|
currentMediaItem
|
||||||
|
?.mediaMetadata
|
||||||
|
?.durationMs
|
||||||
|
?.takeIf { it > 0 }
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.Format
|
import androidx.media3.common.Format
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
@@ -13,9 +12,12 @@ import androidx.media3.common.util.UnstableApi
|
|||||||
import androidx.media3.session.MediaController
|
import androidx.media3.session.MediaController
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
|
import com.subtracks.data.model.ArtistRefsCodec
|
||||||
import com.subtracks.data.model.AudioEncoding
|
import com.subtracks.data.model.AudioEncoding
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
|
||||||
class Media3PlayerHandle(
|
class Media3PlayerHandle(
|
||||||
private val controller: MediaController,
|
private val controller: MediaController,
|
||||||
@@ -112,14 +114,19 @@ class Media3PlayerHandle(
|
|||||||
) = controller.replaceMediaItem(index, toMediaItem(item))
|
) = controller.replaceMediaItem(index, toMediaItem(item))
|
||||||
|
|
||||||
override fun seekToIndex(index: Int) {
|
override fun seekToIndex(index: Int) {
|
||||||
Log.d(TAG, "seekToIndex($index) playerDuration=${controller.duration} state=${controller.playbackState}")
|
Log.d(
|
||||||
|
"playback",
|
||||||
|
"seekToIndex($index) position=${controller.currentPosition} duration=${duration(
|
||||||
|
controller.duration,
|
||||||
|
)} state=${controller.playbackState}",
|
||||||
|
)
|
||||||
controller.seekTo(index, 0)
|
controller.seekTo(index, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun seekTo(positionMs: Long) {
|
override fun seekTo(positionMs: Long) {
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
"playback",
|
||||||
"seekTo(${positionMs}ms) from=${controller.currentPosition} playerDuration=${controller.duration} " +
|
"seekTo(${positionMs}ms) from=${controller.currentPosition} duration=${duration(controller.duration)} " +
|
||||||
"itemDuration=${currentItem?.durationMs} state=${controller.playbackState}",
|
"itemDuration=${currentItem?.durationMs} state=${controller.playbackState}",
|
||||||
)
|
)
|
||||||
controller.seekTo(positionMs)
|
controller.seekTo(positionMs)
|
||||||
@@ -136,7 +143,7 @@ class Media3PlayerHandle(
|
|||||||
mediaItem: MediaItem?,
|
mediaItem: MediaItem?,
|
||||||
reason: Int,
|
reason: Int,
|
||||||
) {
|
) {
|
||||||
Log.d(TAG, "item=${mediaItem?.mediaId}")
|
Log.i("playback", "${describe(mediaItem)} transition=${transitionReason(reason)}")
|
||||||
listener.onTransition()
|
listener.onTransition()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,8 +154,9 @@ class Media3PlayerHandle(
|
|||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
"playback",
|
||||||
"Playback error ${error.errorCodeName} at ${controller.currentPosition}/${controller.duration}",
|
"error ${error.errorCodeName} ${describe(controller.currentMediaItem)} " +
|
||||||
|
"position=${controller.currentPosition} duration=${duration(controller.duration)}",
|
||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
listener.onError(playbackErrorMessage(error))
|
listener.onError(playbackErrorMessage(error))
|
||||||
@@ -156,15 +164,54 @@ class Media3PlayerHandle(
|
|||||||
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
"playback",
|
||||||
"state=$playbackState position=${controller.currentPosition} " +
|
"state=$playbackState position=${controller.currentPosition} " +
|
||||||
"playerDuration=${controller.duration} itemDuration=${currentItem?.durationMs}",
|
"duration=${duration(controller.duration)} itemDuration=${currentItem?.durationMs}",
|
||||||
)
|
)
|
||||||
|
if (playbackState == Player.STATE_ENDED) {
|
||||||
|
Log.i("playback", "ended ${describe(controller.currentMediaItem)}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun describe(mediaItem: MediaItem?): String {
|
||||||
|
if (mediaItem == null) return "item=none"
|
||||||
|
val metadata = mediaItem.mediaMetadata
|
||||||
|
val artist =
|
||||||
|
metadata.artist
|
||||||
|
?.toString()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { " artist=\"$it\"" }
|
||||||
|
.orEmpty()
|
||||||
|
val album =
|
||||||
|
metadata.albumTitle
|
||||||
|
?.toString()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { " album=\"$it\"" }
|
||||||
|
.orEmpty()
|
||||||
|
val source =
|
||||||
|
when (mediaItem.localConfiguration?.uri?.scheme) {
|
||||||
|
"file", "content" -> "file"
|
||||||
|
"http", "https" -> "http"
|
||||||
|
null -> "none"
|
||||||
|
else -> "other"
|
||||||
|
}
|
||||||
|
return "item id=${mediaItem.mediaId} title=\"${metadata.title?.toString().orEmpty()}\"$artist$album source=$source"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun duration(value: Long): String = if (value == C.TIME_UNSET) "unset" else "${value}ms"
|
||||||
|
|
||||||
|
private fun transitionReason(reason: Int): String =
|
||||||
|
when (reason) {
|
||||||
|
Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT -> "repeat"
|
||||||
|
Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> "auto"
|
||||||
|
Player.MEDIA_ITEM_TRANSITION_REASON_SEEK -> "seek"
|
||||||
|
Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> "playlist"
|
||||||
|
else -> reason.toString()
|
||||||
|
}
|
||||||
|
|
||||||
private fun toMediaItem(item: QueueItem): MediaItem {
|
private fun toMediaItem(item: QueueItem): MediaItem {
|
||||||
val quality = sourceRepository.quality.value
|
val quality = sourceRepository.quality.value
|
||||||
return queueMediaItem(
|
return queueMediaItem(
|
||||||
@@ -184,20 +231,51 @@ class Media3PlayerHandle(
|
|||||||
.orEmpty(),
|
.orEmpty(),
|
||||||
artist = mediaItem.mediaMetadata.artist?.toString(),
|
artist = mediaItem.mediaMetadata.artist?.toString(),
|
||||||
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
||||||
|
artists = mediaItem.artistRefs(),
|
||||||
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
||||||
durationMs =
|
durationMs =
|
||||||
mediaItem.mediaMetadata.extras
|
mediaItem.mediaMetadata.extras
|
||||||
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
||||||
?.getLong(EXTRA_DURATION_MS),
|
?.getLong(EXTRA_DURATION_MS),
|
||||||
|
replayGain = mediaItem.replayGain(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TAG = "SubtracksPlayback"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun MediaItem.artistRefs(): List<ArtistRef> =
|
||||||
|
mediaMetadata.extras
|
||||||
|
?.getString(EXTRA_ARTISTS)
|
||||||
|
?.let(ArtistRefsCodec::decode)
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
private const val EXTRA_COVER_ART_ID = "coverArtId"
|
private const val EXTRA_COVER_ART_ID = "coverArtId"
|
||||||
|
private const val EXTRA_ARTISTS = "artists"
|
||||||
private const val EXTRA_DURATION_MS = "durationMs"
|
private const val EXTRA_DURATION_MS = "durationMs"
|
||||||
|
private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain"
|
||||||
|
private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
|
||||||
|
private const val EXTRA_REPLAY_GAIN_TRACK_PEAK = "replayGainTrackPeak"
|
||||||
|
private const val EXTRA_REPLAY_GAIN_ALBUM_PEAK = "replayGainAlbumPeak"
|
||||||
|
private const val EXTRA_ALBUM_CONTEXT = "albumContext"
|
||||||
|
|
||||||
|
internal fun MediaItem?.replayGain(): ReplayGain {
|
||||||
|
val extras = this?.mediaMetadata?.extras ?: return ReplayGain()
|
||||||
|
return ReplayGain(
|
||||||
|
trackGain = extras.gain(EXTRA_REPLAY_GAIN_TRACK_GAIN),
|
||||||
|
albumGain = extras.gain(EXTRA_REPLAY_GAIN_ALBUM_GAIN),
|
||||||
|
trackPeak = extras.gain(EXTRA_REPLAY_GAIN_TRACK_PEAK),
|
||||||
|
albumPeak = extras.gain(EXTRA_REPLAY_GAIN_ALBUM_PEAK),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun MediaItem?.albumContext(): Boolean = this?.mediaMetadata?.extras?.getBoolean(EXTRA_ALBUM_CONTEXT) ?: false
|
||||||
|
|
||||||
|
private fun Bundle.putGain(
|
||||||
|
key: String,
|
||||||
|
value: Float?,
|
||||||
|
) {
|
||||||
|
if (value != null) putFloat(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Bundle.gain(key: String): Float? = if (containsKey(key)) getFloat(key) else null
|
||||||
|
|
||||||
internal fun queueMediaItem(
|
internal fun queueMediaItem(
|
||||||
item: QueueItem,
|
item: QueueItem,
|
||||||
@@ -218,7 +296,13 @@ internal fun queueMediaItem(
|
|||||||
.setExtras(
|
.setExtras(
|
||||||
Bundle().apply {
|
Bundle().apply {
|
||||||
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
||||||
|
putString(EXTRA_ARTISTS, ArtistRefsCodec.encode(item.artists))
|
||||||
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
||||||
|
putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain)
|
||||||
|
putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain)
|
||||||
|
putGain(EXTRA_REPLAY_GAIN_TRACK_PEAK, item.replayGain.trackPeak)
|
||||||
|
putGain(EXTRA_REPLAY_GAIN_ALBUM_PEAK, item.replayGain.albumPeak)
|
||||||
|
putBoolean(EXTRA_ALBUM_CONTEXT, item.albumContext)
|
||||||
},
|
},
|
||||||
).build()
|
).build()
|
||||||
val builder =
|
val builder =
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.AudioEncoding
|
import com.subtracks.data.model.AudioEncoding
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.QueueEntry
|
import com.subtracks.data.model.QueueEntry
|
||||||
@@ -11,6 +12,7 @@ import com.subtracks.data.repo.QueueSnapshot
|
|||||||
import com.subtracks.data.repo.QueueWindowItem
|
import com.subtracks.data.repo.QueueWindowItem
|
||||||
import com.subtracks.data.repo.Shuffle
|
import com.subtracks.data.repo.Shuffle
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -33,6 +35,9 @@ data class QueueItem(
|
|||||||
val album: String?,
|
val album: String?,
|
||||||
val coverArtId: String?,
|
val coverArtId: String?,
|
||||||
val durationMs: Long? = null,
|
val durationMs: Long? = null,
|
||||||
|
val replayGain: ReplayGain = ReplayGain(),
|
||||||
|
val albumContext: Boolean = false,
|
||||||
|
val artists: List<ArtistRef> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class QueueContext(
|
data class QueueContext(
|
||||||
@@ -127,7 +132,9 @@ class PlaybackController(
|
|||||||
private var positionJob: Job? = null
|
private var positionJob: Job? = null
|
||||||
private var windowJob: Job? = null
|
private var windowJob: Job? = null
|
||||||
private var bufferingJob: Job? = null
|
private var bufferingJob: Job? = null
|
||||||
|
private var stallJob: Job? = null
|
||||||
private var showBuffering = false
|
private var showBuffering = false
|
||||||
|
private var stalled = false
|
||||||
private val startLock = Mutex()
|
private val startLock = Mutex()
|
||||||
|
|
||||||
private var snapshot: QueueSnapshot? = null
|
private var snapshot: QueueSnapshot? = null
|
||||||
@@ -258,6 +265,15 @@ class PlaybackController(
|
|||||||
artistId: String,
|
artistId: String,
|
||||||
) = shuffleContext(queueRepository.artistEntry(sourceId, artistId))
|
) = shuffleContext(queueRepository.artistEntry(sourceId, artistId))
|
||||||
|
|
||||||
|
fun shuffleGenre(
|
||||||
|
sourceId: Long,
|
||||||
|
genre: String,
|
||||||
|
) = shuffleContext(queueRepository.genreEntry(sourceId, genre))
|
||||||
|
|
||||||
|
fun shuffleStarred(sourceId: Long) = shuffleContext(queueRepository.starredEntry(sourceId))
|
||||||
|
|
||||||
|
fun shuffleDownloaded(sourceId: Long) = shuffleContext(queueRepository.downloadedEntry(sourceId))
|
||||||
|
|
||||||
private fun playContext(
|
private fun playContext(
|
||||||
entry: QueueEntry,
|
entry: QueueEntry,
|
||||||
start: Long,
|
start: Long,
|
||||||
@@ -380,7 +396,7 @@ class PlaybackController(
|
|||||||
val current = currentPosition() ?: return@withLock
|
val current = currentPosition() ?: return@withLock
|
||||||
val playing = player.playWhenReady
|
val playing = player.playWhenReady
|
||||||
val wasUpNext = snapshot.isUpNext(current)
|
val wasUpNext = snapshot.isUpNext(current)
|
||||||
val removed = if (position == current) null else queueRepository.itemAt(snapshot, position)?.toQueueItem()
|
val removed = if (position == current) null else queueItemAt(snapshot, position)
|
||||||
queueRepository.removeAt(snapshot, position)
|
queueRepository.removeAt(snapshot, position)
|
||||||
var updated = readSnapshot()
|
var updated = readSnapshot()
|
||||||
this.snapshot = updated
|
this.snapshot = updated
|
||||||
@@ -532,13 +548,13 @@ class PlaybackController(
|
|||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
fromInWindow && toInWindow -> {
|
fromInWindow && toInWindow && (snapshot.upNextSize == 0L || snapshot.isUpNext(from)) -> {
|
||||||
updating = true
|
updating = true
|
||||||
player.move((from - windowStart).toInt(), (to - windowStart).toInt())
|
player.move((from - windowStart).toInt(), (to - windowStart).toInt())
|
||||||
updating = false
|
updating = false
|
||||||
}
|
}
|
||||||
|
|
||||||
!fromInWindow && !toInWindow -> {
|
!fromInWindow && !toInWindow && (snapshot.upNextSize == 0L || snapshot.isUpNext(from)) -> {
|
||||||
// The moved track never enters the window, so the playlist is unchanged; only
|
// The moved track never enters the window, so the playlist is unchanged; only
|
||||||
// the window's positions shift, and a move that spans it shifts them by one.
|
// the window's positions shift, and a move that spans it shifts them by one.
|
||||||
when {
|
when {
|
||||||
@@ -680,12 +696,29 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
when {
|
nextPosition(snapshot, current)?.let { jumpTo(it) }
|
||||||
current < snapshot.size - 1 -> jumpTo(current + 1)
|
|
||||||
repeatMode != RepeatMode.Off -> jumpTo(0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun nextPosition(
|
||||||
|
snapshot: QueueSnapshot,
|
||||||
|
current: Long,
|
||||||
|
): Long? =
|
||||||
|
when {
|
||||||
|
current < snapshot.size - 1 -> current + 1
|
||||||
|
repeatMode != RepeatMode.Off -> 0L
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun previousPosition(
|
||||||
|
snapshot: QueueSnapshot,
|
||||||
|
current: Long,
|
||||||
|
): Long? =
|
||||||
|
when {
|
||||||
|
current > 0 -> current - 1
|
||||||
|
repeatMode == RepeatMode.All -> snapshot.size - 1
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun consumeUpNext(position: Long) =
|
private suspend fun consumeUpNext(position: Long) =
|
||||||
startLock.withLock {
|
startLock.withLock {
|
||||||
val snapshot = snapshot ?: return@withLock
|
val snapshot = snapshot ?: return@withLock
|
||||||
@@ -771,19 +804,12 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
when {
|
val target = previousPosition(snapshot, current)
|
||||||
current > 0 -> {
|
if (target != null) {
|
||||||
jumpTo(current - 1)
|
jumpTo(target)
|
||||||
}
|
} else {
|
||||||
|
player.seekTo(0)
|
||||||
repeatMode == RepeatMode.All -> {
|
refresh()
|
||||||
jumpTo(snapshot.size - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
player.seekTo(0)
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,10 +827,7 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
when {
|
previousPosition(snapshot, current)?.let { jumpTo(it) }
|
||||||
current > 0 -> jumpTo(current - 1)
|
|
||||||
repeatMode == RepeatMode.All -> jumpTo(snapshot.size - 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addToQueue(
|
fun addToQueue(
|
||||||
@@ -921,12 +944,16 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
val snapshot = current ?: return null
|
val snapshot = current ?: return null
|
||||||
val at = position ?: return null
|
val at = position ?: return null
|
||||||
if (!isOffline) return queueRepository.itemAt(snapshot, at + 1)?.toQueueItem()
|
if (!isOffline) {
|
||||||
|
return nextPosition(snapshot, at)
|
||||||
|
?.takeIf { it != at }
|
||||||
|
?.let { queueItemAt(snapshot, it) }
|
||||||
|
}
|
||||||
downloads.awaitLoaded()
|
downloads.awaitLoaded()
|
||||||
val target =
|
val target =
|
||||||
downloadedAtOrAfter(snapshot, at + 1)
|
downloadedAtOrAfter(snapshot, at + 1)
|
||||||
?: if (repeatMode != RepeatMode.Off) downloadedAtOrAfter(snapshot, 0L) else null
|
?: if (repeatMode != RepeatMode.Off) downloadedAtOrAfter(snapshot, 0L) else null
|
||||||
return target?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
|
return target?.takeIf { it != at }?.let { queueItemAt(snapshot, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun previousItem(): QueueItem? {
|
suspend fun previousItem(): QueueItem? {
|
||||||
@@ -940,12 +967,16 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
val snapshot = current ?: return null
|
val snapshot = current ?: return null
|
||||||
val at = position ?: return null
|
val at = position ?: return null
|
||||||
if (!isOffline) return if (at <= 0) null else queueRepository.itemAt(snapshot, at - 1)?.toQueueItem()
|
if (!isOffline) {
|
||||||
|
return previousPosition(snapshot, at)
|
||||||
|
?.takeIf { it != at }
|
||||||
|
?.let { queueItemAt(snapshot, it) }
|
||||||
|
}
|
||||||
downloads.awaitLoaded()
|
downloads.awaitLoaded()
|
||||||
val target =
|
val target =
|
||||||
downloadedAtOrBefore(snapshot, at - 1)
|
downloadedAtOrBefore(snapshot, at - 1)
|
||||||
?: if (repeatMode == RepeatMode.All) downloadedAtOrBefore(snapshot, snapshot.size - 1) else null
|
?: if (repeatMode == RepeatMode.All) downloadedAtOrBefore(snapshot, snapshot.size - 1) else null
|
||||||
return target?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
|
return target?.takeIf { it != at }?.let { queueItemAt(snapshot, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun play(
|
private fun play(
|
||||||
@@ -1047,6 +1078,9 @@ class PlaybackController(
|
|||||||
windowJob?.cancel()
|
windowJob?.cancel()
|
||||||
bufferingJob?.cancel()
|
bufferingJob?.cancel()
|
||||||
bufferingJob = null
|
bufferingJob = null
|
||||||
|
stallJob?.cancel()
|
||||||
|
stallJob = null
|
||||||
|
stalled = false
|
||||||
showBuffering = false
|
showBuffering = false
|
||||||
snapshot = null
|
snapshot = null
|
||||||
queueSourceId = null
|
queueSourceId = null
|
||||||
@@ -1101,7 +1135,7 @@ class PlaybackController(
|
|||||||
lastPosition = actual
|
lastPosition = actual
|
||||||
if (actual != requested) queueRepository.setCursor(actual)
|
if (actual != requested) queueRepository.setCursor(actual)
|
||||||
updating = true
|
updating = true
|
||||||
player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs)
|
player.setWindow(window.map { it.playerItem(snapshot) }, startIndex, startPositionMs)
|
||||||
updating = false
|
updating = false
|
||||||
if (autoplay) {
|
if (autoplay) {
|
||||||
player.prepare()
|
player.prepare()
|
||||||
@@ -1141,7 +1175,7 @@ class PlaybackController(
|
|||||||
window.forEachIndexed { index, entry ->
|
window.forEachIndexed { index, entry ->
|
||||||
val local = downloads.localUri(entry.item.song.id) != null
|
val local = downloads.localUri(entry.item.song.id) != null
|
||||||
if (local != (uris[index]?.startsWith(FILE_SCHEME) == true)) {
|
if (local != (uris[index]?.startsWith(FILE_SCHEME) == true)) {
|
||||||
player.replaceItem(index, entry.item.toQueueItem())
|
player.replaceItem(index, entry.playerItem(snapshot))
|
||||||
replaced = true
|
replaced = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1256,8 +1290,8 @@ class PlaybackController(
|
|||||||
updating = true
|
updating = true
|
||||||
try {
|
try {
|
||||||
while (windowStart > desiredStart) {
|
while (windowStart > desiredStart) {
|
||||||
val item = queueRepository.itemAt(snapshot, windowStart - 1) ?: break
|
val item = queueItemAt(snapshot, windowStart - 1) ?: break
|
||||||
player.addFirst(item.toQueueItem())
|
player.addFirst(item)
|
||||||
windowStart--
|
windowStart--
|
||||||
}
|
}
|
||||||
while (windowStart < desiredStart) {
|
while (windowStart < desiredStart) {
|
||||||
@@ -1265,8 +1299,8 @@ class PlaybackController(
|
|||||||
windowStart++
|
windowStart++
|
||||||
}
|
}
|
||||||
while (windowEnd < desiredEnd) {
|
while (windowEnd < desiredEnd) {
|
||||||
val item = queueRepository.itemAt(snapshot, windowEnd + 1) ?: break
|
val item = queueItemAt(snapshot, windowEnd + 1) ?: break
|
||||||
player.addLast(item.toQueueItem())
|
player.addLast(item)
|
||||||
windowEnd++
|
windowEnd++
|
||||||
}
|
}
|
||||||
while (windowEnd > desiredEnd) {
|
while (windowEnd > desiredEnd) {
|
||||||
@@ -1284,6 +1318,7 @@ class PlaybackController(
|
|||||||
position: Long,
|
position: Long,
|
||||||
) {
|
) {
|
||||||
val player = player ?: return
|
val player = player ?: return
|
||||||
|
val snapshot = this.snapshot ?: return
|
||||||
downloads.awaitLoaded()
|
downloads.awaitLoaded()
|
||||||
val ordered =
|
val ordered =
|
||||||
if (offline) window.filter { downloads.localUri(it.item.song.id) != null } else window
|
if (offline) window.filter { downloads.localUri(it.item.song.id) != null } else window
|
||||||
@@ -1297,7 +1332,7 @@ class PlaybackController(
|
|||||||
val current = ordered[index]
|
val current = ordered[index]
|
||||||
val wantsLocal = downloads.localUri(current.item.song.id) != null
|
val wantsLocal = downloads.localUri(current.item.song.id) != null
|
||||||
val isLocal = player.itemUris().getOrNull(player.currentIndex)?.startsWith(FILE_SCHEME) == true
|
val isLocal = player.itemUris().getOrNull(player.currentIndex)?.startsWith(FILE_SCHEME) == true
|
||||||
if (wantsLocal != isLocal) player.replaceItem(player.currentIndex, current.item.toQueueItem())
|
if (wantsLocal != isLocal) player.replaceItem(player.currentIndex, current.playerItem(snapshot))
|
||||||
windowPositions = LongArray(ordered.size) { ordered[it].position }
|
windowPositions = LongArray(ordered.size) { ordered[it].position }
|
||||||
windowFiltered = offline
|
windowFiltered = offline
|
||||||
windowStart = ordered.first().position
|
windowStart = ordered.first().position
|
||||||
@@ -1307,8 +1342,8 @@ class PlaybackController(
|
|||||||
try {
|
try {
|
||||||
repeat(player.currentIndex) { player.removeFirst() }
|
repeat(player.currentIndex) { player.removeFirst() }
|
||||||
while (player.itemCount > 1) player.removeLast()
|
while (player.itemCount > 1) player.removeLast()
|
||||||
for (i in index - 1 downTo 0) player.addFirst(ordered[i].item.toQueueItem())
|
for (i in index - 1 downTo 0) player.addFirst(ordered[i].playerItem(snapshot))
|
||||||
for (i in index + 1 until ordered.size) player.addLast(ordered[i].item.toQueueItem())
|
for (i in index + 1 until ordered.size) player.addLast(ordered[i].playerItem(snapshot))
|
||||||
} finally {
|
} finally {
|
||||||
updating = false
|
updating = false
|
||||||
}
|
}
|
||||||
@@ -1450,6 +1485,17 @@ class PlaybackController(
|
|||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun QueueWindowItem.playerItem(snapshot: QueueSnapshot): QueueItem =
|
||||||
|
item.toQueueItem(albumContext = snapshot.isAlbumContext(position))
|
||||||
|
|
||||||
|
private suspend fun queueItemAt(
|
||||||
|
snapshot: QueueSnapshot,
|
||||||
|
position: Long,
|
||||||
|
): QueueItem? =
|
||||||
|
queueRepository
|
||||||
|
.itemAt(snapshot, position)
|
||||||
|
?.toQueueItem(albumContext = snapshot.isAlbumContext(position))
|
||||||
|
|
||||||
private fun contextAt(position: Long?): QueueContext? {
|
private fun contextAt(position: Long?): QueueContext? {
|
||||||
val snapshot = snapshot ?: return null
|
val snapshot = snapshot ?: return null
|
||||||
val play = position?.let { snapshot.anchorContextPlay(it) } ?: return null
|
val play = position?.let { snapshot.anchorContextPlay(it) } ?: return null
|
||||||
@@ -1460,21 +1506,42 @@ class PlaybackController(
|
|||||||
suspend fun sourceTitle(context: QueueContext?): String? = context?.let { queueRepository.sourceName(it.kind, it.sourceId, it.refId) }
|
suspend fun sourceTitle(context: QueueContext?): String? = context?.let { queueRepository.sourceName(it.kind, it.sourceId, it.refId) }
|
||||||
|
|
||||||
private fun updateBuffering() {
|
private fun updateBuffering() {
|
||||||
|
val player = player
|
||||||
if (player?.isBuffering != true) {
|
if (player?.isBuffering != true) {
|
||||||
bufferingJob?.cancel()
|
bufferingJob?.cancel()
|
||||||
bufferingJob = null
|
bufferingJob = null
|
||||||
|
stallJob?.cancel()
|
||||||
|
stallJob = null
|
||||||
|
stalled = false
|
||||||
showBuffering = false
|
showBuffering = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (showBuffering || bufferingJob?.isActive == true) return
|
if (!showBuffering && bufferingJob?.isActive != true) {
|
||||||
bufferingJob =
|
bufferingJob =
|
||||||
scope.launch {
|
scope.launch {
|
||||||
delay(BUFFERING_INDICATOR_DELAY_MS)
|
delay(BUFFERING_INDICATOR_DELAY_MS)
|
||||||
if (player?.isBuffering == true) {
|
if (player.isBuffering) {
|
||||||
showBuffering = true
|
showBuffering = true
|
||||||
refresh()
|
refresh()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A stall is buffering that persists long enough to be a real problem, not a track change.
|
||||||
|
if (stallJob?.isActive != true && !stalled) {
|
||||||
|
stallJob =
|
||||||
|
scope.launch {
|
||||||
|
delay(STALL_WATCHDOG_MS)
|
||||||
|
if (player === this@PlaybackController.player && player.isBuffering && player.playWhenReady) {
|
||||||
|
stalled = true
|
||||||
|
val item = player.currentItem
|
||||||
|
Log.w(
|
||||||
|
"playback",
|
||||||
|
"stalled: buffering for ${STALL_WATCHDOG_MS / 1000}s at ${player.currentPositionMs}ms " +
|
||||||
|
"item id=${item?.id} title=\"${item?.title}\"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refresh(position: Long? = currentPosition()) {
|
private fun refresh(position: Long? = currentPosition()) {
|
||||||
@@ -1541,6 +1608,7 @@ class PlaybackController(
|
|||||||
const val POSITION_TICK_MS = 500L
|
const val POSITION_TICK_MS = 500L
|
||||||
const val POSITION_SAVE_INTERVAL_MS = 15_000L
|
const val POSITION_SAVE_INTERVAL_MS = 15_000L
|
||||||
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
|
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
|
||||||
|
const val STALL_WATCHDOG_MS = 15_000L
|
||||||
const val QUEUE_WINDOW_RADIUS = 25L
|
const val QUEUE_WINDOW_RADIUS = 25L
|
||||||
const val WINDOW_SHIFT_DELAY_MS = 400L
|
const val WINDOW_SHIFT_DELAY_MS = 400L
|
||||||
const val RESTART_THRESHOLD_MS = 3_000L
|
const val RESTART_THRESHOLD_MS = 3_000L
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.media3.common.AudioAttributes
|
import androidx.media3.common.AudioAttributes
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
@@ -13,8 +12,13 @@ import androidx.media3.common.MediaItem
|
|||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.ExperimentalApi
|
import androidx.media3.common.util.ExperimentalApi
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.database.StandaloneDatabaseProvider
|
||||||
import androidx.media3.datasource.DataSource
|
import androidx.media3.datasource.DataSource
|
||||||
import androidx.media3.datasource.DefaultDataSource
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
|
import androidx.media3.datasource.cache.Cache
|
||||||
|
import androidx.media3.datasource.cache.CacheDataSource
|
||||||
|
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||||
|
import androidx.media3.datasource.cache.SimpleCache
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
@@ -29,17 +33,22 @@ import com.google.common.util.concurrent.Futures
|
|||||||
import com.google.common.util.concurrent.ListenableFuture
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
import com.subtracks.MainActivity
|
import com.subtracks.MainActivity
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
|
import com.subtracks.UserAgent
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koin.core.context.GlobalContext
|
import org.koin.core.context.GlobalContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
class PlaybackService : MediaSessionService() {
|
class PlaybackService : MediaSessionService() {
|
||||||
@@ -53,6 +62,9 @@ class PlaybackService : MediaSessionService() {
|
|||||||
private var starred = false
|
private var starred = false
|
||||||
private var starJob: Job? = null
|
private var starJob: Job? = null
|
||||||
|
|
||||||
|
private val gainProcessor: ReplayGainProcessor = ReplayGainProcessor { replayGain.applyItem(it) }
|
||||||
|
private val replayGain: ReplayGainController = ReplayGainController { gainProcessor.setLinearGain(it) }
|
||||||
|
|
||||||
private val starCallback =
|
private val starCallback =
|
||||||
object : MediaSession.Callback {
|
object : MediaSession.Callback {
|
||||||
override fun onConnect(
|
override fun onConnect(
|
||||||
@@ -90,9 +102,18 @@ class PlaybackService : MediaSessionService() {
|
|||||||
val sources = GlobalContext.get().get<SourceRepository>()
|
val sources = GlobalContext.get().get<SourceRepository>()
|
||||||
sourceRepository = sources
|
sourceRepository = sources
|
||||||
library = GlobalContext.get().get<LibraryRepository>()
|
library = GlobalContext.get().get<LibraryRepository>()
|
||||||
|
val preferences = GlobalContext.get().get<UserPreferences>()
|
||||||
|
val cache = StreamCache.open(this)
|
||||||
|
scope.launch {
|
||||||
|
preferences
|
||||||
|
.replayGainMode()
|
||||||
|
.combine(preferences.replayGainPreampDb()) { mode, preamp -> ReplayGainSettings(mode, preamp) }
|
||||||
|
.collect(replayGain::setSettings)
|
||||||
|
}
|
||||||
val exoPlayer =
|
val exoPlayer =
|
||||||
ExoPlayer
|
ExoPlayer
|
||||||
.Builder(this)
|
.Builder(this)
|
||||||
|
.setRenderersFactory(ReplayGainRenderersFactory(this, gainProcessor))
|
||||||
.setAudioAttributes(
|
.setAudioAttributes(
|
||||||
AudioAttributes
|
AudioAttributes
|
||||||
.Builder()
|
.Builder()
|
||||||
@@ -104,7 +125,13 @@ class PlaybackService : MediaSessionService() {
|
|||||||
.setMediaSourceFactory(
|
.setMediaSourceFactory(
|
||||||
DefaultMediaSourceFactory(this)
|
DefaultMediaSourceFactory(this)
|
||||||
.setEnableClippingInMediaPeriod(true)
|
.setEnableClippingInMediaPeriod(true)
|
||||||
.setDataSourceFactory(mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()))),
|
.setDataSourceFactory(
|
||||||
|
mediaDataSourceFactory(
|
||||||
|
this,
|
||||||
|
cache,
|
||||||
|
OkHttpDataSource.Factory(streamingClient()).setUserAgent(UserAgent.value),
|
||||||
|
),
|
||||||
|
),
|
||||||
).build()
|
).build()
|
||||||
exoPlayer.addListener(
|
exoPlayer.addListener(
|
||||||
object : Player.Listener {
|
object : Player.Listener {
|
||||||
@@ -116,6 +143,7 @@ class PlaybackService : MediaSessionService() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
val player = KnownDurationPlayer(exoPlayer)
|
||||||
val loader =
|
val loader =
|
||||||
CoverArtBitmapLoader(
|
CoverArtBitmapLoader(
|
||||||
applicationContext,
|
applicationContext,
|
||||||
@@ -125,7 +153,7 @@ class PlaybackService : MediaSessionService() {
|
|||||||
artworkLoader = loader
|
artworkLoader = loader
|
||||||
session =
|
session =
|
||||||
MediaSession
|
MediaSession
|
||||||
.Builder(this, exoPlayer)
|
.Builder(this, player)
|
||||||
.setSessionActivity(
|
.setSessionActivity(
|
||||||
PendingIntent.getActivity(
|
PendingIntent.getActivity(
|
||||||
this,
|
this,
|
||||||
@@ -183,12 +211,13 @@ class PlaybackService : MediaSessionService() {
|
|||||||
private fun streamingClient(): OkHttpClient =
|
private fun streamingClient(): OkHttpClient =
|
||||||
OkHttpClient
|
OkHttpClient
|
||||||
.Builder()
|
.Builder()
|
||||||
|
.addInterceptor(Log.interceptor())
|
||||||
.addInterceptor { chain ->
|
.addInterceptor { chain ->
|
||||||
val started = SystemClock.elapsedRealtime()
|
val started = SystemClock.elapsedRealtime()
|
||||||
val response = chain.proceed(chain.request())
|
val response = chain.proceed(chain.request())
|
||||||
Log.d(
|
Log.d(
|
||||||
STREAM_TAG,
|
"stream",
|
||||||
"stream range=${chain.request().header("Range")} code=${response.code} " +
|
"range=${chain.request().header("Range")} code=${response.code} " +
|
||||||
"contentRange=${response.header("Content-Range")} " +
|
"contentRange=${response.header("Content-Range")} " +
|
||||||
"length=${response.body.contentLength()} in ${SystemClock.elapsedRealtime() - started}ms",
|
"length=${response.body.contentLength()} in ${SystemClock.elapsedRealtime() - started}ms",
|
||||||
)
|
)
|
||||||
@@ -208,13 +237,50 @@ class PlaybackService : MediaSessionService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val STREAM_TAG = "SubtracksPlayback"
|
|
||||||
val STAR_COMMAND = SessionCommand("com.subtracks.STAR", Bundle.EMPTY)
|
val STAR_COMMAND = SessionCommand("com.subtracks.STAR", Bundle.EMPTY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process-scoped so it is never released while a loader thread is still committing to it, and so
|
||||||
|
// a recreated service does not try to lock the directory a second time.
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
private object StreamCache {
|
||||||
|
@Volatile
|
||||||
|
private var instance: SimpleCache? = null
|
||||||
|
|
||||||
|
fun open(context: Context): SimpleCache? =
|
||||||
|
instance ?: synchronized(this) {
|
||||||
|
instance ?: runCatching {
|
||||||
|
SimpleCache(
|
||||||
|
File(context.cacheDir, STREAM_CACHE_DIR),
|
||||||
|
LeastRecentlyUsedCacheEvictor(STREAM_CACHE_BYTES),
|
||||||
|
StandaloneDatabaseProvider(context),
|
||||||
|
)
|
||||||
|
}.onFailure { Log.w("playback", "stream cache unavailable: ${it.message}") }
|
||||||
|
.getOrNull()
|
||||||
|
?.also { instance = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val STREAM_CACHE_DIR = "stream-cache"
|
||||||
|
private const val STREAM_CACHE_BYTES = 64L * 1024 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
internal fun mediaDataSourceFactory(
|
internal fun mediaDataSourceFactory(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
cache: Cache?,
|
||||||
upstream: DataSource.Factory,
|
upstream: DataSource.Factory,
|
||||||
): DataSource.Factory = DefaultDataSource.Factory(context, KnownLengthDataSourceFactory(upstream))
|
): DataSource.Factory {
|
||||||
|
val known = KnownLengthDataSourceFactory(upstream)
|
||||||
|
val source: DataSource.Factory =
|
||||||
|
if (cache == null) {
|
||||||
|
known
|
||||||
|
} else {
|
||||||
|
CacheDataSource
|
||||||
|
.Factory()
|
||||||
|
.setCache(cache)
|
||||||
|
.setUpstreamDataSourceFactory(known)
|
||||||
|
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||||
|
}
|
||||||
|
return DefaultDataSource.Factory(context, source)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.SongItem
|
import com.subtracks.data.model.SongItem
|
||||||
|
|
||||||
fun SongItem.toQueueItem() =
|
fun SongItem.toQueueItem(albumContext: Boolean = false) =
|
||||||
QueueItem(
|
QueueItem(
|
||||||
id = song.id,
|
id = song.id,
|
||||||
title = song.title,
|
title = song.title,
|
||||||
artist = song.artist,
|
artist = song.artist,
|
||||||
|
artists = song.artistRefs,
|
||||||
album = song.album,
|
album = song.album,
|
||||||
coverArtId = coverArt,
|
coverArtId = coverArt,
|
||||||
durationMs = song.duration?.let { it * 1000 },
|
durationMs = song.duration?.let { it * 1000 },
|
||||||
|
replayGain = song.replayGain(),
|
||||||
|
albumContext = albumContext,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
|
import kotlin.math.log10
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.pow
|
||||||
|
|
||||||
|
data class ReplayGain(
|
||||||
|
val trackGain: Float? = null,
|
||||||
|
val albumGain: Float? = null,
|
||||||
|
val trackPeak: Float? = null,
|
||||||
|
val albumPeak: Float? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class ReplayGainSettings(
|
||||||
|
val mode: ReplayGainMode,
|
||||||
|
val preampDb: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun Song.replayGain(): ReplayGain =
|
||||||
|
ReplayGain(
|
||||||
|
trackGain = trackGain,
|
||||||
|
albumGain = albumGain,
|
||||||
|
trackPeak = trackPeak,
|
||||||
|
albumPeak = albumPeak,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun replayGainLinearGain(
|
||||||
|
gain: ReplayGain,
|
||||||
|
settings: ReplayGainSettings,
|
||||||
|
albumContext: Boolean = false,
|
||||||
|
): Float {
|
||||||
|
if (settings.mode == ReplayGainMode.Off) return 1f
|
||||||
|
// gonic sends a peak of 0 when it only has album gain, so a non-positive or non-finite peak means
|
||||||
|
// "no peak data" and is ignored rather than treated as silence. A boost cannot be checked for
|
||||||
|
// headroom without a peak, so a peakless track is only ever attenuated.
|
||||||
|
val trackPeak = gain.trackPeak?.takeIf { it.isFinite() && it > 0f }
|
||||||
|
val albumPeak = gain.albumPeak?.takeIf { it.isFinite() && it > 0f }
|
||||||
|
val album = settings.mode == ReplayGainMode.Album || (settings.mode == ReplayGainMode.Auto && albumContext)
|
||||||
|
val (requestedDb, peak) =
|
||||||
|
if (album) {
|
||||||
|
(gain.albumGain ?: gain.trackGain) to (albumPeak ?: trackPeak)
|
||||||
|
} else {
|
||||||
|
(gain.trackGain ?: gain.albumGain) to (trackPeak ?: albumPeak)
|
||||||
|
}
|
||||||
|
val gainDb = requestedDb?.takeIf { it.isFinite() } ?: return 1f
|
||||||
|
val requested = gainDb + settings.preampDb
|
||||||
|
val maxGainDb = peak?.let(::maxSafeGainDb) ?: 0f
|
||||||
|
val applied = min(requested, maxGainDb)
|
||||||
|
return 10f.pow(applied / 20f).takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maxSafeGainDb(peak: Float): Float = -20f * log10(peak)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
|
|
||||||
|
internal class ReplayGainController(
|
||||||
|
private val apply: (Float) -> Unit,
|
||||||
|
) {
|
||||||
|
private var track = ReplayGain()
|
||||||
|
private var albumContext = false
|
||||||
|
private var settings = ReplayGainSettings(ReplayGainMode.Auto, 0f)
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun applyItem(item: MediaItem) {
|
||||||
|
track = item.replayGain()
|
||||||
|
albumContext = item.albumContext()
|
||||||
|
applyGain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun setSettings(value: ReplayGainSettings) {
|
||||||
|
settings = value
|
||||||
|
applyGain()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyGain() {
|
||||||
|
apply(replayGainLinearGain(track, settings, albumContext))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.Timeline
|
||||||
|
import androidx.media3.common.audio.AudioProcessor.AudioFormat
|
||||||
|
import androidx.media3.common.audio.AudioProcessor.StreamMetadata
|
||||||
|
import androidx.media3.common.audio.BaseAudioProcessor
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
internal class ReplayGainProcessor(
|
||||||
|
private val onItem: (MediaItem) -> Unit = {},
|
||||||
|
) : BaseAudioProcessor() {
|
||||||
|
@Volatile
|
||||||
|
private var linearGain = 1f
|
||||||
|
|
||||||
|
fun setLinearGain(value: Float) {
|
||||||
|
linearGain = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConfigure(inputAudioFormat: AudioFormat): AudioFormat = inputAudioFormat
|
||||||
|
|
||||||
|
// DefaultAudioSink flushes the processors on every configuration, which happens at each media
|
||||||
|
// item transition even when the format is unchanged, and hands over the timeline and the period
|
||||||
|
// being flushed (androidx/media#418). That is early enough to set the new item's gain before any
|
||||||
|
// of its buffers are processed, unlike Player.Listener.onMediaItemTransition, which the app
|
||||||
|
// thread receives afterwards.
|
||||||
|
override fun onFlush(streamMetadata: StreamMetadata) {
|
||||||
|
streamMetadata.currentItem()?.let(onItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun queueInput(inputBuffer: ByteBuffer) {
|
||||||
|
val output = replaceOutputBuffer(inputBuffer.remaining())
|
||||||
|
val gain = linearGain.takeIf { it.isFinite() } ?: 1f
|
||||||
|
when (inputAudioFormat.encoding) {
|
||||||
|
C.ENCODING_PCM_16BIT -> {
|
||||||
|
while (inputBuffer.remaining() >= 2) {
|
||||||
|
output.putShort((inputBuffer.short * gain).roundToInt().coerceIn(MIN_SAMPLE, MAX_SAMPLE).toShort())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
C.ENCODING_PCM_FLOAT -> {
|
||||||
|
while (inputBuffer.remaining() >= 4) output.putFloat(inputBuffer.float * gain)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
if (inputBuffer.hasRemaining()) output.put(inputBuffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inputBuffer.position(inputBuffer.limit())
|
||||||
|
output.flip()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MIN_SAMPLE = -32768
|
||||||
|
const val MAX_SAMPLE = 32767
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
internal fun StreamMetadata.currentItem(): MediaItem? {
|
||||||
|
val uid = periodUid ?: return null
|
||||||
|
val index = timeline.getIndexOfPeriod(uid)
|
||||||
|
if (index == C.INDEX_UNSET) return null
|
||||||
|
val period = timeline.getPeriod(index, Timeline.Period())
|
||||||
|
return timeline.getWindow(period.windowIndex, Timeline.Window()).mediaItem
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
|
import androidx.media3.exoplayer.audio.AudioSink
|
||||||
|
import androidx.media3.exoplayer.audio.DefaultAudioSink
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
internal class ReplayGainRenderersFactory(
|
||||||
|
context: Context,
|
||||||
|
private val processor: ReplayGainProcessor,
|
||||||
|
) : DefaultRenderersFactory(context) {
|
||||||
|
// Mirrors the stock DefaultRenderersFactory.buildAudioSink. setAudioProcessors keeps the default
|
||||||
|
// chain and prepends ours; DefaultAudioSink omits the whole chain when float output is enabled,
|
||||||
|
// and AudioSink.setOffloadMode bypasses it entirely.
|
||||||
|
public override fun buildAudioSink(
|
||||||
|
context: Context,
|
||||||
|
enableFloatOutput: Boolean,
|
||||||
|
enableAudioOutputPlaybackParameters: Boolean,
|
||||||
|
): AudioSink {
|
||||||
|
if (enableFloatOutput) {
|
||||||
|
Log.w(FLOAT_OUTPUT_TAG, "float output is on, so replay gain is not applied to high-resolution tracks")
|
||||||
|
}
|
||||||
|
return DefaultAudioSink
|
||||||
|
.Builder(context)
|
||||||
|
.setEnableFloatOutput(enableFloatOutput)
|
||||||
|
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParameters)
|
||||||
|
.setAudioProcessors(arrayOf(processor))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val FLOAT_OUTPUT_TAG = "replaygain"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -58,6 +59,7 @@ class ScrobblePolicy(
|
|||||||
item?.id != songId ||
|
item?.id != songId ||
|
||||||
(item != null && item.id == lastItemId && positionMs < previousPosition && positionMs <= RESTART_POSITION_MS)
|
(item != null && item.id == lastItemId && positionMs < previousPosition && positionMs <= RESTART_POSITION_MS)
|
||||||
if (restarted) {
|
if (restarted) {
|
||||||
|
val alreadyAnnounced = nowPlayingSent && item?.id == songId
|
||||||
songId = item?.id
|
songId = item?.id
|
||||||
lastItemId = null
|
lastItemId = null
|
||||||
playedMs = 0
|
playedMs = 0
|
||||||
@@ -67,7 +69,7 @@ class ScrobblePolicy(
|
|||||||
if (item == null || !isPlaying) return null
|
if (item == null || !isPlaying) return null
|
||||||
nowPlayingSent = true
|
nowPlayingSent = true
|
||||||
startedAt = now()
|
startedAt = now()
|
||||||
return Scrobble.NowPlaying(item.id)
|
return if (alreadyAnnounced) null else Scrobble.NowPlaying(item.id)
|
||||||
}
|
}
|
||||||
lastPositionMs = positionMs
|
lastPositionMs = positionMs
|
||||||
lastItemId = item?.id
|
lastItemId = item?.id
|
||||||
@@ -128,22 +130,28 @@ class Scrobbler(
|
|||||||
private suspend fun submit(event: Scrobble) {
|
private suspend fun submit(event: Scrobble) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is Scrobble.NowPlaying -> {
|
is Scrobble.NowPlaying -> {
|
||||||
attempt { sink.nowPlaying(event.songId) }
|
Log.i("scrobble", "now playing ${event.songId}")
|
||||||
|
attempt("now playing ${event.songId}") { sink.nowPlaying(event.songId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
is Scrobble.Submission -> {
|
is Scrobble.Submission -> {
|
||||||
attempt { recordPlay(event.songId, event.time / 1000) }
|
Log.i("scrobble", "submitting ${event.songId}")
|
||||||
attempt { sink.scrobble(event.songId, event.time) }
|
attempt("record play ${event.songId}") { recordPlay(event.songId, event.time / 1000) }
|
||||||
|
attempt("scrobble ${event.songId}") { sink.scrobble(event.songId, event.time) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun attempt(block: suspend () -> Unit) {
|
private suspend fun attempt(
|
||||||
|
label: String,
|
||||||
|
block: suspend () -> Unit,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
block()
|
block()
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (_: Exception) {
|
} catch (failure: Exception) {
|
||||||
|
Log.w("scrobble", "$label failed", failure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,14 +61,20 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
|||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.AlbumInfoDialog
|
||||||
|
import com.subtracks.ui.components.ArtistChooserSheet
|
||||||
|
import com.subtracks.ui.components.ArtistInfoDialog
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.ItemContextMenu
|
import com.subtracks.ui.components.ItemContextMenu
|
||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
|
import com.subtracks.ui.components.MenuTarget
|
||||||
|
import com.subtracks.ui.components.PlaylistInfoDialog
|
||||||
import com.subtracks.ui.components.SongInfoDialog
|
import com.subtracks.ui.components.SongInfoDialog
|
||||||
import com.subtracks.ui.components.statusBarScrim
|
import com.subtracks.ui.components.statusBarScrim
|
||||||
import com.subtracks.ui.downloads.DownloadsRoute
|
import com.subtracks.ui.downloads.DownloadsRoute
|
||||||
@@ -230,7 +236,8 @@ private fun MainNavigation() {
|
|||||||
var settleJob by remember { mutableStateOf<Job?>(null) }
|
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val contextMenuHost = remember { ContextMenuHost() }
|
val contextMenuHost = remember { ContextMenuHost() }
|
||||||
var infoSong by remember { mutableStateOf<Song?>(null) }
|
var infoTarget by remember { mutableStateOf<MenuTarget?>(null) }
|
||||||
|
var artistChooser by remember { mutableStateOf<List<ArtistRef>?>(null) }
|
||||||
|
|
||||||
val activeSourceId by libraryRepository.activeSourceId.collectAsStateWithLifecycle(initialValue = null)
|
val activeSourceId by libraryRepository.activeSourceId.collectAsStateWithLifecycle(initialValue = null)
|
||||||
var previousSourceId by rememberSaveable { mutableStateOf<Long?>(null) }
|
var previousSourceId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||||
@@ -312,7 +319,8 @@ private fun MainNavigation() {
|
|||||||
nowPlayingLauncher.openNowPlaying.collect {
|
nowPlayingLauncher.openNowPlaying.collect {
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
contextMenuHost.dismissTransients()
|
contextMenuHost.dismissTransients()
|
||||||
infoSong = null
|
infoTarget = null
|
||||||
|
artistChooser = null
|
||||||
showingQueue = false
|
showingQueue = false
|
||||||
if (navController.currentDestination?.route in DETAIL_ROUTES) {
|
if (navController.currentDestination?.route in DETAIL_ROUTES) {
|
||||||
navController.popBackStack(Routes.LIBRARY, inclusive = false)
|
navController.popBackStack(Routes.LIBRARY, inclusive = false)
|
||||||
@@ -375,7 +383,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
navigateDetail(
|
||||||
|
Routes.ARTIST_DETAIL,
|
||||||
|
"artistId",
|
||||||
|
artist.id,
|
||||||
|
Routes.artist(
|
||||||
|
artist.id,
|
||||||
|
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onPlaylistClick = { playlist ->
|
onPlaylistClick = { playlist ->
|
||||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||||
@@ -418,7 +434,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
navigateDetail(
|
||||||
|
Routes.ARTIST_DETAIL,
|
||||||
|
"artistId",
|
||||||
|
artist.id,
|
||||||
|
Routes.artist(
|
||||||
|
artist.id,
|
||||||
|
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onPlaylistClick = { playlist ->
|
onPlaylistClick = { playlist ->
|
||||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||||
@@ -514,7 +538,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
navigateDetail(
|
||||||
|
Routes.ARTIST_DETAIL,
|
||||||
|
"artistId",
|
||||||
|
artist.id,
|
||||||
|
Routes.artist(
|
||||||
|
artist.id,
|
||||||
|
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||||
onViewArtist = { artistId ->
|
onViewArtist = { artistId ->
|
||||||
@@ -538,7 +570,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
navigateDetail(
|
||||||
|
Routes.ARTIST_DETAIL,
|
||||||
|
"artistId",
|
||||||
|
artist.id,
|
||||||
|
Routes.artist(
|
||||||
|
artist.id,
|
||||||
|
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||||
onViewArtist = { artistId ->
|
onViewArtist = { artistId ->
|
||||||
@@ -562,7 +602,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
navigateDetail(
|
||||||
|
Routes.ARTIST_DETAIL,
|
||||||
|
"artistId",
|
||||||
|
artist.id,
|
||||||
|
Routes.artist(
|
||||||
|
artist.id,
|
||||||
|
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||||
onViewArtist = { artistId ->
|
onViewArtist = { artistId ->
|
||||||
@@ -588,7 +636,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
navigateDetail(
|
||||||
|
Routes.ARTIST_DETAIL,
|
||||||
|
"artistId",
|
||||||
|
artist.id,
|
||||||
|
Routes.artist(
|
||||||
|
artist.id,
|
||||||
|
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onPlaylistClick = { playlist ->
|
onPlaylistClick = { playlist ->
|
||||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||||
@@ -725,23 +781,69 @@ private fun MainNavigation() {
|
|||||||
target = target,
|
target = target,
|
||||||
actions = contextMenuHost.actions,
|
actions = contextMenuHost.actions,
|
||||||
onDismiss = { contextMenuHost.dismiss() },
|
onDismiss = { contextMenuHost.dismiss() },
|
||||||
onInfo = { infoSong = it },
|
onInfo = { infoTarget = it },
|
||||||
|
onChooseArtists = { artistChooser = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
infoSong?.let { song ->
|
artistChooser?.let { artists ->
|
||||||
val genres by
|
ArtistChooserSheet(
|
||||||
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
artists = artists,
|
||||||
.collectAsStateWithLifecycle(initialValue = emptyList())
|
onArtistClick = { artist ->
|
||||||
SongInfoDialog(
|
artistChooser = null
|
||||||
song = song,
|
navigateDetail(
|
||||||
download = downloads[song.id],
|
Routes.ARTIST_DETAIL,
|
||||||
localFile = downloadRepository.localFile(song.id),
|
"artistId",
|
||||||
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
artist.id,
|
||||||
onDismiss = { infoSong = null },
|
Routes.artist(artist.id),
|
||||||
genres = genres,
|
)
|
||||||
|
},
|
||||||
|
onDismiss = { artistChooser = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
when (val target = infoTarget) {
|
||||||
|
is MenuTarget.Song -> {
|
||||||
|
val song = target.song
|
||||||
|
val genres by
|
||||||
|
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
|
SongInfoDialog(
|
||||||
|
song = song,
|
||||||
|
download = downloads[song.id],
|
||||||
|
localFile = downloadRepository.localFile(song.id),
|
||||||
|
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
||||||
|
onDismiss = { infoTarget = null },
|
||||||
|
genres = genres,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MenuTarget.Album -> {
|
||||||
|
AlbumInfoDialog(
|
||||||
|
album = target.album,
|
||||||
|
download = target.download,
|
||||||
|
onDismiss = { infoTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MenuTarget.Artist -> {
|
||||||
|
ArtistInfoDialog(
|
||||||
|
artist = target.artist,
|
||||||
|
onDismiss = { infoTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MenuTarget.Playlist -> {
|
||||||
|
PlaylistInfoDialog(
|
||||||
|
playlist = target.playlist,
|
||||||
|
onDismiss = { infoTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
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.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Outline
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
data class ArtistArt(
|
||||||
|
val image: CoverArtRef? = null,
|
||||||
|
val thumbnail: CoverArtRef? = null,
|
||||||
|
val covers: List<CoverArtRef> = emptyList(),
|
||||||
|
val fallback: ArtistArt? = null,
|
||||||
|
) {
|
||||||
|
val primary: CoverArtRef? get() = image ?: covers.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SourceRepository.artistArt(
|
||||||
|
artist: Artist,
|
||||||
|
forIcon: Boolean = true,
|
||||||
|
): ArtistArt =
|
||||||
|
resolveArtistArt(
|
||||||
|
artist = artist,
|
||||||
|
coverArt = this::coverArt,
|
||||||
|
artistImage = this::artistImage,
|
||||||
|
offline = offline.value,
|
||||||
|
forIcon = forIcon,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun resolveArtistArt(
|
||||||
|
artist: Artist,
|
||||||
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
artistImage: (String, String?) -> CoverArtRef?,
|
||||||
|
offline: Boolean,
|
||||||
|
forIcon: Boolean,
|
||||||
|
): ArtistArt {
|
||||||
|
val coverIds = if (offline) artist.downloadedCoverArts else artist.coverArts
|
||||||
|
val grid =
|
||||||
|
ArtistArt(
|
||||||
|
thumbnail = coverIds.singleOrNull()?.let { coverArt(it, true) },
|
||||||
|
covers = coverIds.take(4).mapNotNull { coverArt(it, forIcon || coverIds.size > 1) },
|
||||||
|
)
|
||||||
|
val own =
|
||||||
|
artist.coverArt?.let { coverId ->
|
||||||
|
coverArt(coverId, forIcon)?.let { image ->
|
||||||
|
ArtistArt(
|
||||||
|
image = image,
|
||||||
|
thumbnail = if (forIcon) image else coverArt(coverId, true),
|
||||||
|
fallback = grid,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return artist.artistImageUrl
|
||||||
|
?.let { artistImage(artist.id, it) }
|
||||||
|
?.let { ArtistArt(image = it, thumbnail = it, fallback = own ?: grid) }
|
||||||
|
?: own
|
||||||
|
?: grid
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ArtistIcon(
|
||||||
|
artist: Artist,
|
||||||
|
art: ArtistArt,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var shown by remember(artist.id, art) { mutableStateOf(art) }
|
||||||
|
val showFallback = {
|
||||||
|
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
shown.image != null -> {
|
||||||
|
CoverArt(
|
||||||
|
ref = shown.image,
|
||||||
|
name = artist.name,
|
||||||
|
modifier = modifier,
|
||||||
|
onError = showFallback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
shown.covers.size >= 2 -> {
|
||||||
|
ArtistArtGrid(covers = shown.covers, name = artist.name, modifier = modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
CoverArt(ref = shown.covers.firstOrNull(), name = artist.name, modifier = modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ArtistHeroArt(
|
||||||
|
art: ArtistArt,
|
||||||
|
name: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var shown by remember(art) { mutableStateOf(art) }
|
||||||
|
val showFallback = {
|
||||||
|
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
shown.image != null -> {
|
||||||
|
CoverArt(
|
||||||
|
ref = shown.image,
|
||||||
|
name = name,
|
||||||
|
thumbnailRef = shown.thumbnail,
|
||||||
|
showPlaceholder = false,
|
||||||
|
modifier = modifier,
|
||||||
|
onError = showFallback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
shown.covers.isNotEmpty() -> {
|
||||||
|
ArtistArtGrid(
|
||||||
|
covers = shown.covers,
|
||||||
|
name = name,
|
||||||
|
singleThumbnail = shown.thumbnail,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
CoverArt(ref = null, name = name, modifier = modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ArtistArtGrid(
|
||||||
|
covers: List<CoverArtRef>,
|
||||||
|
name: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
singleThumbnail: CoverArtRef? = null,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
covers.size >= 4 -> {
|
||||||
|
Column(modifier) {
|
||||||
|
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
covers.take(2).forEach { ref ->
|
||||||
|
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
covers.drop(2).take(2).forEach { ref ->
|
||||||
|
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
covers.size == 3 -> {
|
||||||
|
Box(modifier) {
|
||||||
|
covers.forEachIndexed { index, ref ->
|
||||||
|
CoverArt(
|
||||||
|
ref = ref,
|
||||||
|
name = name,
|
||||||
|
modifier = Modifier.matchParentSize().clip(YRegionShape(index)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
covers.size == 2 -> {
|
||||||
|
Row(modifier) {
|
||||||
|
covers.forEach { ref ->
|
||||||
|
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
covers.size == 1 -> {
|
||||||
|
CoverArt(
|
||||||
|
ref = covers.first(),
|
||||||
|
name = name,
|
||||||
|
thumbnailRef = singleThumbnail,
|
||||||
|
showPlaceholder = false,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class YRegionShape(
|
||||||
|
private val region: Int,
|
||||||
|
) : Shape {
|
||||||
|
override fun createOutline(
|
||||||
|
size: Size,
|
||||||
|
layoutDirection: LayoutDirection,
|
||||||
|
density: Density,
|
||||||
|
): Outline {
|
||||||
|
val center = Offset(size.width / 2f, size.height / 2f)
|
||||||
|
val left = rayPoint(size, 150.0)
|
||||||
|
val right = rayPoint(size, 30.0)
|
||||||
|
val top = Offset(size.width / 2f, 0f)
|
||||||
|
val path = Path()
|
||||||
|
when (region) {
|
||||||
|
0 -> {
|
||||||
|
path.moveTo(center.x, center.y)
|
||||||
|
path.lineTo(right.x, right.y)
|
||||||
|
addClockwise(path, size, right, left)
|
||||||
|
}
|
||||||
|
|
||||||
|
1 -> {
|
||||||
|
path.moveTo(center.x, center.y)
|
||||||
|
path.lineTo(left.x, left.y)
|
||||||
|
addClockwise(path, size, left, top)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
path.moveTo(center.x, center.y)
|
||||||
|
path.lineTo(top.x, top.y)
|
||||||
|
addClockwise(path, size, top, right)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path.lineTo(center.x, center.y)
|
||||||
|
path.close()
|
||||||
|
return Outline.Generic(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rayPoint(
|
||||||
|
size: Size,
|
||||||
|
angleDegrees: Double,
|
||||||
|
): Offset {
|
||||||
|
val cx = size.width / 2.0
|
||||||
|
val cy = size.height / 2.0
|
||||||
|
val radians = angleDegrees * PI / 180.0
|
||||||
|
val dx = cos(radians)
|
||||||
|
val dy = sin(radians)
|
||||||
|
val tx =
|
||||||
|
when {
|
||||||
|
dx > 0 -> (size.width - cx) / dx
|
||||||
|
dx < 0 -> -cx / dx
|
||||||
|
else -> Double.POSITIVE_INFINITY
|
||||||
|
}
|
||||||
|
val ty =
|
||||||
|
when {
|
||||||
|
dy > 0 -> (size.height - cy) / dy
|
||||||
|
dy < 0 -> -cy / dy
|
||||||
|
else -> Double.POSITIVE_INFINITY
|
||||||
|
}
|
||||||
|
val t = min(tx, ty)
|
||||||
|
return Offset((cx + dx * t).toFloat(), (cy + dy * t).toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addClockwise(
|
||||||
|
path: Path,
|
||||||
|
size: Size,
|
||||||
|
from: Offset,
|
||||||
|
to: Offset,
|
||||||
|
) {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val corners =
|
||||||
|
listOf(
|
||||||
|
Offset(0f, 0f),
|
||||||
|
Offset(w, 0f),
|
||||||
|
Offset(w, h),
|
||||||
|
Offset(0f, h),
|
||||||
|
)
|
||||||
|
val fromParam = perimeterParam(from, size)
|
||||||
|
val toParam = perimeterParam(to, size)
|
||||||
|
if (fromParam <= toParam) {
|
||||||
|
corners
|
||||||
|
.filter { perimeterParam(it, size) > fromParam && perimeterParam(it, size) < toParam }
|
||||||
|
.forEach { path.lineTo(it.x, it.y) }
|
||||||
|
} else {
|
||||||
|
corners
|
||||||
|
.filter { perimeterParam(it, size) > fromParam }
|
||||||
|
.forEach { path.lineTo(it.x, it.y) }
|
||||||
|
corners
|
||||||
|
.filter { perimeterParam(it, size) < toParam }
|
||||||
|
.forEach { path.lineTo(it.x, it.y) }
|
||||||
|
}
|
||||||
|
path.lineTo(to.x, to.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun perimeterParam(
|
||||||
|
point: Offset,
|
||||||
|
size: Size,
|
||||||
|
): Float {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
return when {
|
||||||
|
point.y <= 0f -> point.x
|
||||||
|
point.x >= w -> w + point.y
|
||||||
|
point.y >= h -> w + h + (w - point.x)
|
||||||
|
else -> 2f * w + h + (h - point.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Person
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
|
|
||||||
|
const val ARTIST_CHOOSER_TAG = "artistChooser"
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ArtistChooserSheet(
|
||||||
|
artists: List<ArtistRef>,
|
||||||
|
onArtistClick: (ArtistRef) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState,
|
||||||
|
dragHandle = null,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
modifier = Modifier.testTag(ARTIST_CHOOSER_TAG),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.artist_chooser_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 400.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
artists.forEach { artist ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(artist.name.ifBlank { stringResource(R.string.resources_filter_artist) }) },
|
||||||
|
leadingContent = { Icon(Icons.Rounded.Person, contentDescription = null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable { onArtistClick(artist) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,11 +85,16 @@ fun CoverArt(
|
|||||||
square: Boolean = true,
|
square: Boolean = true,
|
||||||
elevation: Dp = 0.dp,
|
elevation: Dp = 0.dp,
|
||||||
colorFilter: ColorFilter? = null,
|
colorFilter: ColorFilter? = null,
|
||||||
|
onError: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalPlatformContext.current
|
val context = LocalPlatformContext.current
|
||||||
var failed by remember(ref, thumbnailRef) { mutableStateOf(false) }
|
val refKey = ref?.cacheKey
|
||||||
var thumbnailLoaded by remember(ref, thumbnailRef) { mutableStateOf(false) }
|
val thumbnailKey = thumbnailRef?.cacheKey
|
||||||
var thumbnailRatio by remember(ref, thumbnailRef) { mutableStateOf(thumbnailRef?.cacheKey?.let(ArtworkRatioCache::get)) }
|
val hasThumbnailFallback = thumbnailRef != null && thumbnailRef != ref
|
||||||
|
var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||||
|
var thumbnailLoaded by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||||
|
var thumbnailFailed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||||
|
var thumbnailRatio by remember(refKey, thumbnailKey) { mutableStateOf(thumbnailKey?.let(ArtworkRatioCache::get)) }
|
||||||
val letterScheme =
|
val letterScheme =
|
||||||
if (showPlaceholder || (failed && !thumbnailLoaded)) {
|
if (showPlaceholder || (failed && !thumbnailLoaded)) {
|
||||||
remember(name) { artworkColorsFromSeed(placeholderSeed(name)).scheme }
|
remember(name) { artworkColorsFromSeed(placeholderSeed(name)).scheme }
|
||||||
@@ -102,7 +107,7 @@ fun CoverArt(
|
|||||||
.background(letterScheme?.primaryContainer ?: MaterialTheme.colorScheme.surfaceVariant)
|
.background(letterScheme?.primaryContainer ?: MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
|
||||||
if (square) {
|
if (square) {
|
||||||
val model = remember(ref, thumbnailRef) { ref?.let { imageRequest(context, it, crossfade = true) } }
|
val model = remember(refKey, thumbnailKey) { ref?.let { imageRequest(context, it, crossfade = true) } }
|
||||||
Box(modifier.then(frameModifier)) {
|
Box(modifier.then(frameModifier)) {
|
||||||
CoverArtContent(
|
CoverArtContent(
|
||||||
ref = ref,
|
ref = ref,
|
||||||
@@ -113,6 +118,10 @@ fun CoverArt(
|
|||||||
thumbnailLoaded = thumbnailLoaded,
|
thumbnailLoaded = thumbnailLoaded,
|
||||||
onThumbnailLoaded = { thumbnailLoaded = true },
|
onThumbnailLoaded = { thumbnailLoaded = true },
|
||||||
onThumbnailRatio = { thumbnailRatio = it },
|
onThumbnailRatio = { thumbnailRatio = it },
|
||||||
|
onThumbnailError = {
|
||||||
|
thumbnailFailed = true
|
||||||
|
if (failed) onError?.invoke()
|
||||||
|
},
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
colorFilter = colorFilter,
|
colorFilter = colorFilter,
|
||||||
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
||||||
@@ -129,7 +138,10 @@ fun CoverArt(
|
|||||||
.ratioOrNull()
|
.ratioOrNull()
|
||||||
?.let { ArtworkRatioCache.put(ref.cacheKey, it) }
|
?.let { ArtworkRatioCache.put(ref.cacheKey, it) }
|
||||||
},
|
},
|
||||||
onError = { failed = true },
|
onError = {
|
||||||
|
failed = true
|
||||||
|
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -147,7 +159,7 @@ fun CoverArt(
|
|||||||
val painter =
|
val painter =
|
||||||
rememberAsyncImagePainter(
|
rememberAsyncImagePainter(
|
||||||
model =
|
model =
|
||||||
remember(ref, thumbnailRef, target) {
|
remember(refKey, thumbnailKey, target) {
|
||||||
ref?.let {
|
ref?.let {
|
||||||
imageRequest(
|
imageRequest(
|
||||||
context,
|
context,
|
||||||
@@ -158,7 +170,10 @@ fun CoverArt(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError = { failed = true },
|
onError = {
|
||||||
|
failed = true
|
||||||
|
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
|
||||||
|
},
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
filterQuality = FilterQuality.High,
|
filterQuality = FilterQuality.High,
|
||||||
)
|
)
|
||||||
@@ -177,6 +192,10 @@ fun CoverArt(
|
|||||||
thumbnailLoaded = thumbnailLoaded,
|
thumbnailLoaded = thumbnailLoaded,
|
||||||
onThumbnailLoaded = { thumbnailLoaded = true },
|
onThumbnailLoaded = { thumbnailLoaded = true },
|
||||||
onThumbnailRatio = { thumbnailRatio = it },
|
onThumbnailRatio = { thumbnailRatio = it },
|
||||||
|
onThumbnailError = {
|
||||||
|
thumbnailFailed = true
|
||||||
|
if (failed) onError?.invoke()
|
||||||
|
},
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
colorFilter = colorFilter,
|
colorFilter = colorFilter,
|
||||||
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
||||||
@@ -206,6 +225,7 @@ private fun BoxScope.CoverArtContent(
|
|||||||
thumbnailLoaded: Boolean,
|
thumbnailLoaded: Boolean,
|
||||||
onThumbnailLoaded: () -> Unit,
|
onThumbnailLoaded: () -> Unit,
|
||||||
onThumbnailRatio: (Float) -> Unit,
|
onThumbnailRatio: (Float) -> Unit,
|
||||||
|
onThumbnailError: () -> Unit,
|
||||||
contentScale: ContentScale,
|
contentScale: ContentScale,
|
||||||
colorFilter: ColorFilter?,
|
colorFilter: ColorFilter?,
|
||||||
placeholderContentColor: Color?,
|
placeholderContentColor: Color?,
|
||||||
@@ -231,7 +251,7 @@ private fun BoxScope.CoverArtContent(
|
|||||||
}
|
}
|
||||||
if (thumbnailRef != null && thumbnailRef != ref) {
|
if (thumbnailRef != null && thumbnailRef != ref) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = remember(thumbnailRef) { imageRequest(context, thumbnailRef, crossfade = false) },
|
model = remember(thumbnailRef.cacheKey) { imageRequest(context, thumbnailRef, crossfade = false) },
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
colorFilter = colorFilter,
|
colorFilter = colorFilter,
|
||||||
@@ -244,6 +264,7 @@ private fun BoxScope.CoverArtContent(
|
|||||||
ArtworkRatioCache.put(thumbnailRef.cacheKey, it)
|
ArtworkRatioCache.put(thumbnailRef.cacheKey, it)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onError = { onThumbnailError() },
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.statusBars
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -47,11 +48,17 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -77,6 +84,7 @@ import com.subtracks.ui.theme.ArtworkTheme
|
|||||||
import com.subtracks.ui.theme.HeroGradient
|
import com.subtracks.ui.theme.HeroGradient
|
||||||
import com.subtracks.ui.theme.baseArtworkColors
|
import com.subtracks.ui.theme.baseArtworkColors
|
||||||
import com.subtracks.ui.theme.heroBarColor
|
import com.subtracks.ui.theme.heroBarColor
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private const val FADE_DISTANCE_DP = 64
|
private const val FADE_DISTANCE_DP = 64
|
||||||
|
|
||||||
@@ -96,12 +104,14 @@ fun HeroDetailScaffold(
|
|||||||
header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit,
|
header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit,
|
||||||
content: LazyListScope.(rowModifier: Modifier) -> Unit,
|
content: LazyListScope.(rowModifier: Modifier) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
contentReady: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val effectiveArtwork = artwork ?: baseArtworkColors()
|
val effectiveArtwork = artwork ?: baseArtworkColors()
|
||||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||||
ArtworkTheme(effectiveArtwork) {
|
ArtworkTheme(effectiveArtwork) {
|
||||||
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberRestorableLazyListState(contentReady)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val screenHeightPx = with(density) { maxHeight.toPx() }
|
val screenHeightPx = with(density) { maxHeight.toPx() }
|
||||||
@@ -111,9 +121,9 @@ fun HeroDetailScaffold(
|
|||||||
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
||||||
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
|
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
|
||||||
|
|
||||||
var headerHeightPx by remember { mutableFloatStateOf(0f) }
|
var headerHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
var rowHeightPx by remember { mutableFloatStateOf(0f) }
|
var rowHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
var controlsTopPx by remember { mutableFloatStateOf(0f) }
|
var controlsTopPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
val scrollPx by remember {
|
val scrollPx by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val index = listState.firstVisibleItemIndex
|
val index = listState.firstVisibleItemIndex
|
||||||
@@ -195,13 +205,39 @@ fun HeroDetailScaffold(
|
|||||||
.clickable(
|
.clickable(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null,
|
indication = null,
|
||||||
) {},
|
onClickLabel = stringResource(R.string.scroll_to_top),
|
||||||
|
) { scope.launch { listState.animateScrollToItem(0) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberRestorableLazyListState(contentReady: Boolean): LazyListState {
|
||||||
|
val state = rememberLazyListState()
|
||||||
|
var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) }
|
||||||
|
var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) }
|
||||||
|
var pending by remember { mutableStateOf(index > 0 || offset > 0) }
|
||||||
|
LaunchedEffect(contentReady) {
|
||||||
|
if (contentReady && pending) {
|
||||||
|
val target = index.coerceAtMost((state.layoutInfo.totalItemsCount - 1).coerceAtLeast(0))
|
||||||
|
state.scrollToItem(target, offset)
|
||||||
|
pending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }
|
||||||
|
.collect { (currentIndex, currentOffset) ->
|
||||||
|
if (!pending) {
|
||||||
|
index = currentIndex
|
||||||
|
offset = currentOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HeroSubtitle(
|
private fun HeroSubtitle(
|
||||||
text: String,
|
text: String,
|
||||||
@@ -253,6 +289,7 @@ fun HeroHeader(
|
|||||||
thumbnailRef: CoverArtRef? = null,
|
thumbnailRef: CoverArtRef? = null,
|
||||||
comment: String? = null,
|
comment: String? = null,
|
||||||
onSubtitleClick: (() -> Unit)? = null,
|
onSubtitleClick: (() -> Unit)? = null,
|
||||||
|
nameMaxLines: Int = 2,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -274,7 +311,7 @@ fun HeroHeader(
|
|||||||
text = name,
|
text = name,
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
maxLines = 2,
|
maxLines = nameMaxLines,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
val subtitleIsTarget = onSubtitleClick != null && subtitle.isNotEmpty() && comment.isNullOrBlank()
|
val subtitleIsTarget = onSubtitleClick != null && subtitle.isNotEmpty() && comment.isNullOrBlank()
|
||||||
@@ -286,7 +323,11 @@ fun HeroHeader(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.heightIn(min = 32.dp)
|
.heightIn(min = 32.dp)
|
||||||
.clickable(onClickLabel = stringResource(R.string.open_artist)) { onSubtitleClick() },
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClickLabel = stringResource(R.string.open_artist),
|
||||||
|
) { onSubtitleClick() },
|
||||||
contentAlignment = Alignment.TopCenter,
|
contentAlignment = Alignment.TopCenter,
|
||||||
) {
|
) {
|
||||||
HeroSubtitle(subtitle, Modifier.padding(top = 4.dp))
|
HeroSubtitle(subtitle, Modifier.padding(top = 4.dp))
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
|
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
|
||||||
import androidx.compose.material.icons.rounded.Album
|
import androidx.compose.material.icons.rounded.Album
|
||||||
import androidx.compose.material.icons.rounded.Cancel
|
import androidx.compose.material.icons.rounded.Cancel
|
||||||
@@ -47,6 +51,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.BulkDownloadAction
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.DownloadList
|
import com.subtracks.data.model.DownloadList
|
||||||
@@ -77,7 +82,7 @@ sealed interface MenuTarget {
|
|||||||
) : MenuTarget {
|
) : MenuTarget {
|
||||||
override val title: String get() = song.title
|
override val title: String get() = song.title
|
||||||
|
|
||||||
override val subtitle: String? get() = song.artist
|
override val subtitle: String? get() = song.artistSubtitle()
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Album(
|
data class Album(
|
||||||
@@ -194,7 +199,8 @@ fun ItemContextMenu(
|
|||||||
target: MenuTarget,
|
target: MenuTarget,
|
||||||
actions: ItemActions,
|
actions: ItemActions,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onInfo: ((SongModel) -> Unit)? = null,
|
onInfo: ((MenuTarget) -> Unit)? = null,
|
||||||
|
onChooseArtists: ((List<ArtistRef>) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -207,6 +213,11 @@ fun ItemContextMenu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun replaceWith(after: () -> Unit) {
|
||||||
|
onDismiss()
|
||||||
|
after()
|
||||||
|
}
|
||||||
|
|
||||||
val queueRef = target.queueRef()
|
val queueRef = target.queueRef()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -226,76 +237,75 @@ fun ItemContextMenu(
|
|||||||
dragHandle = null,
|
dragHandle = null,
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
) {
|
) {
|
||||||
MenuHeader(target)
|
Column(
|
||||||
when (target) {
|
modifier =
|
||||||
is MenuTarget.Song -> {
|
Modifier
|
||||||
queueItems()
|
.fillMaxWidth()
|
||||||
DownloadItem(target.download, target.song, actions, ::dismiss)
|
.heightIn(max = MENU_MAX_HEIGHT)
|
||||||
StarItem(
|
.verticalScroll(rememberScrollState()),
|
||||||
current = target.song.starred,
|
) {
|
||||||
onSet = { starring -> actions.setStar(StarType.Song, target.song.id, starring) },
|
MenuHeader(target)
|
||||||
onDismiss = { dismiss() },
|
when (target) {
|
||||||
)
|
is MenuTarget.Song -> {
|
||||||
target.song.albumId?.let { albumId ->
|
queueItems()
|
||||||
actions.viewAlbum?.let { view ->
|
DownloadItem(target.download, target.song, actions, ::dismiss)
|
||||||
MenuItem(
|
StarItem(
|
||||||
Icons.Rounded.Album,
|
current = target.song.starred,
|
||||||
target.song.album?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_album),
|
onSet = { starring -> actions.setStar(StarType.Song, target.song.id, starring) },
|
||||||
) {
|
onDismiss = { dismiss() },
|
||||||
dismiss { view(albumId) }
|
)
|
||||||
|
target.song.albumId?.let { albumId ->
|
||||||
|
actions.viewAlbum?.let { view ->
|
||||||
|
MenuItem(
|
||||||
|
Icons.Rounded.Album,
|
||||||
|
target.song.album?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_album),
|
||||||
|
) {
|
||||||
|
dismiss { view(albumId) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
ArtistItems(target.song.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||||
target.song.artistId?.let { artistId ->
|
onInfo?.let { info ->
|
||||||
actions.viewArtist?.let { view ->
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
MenuItem(
|
|
||||||
Icons.Rounded.Person,
|
|
||||||
target.song.artist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
|
||||||
) {
|
|
||||||
dismiss { view(artistId) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onInfo?.let { info ->
|
|
||||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target.song) } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is MenuTarget.Album -> {
|
is MenuTarget.Album -> {
|
||||||
queueItems()
|
queueItems()
|
||||||
BulkDownloadItem(target, actions, ::dismiss)
|
BulkDownloadItem(target, actions, ::dismiss)
|
||||||
StarItem(
|
StarItem(
|
||||||
current = target.album.starred,
|
current = target.album.starred,
|
||||||
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
||||||
onDismiss = { dismiss() },
|
onDismiss = { dismiss() },
|
||||||
)
|
)
|
||||||
target.album.artistId?.let { artistId ->
|
ArtistItems(target.album.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||||
actions.viewArtist?.let { view ->
|
onInfo?.let { info ->
|
||||||
MenuItem(
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
Icons.Rounded.Person,
|
}
|
||||||
target.album.albumArtist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
}
|
||||||
) {
|
|
||||||
dismiss { view(artistId) }
|
is MenuTarget.Artist -> {
|
||||||
}
|
BulkDownloadItem(target, actions, ::dismiss)
|
||||||
|
StarItem(
|
||||||
|
current = target.artist.starred,
|
||||||
|
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
|
||||||
|
onDismiss = { dismiss() },
|
||||||
|
)
|
||||||
|
onInfo?.let { info ->
|
||||||
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is MenuTarget.Playlist -> {
|
||||||
|
queueItems()
|
||||||
|
BulkDownloadItem(target, actions, ::dismiss)
|
||||||
|
onInfo?.let { info ->
|
||||||
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
is MenuTarget.Artist -> {
|
|
||||||
BulkDownloadItem(target, actions, ::dismiss)
|
|
||||||
StarItem(
|
|
||||||
current = target.artist.starred,
|
|
||||||
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
|
|
||||||
onDismiss = { dismiss() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is MenuTarget.Playlist -> {
|
|
||||||
queueItems()
|
|
||||||
BulkDownloadItem(target, actions, ::dismiss)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +341,55 @@ private fun MenuHeader(target: MenuTarget) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ArtistItems(
|
||||||
|
artists: List<ArtistRef>,
|
||||||
|
actions: ItemActions,
|
||||||
|
onChooseArtists: ((List<ArtistRef>) -> Unit)?,
|
||||||
|
dismiss: (() -> Unit) -> Unit,
|
||||||
|
replaceWith: (() -> Unit) -> Unit,
|
||||||
|
) {
|
||||||
|
val view = actions.viewArtist ?: return
|
||||||
|
val distinct = artists.distinctBy { it.id }
|
||||||
|
when (distinct.size) {
|
||||||
|
0 -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
1 -> {
|
||||||
|
val artist = distinct.first()
|
||||||
|
MenuItem(
|
||||||
|
Icons.Rounded.Person,
|
||||||
|
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||||
|
) {
|
||||||
|
dismiss { view(artist.id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
if (onChooseArtists == null) {
|
||||||
|
distinct.forEach { artist ->
|
||||||
|
MenuItem(
|
||||||
|
Icons.Rounded.Person,
|
||||||
|
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||||
|
) {
|
||||||
|
dismiss { view(artist.id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val fallback = stringResource(R.string.resources_filter_artist)
|
||||||
|
MenuItem(
|
||||||
|
Icons.Rounded.Person,
|
||||||
|
distinct.joinToString(" \u00B7 ") { it.name.ifBlank { fallback } },
|
||||||
|
trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) },
|
||||||
|
) {
|
||||||
|
replaceWith { onChooseArtists(distinct) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DownloadItem(
|
private fun DownloadItem(
|
||||||
download: SongDownload?,
|
download: SongDownload?,
|
||||||
@@ -428,11 +487,19 @@ private fun MenuItem(
|
|||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
label: String,
|
label: String,
|
||||||
tint: Color? = null,
|
tint: Color? = null,
|
||||||
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(label) },
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
leadingContent = { Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) },
|
leadingContent = { Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) },
|
||||||
|
trailingContent = trailing,
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
)
|
)
|
||||||
@@ -445,3 +512,4 @@ private fun starLabel(starred: Long?): String =
|
|||||||
if (starred == null) stringResource(R.string.actions_star) else stringResource(R.string.actions_unstar)
|
if (starred == null) stringResource(R.string.actions_star) else stringResource(R.string.actions_unstar)
|
||||||
|
|
||||||
private const val STAR_DISMISS_DELAY_MS = 200L
|
private const val STAR_DISMISS_DELAY_MS = 200L
|
||||||
|
private val MENU_MAX_HEIGHT = 560.dp
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
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.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
|
import com.subtracks.data.model.Playlist
|
||||||
|
|
||||||
|
private const val ARTIST_SEPARATOR = " \u00B7 "
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AlbumInfoDialog(
|
||||||
|
album: Album,
|
||||||
|
download: ListDownloadStatus?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.info)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
InfoRow(stringResource(R.string.resources_sort_by_title), album.name)
|
||||||
|
InfoRow(stringResource(R.string.info_sort_title), album.sortName.takeIfDifferentFrom(album.name))
|
||||||
|
InfoRow(stringResource(R.string.info_album_artists), album.artistRefs.names())
|
||||||
|
InfoRow(stringResource(R.string.info_album_artist_sort), album.albumArtistSort.takeIfDifferentFrom(album.albumArtist))
|
||||||
|
InfoRow(stringResource(R.string.resources_sort_by_year), album.year?.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.resources_filter_genre), album.genre)
|
||||||
|
InfoRow(stringResource(R.string.info_songs), album.songCount.takeIf { it > 0 }?.toString())
|
||||||
|
download?.takeIf { it.total > 0 }?.let { status ->
|
||||||
|
InfoRow(
|
||||||
|
stringResource(R.string.status_downloaded),
|
||||||
|
stringResource(R.string.info_downloaded_count, status.downloaded, status.total),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
InfoRow(stringResource(R.string.info_added), album.created.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||||
|
InfoRow(stringResource(R.string.info_last_played), album.played?.let { formatDate(context, it) })
|
||||||
|
InfoRow(stringResource(R.string.info_plays), album.playCount.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.info_starred), if (album.starred != null) stringResource(R.string.info_yes) else null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ArtistInfoDialog(
|
||||||
|
artist: Artist,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.info)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
InfoRow(stringResource(R.string.resources_filter_artist), artist.name)
|
||||||
|
InfoRow(stringResource(R.string.info_sort_name), artist.sortName.takeIfDifferentFrom(artist.name))
|
||||||
|
InfoRow(stringResource(R.string.info_albums), artist.albumCount.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.info_songs), artist.songCount.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.info_plays), artist.playCount.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.info_last_played), artist.played?.let { formatDate(context, it) })
|
||||||
|
InfoRow(stringResource(R.string.info_starred), if (artist.starred != null) stringResource(R.string.info_yes) else null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlaylistInfoDialog(
|
||||||
|
playlist: Playlist,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.info)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
InfoRow(stringResource(R.string.resources_sort_by_title), playlist.name)
|
||||||
|
InfoRow(stringResource(R.string.info_owner), playlist.owner)
|
||||||
|
InfoRow(stringResource(R.string.info_shared), if (playlist.shared) stringResource(R.string.info_yes) else null)
|
||||||
|
InfoRow(stringResource(R.string.info_songs), playlist.songCount.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.info_length), playlist.duration.takeIf { it > 0 }?.let(DateUtils::formatElapsedTime))
|
||||||
|
InfoRow(stringResource(R.string.info_created), playlist.created.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||||
|
InfoRow(stringResource(R.string.info_updated), playlist.changed.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String?.takeIfDifferentFrom(regular: String?): String? =
|
||||||
|
this?.trim()?.takeIf { value -> value.isNotEmpty() && !value.equals(regular?.trim(), ignoreCase = true) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun InfoRow(
|
||||||
|
label: String,
|
||||||
|
value: String?,
|
||||||
|
) {
|
||||||
|
if (value.isNullOrBlank()) return
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.width(INFO_LABEL_WIDTH),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(INFO_LABEL_GAP))
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<ArtistRef>.names(): String? =
|
||||||
|
map { it.name }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
.joinToString(ARTIST_SEPARATOR)
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
private fun formatDate(
|
||||||
|
context: Context,
|
||||||
|
epochSeconds: Long,
|
||||||
|
): String =
|
||||||
|
DateUtils.formatDateTime(
|
||||||
|
context,
|
||||||
|
epochSeconds * 1000,
|
||||||
|
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val INFO_LABEL_WIDTH = 100.dp
|
||||||
|
private val INFO_LABEL_GAP = 12.dp
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Shuffle
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.subtracks.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ShuffleFab(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
visible: Boolean = true,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn() + scaleIn(),
|
||||||
|
exit = fadeOut() + scaleOut(),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
FloatingActionButton(onClick = onClick) {
|
||||||
|
Icon(Icons.Rounded.Shuffle, contentDescription = stringResource(R.string.controls_shuffle_play))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,10 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
@@ -28,6 +32,8 @@ import androidx.compose.ui.graphics.SolidColor
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
@@ -43,6 +49,12 @@ fun SearchField(
|
|||||||
) {
|
) {
|
||||||
val hint = stringResource(R.string.search_input_placeholder)
|
val hint = stringResource(R.string.search_input_placeholder)
|
||||||
val closeLabel = stringResource(R.string.close_search)
|
val closeLabel = stringResource(R.string.close_search)
|
||||||
|
var textFieldValue by remember { mutableStateOf(TextFieldValue(value, selection = TextRange(value.length))) }
|
||||||
|
LaunchedEffect(value) {
|
||||||
|
if (value != textFieldValue.text) {
|
||||||
|
textFieldValue = TextFieldValue(value, selection = TextRange(value.length))
|
||||||
|
}
|
||||||
|
}
|
||||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
@@ -62,8 +74,11 @@ fun SearchField(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = value,
|
value = textFieldValue,
|
||||||
onValueChange = onValueChange,
|
onValueChange = {
|
||||||
|
textFieldValue = it
|
||||||
|
onValueChange(it.text)
|
||||||
|
},
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
|
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
@@ -73,7 +88,7 @@ fun SearchField(
|
|||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
.semantics { contentDescription = hint },
|
.semantics { contentDescription = hint },
|
||||||
decorationBox = { inner ->
|
decorationBox = { inner ->
|
||||||
if (value.isEmpty()) {
|
if (textFieldValue.text.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = hint,
|
text = hint,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
|
||||||
|
private const val ARTIST_SEPARATOR = " \u00B7 "
|
||||||
|
|
||||||
|
internal fun Song.artistSubtitle(): String? = artistSubtitle(artist, artists)
|
||||||
|
|
||||||
|
internal fun Song.artistNames(): List<String> = artistNames(artist, artists)
|
||||||
|
|
||||||
|
internal fun artistNames(
|
||||||
|
artist: String?,
|
||||||
|
artists: List<ArtistRef>,
|
||||||
|
): List<String> {
|
||||||
|
val names = artists.map { it.name }.filter { it.isNotBlank() }.distinct()
|
||||||
|
return names.ifEmpty { listOfNotNull(artist?.takeIf { it.isNotBlank() }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun artistSubtitle(
|
||||||
|
artist: String?,
|
||||||
|
artists: List<ArtistRef>,
|
||||||
|
): String? {
|
||||||
|
val names = artistNames(artist, artists)
|
||||||
|
if (names.isEmpty()) return null
|
||||||
|
val primary = artist?.takeIf { it in names }
|
||||||
|
return buildList {
|
||||||
|
primary?.let(::add)
|
||||||
|
addAll(names.filterNot { it == primary })
|
||||||
|
}.joinToString(ARTIST_SEPARATOR)
|
||||||
|
}
|
||||||
@@ -4,12 +4,7 @@ import android.text.format.DateUtils
|
|||||||
import android.text.format.Formatter
|
import android.text.format.Formatter
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -18,8 +13,6 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -53,8 +46,20 @@ fun SongInfoDialog(
|
|||||||
title = { Text(stringResource(R.string.info)) },
|
title = { Text(stringResource(R.string.info)) },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
val multipleArtists = song.artistNames().size > 1
|
||||||
|
val regularArtistSort = song.artist?.takeIf { it.isNotBlank() } ?: song.artistRefs.firstOrNull()?.name
|
||||||
InfoRow(stringResource(R.string.resources_sort_by_title), song.title)
|
InfoRow(stringResource(R.string.resources_sort_by_title), song.title)
|
||||||
InfoRow(stringResource(R.string.resources_filter_artist), song.artist)
|
InfoRow(stringResource(R.string.info_sort_title), song.sortName.takeIfDifferentFrom(song.title))
|
||||||
|
InfoRow(
|
||||||
|
stringResource(
|
||||||
|
if (multipleArtists) R.string.info_artists else R.string.resources_filter_artist,
|
||||||
|
),
|
||||||
|
song.artistSubtitle(),
|
||||||
|
)
|
||||||
|
InfoRow(
|
||||||
|
stringResource(if (multipleArtists) R.string.info_artists_sort else R.string.info_artist_sort),
|
||||||
|
song.artistSort.takeIfDifferentFrom(regularArtistSort),
|
||||||
|
)
|
||||||
InfoRow(stringResource(R.string.resources_filter_album), song.album)
|
InfoRow(stringResource(R.string.resources_filter_album), song.album)
|
||||||
InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
|
InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
|
||||||
InfoRow(
|
InfoRow(
|
||||||
@@ -92,32 +97,6 @@ fun SongInfoDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun InfoRow(
|
|
||||||
label: String,
|
|
||||||
value: String?,
|
|
||||||
) {
|
|
||||||
if (value.isNullOrBlank()) return
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.width(INFO_LABEL_WIDTH),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = value,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val INFO_LABEL_WIDTH = 80.dp
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun downloadStatus(status: DownloadStatus): String =
|
private fun downloadStatus(status: DownloadStatus): String =
|
||||||
when (status) {
|
when (status) {
|
||||||
|
|||||||
@@ -88,3 +88,34 @@ fun rememberViewportFill(state: LazyGridState): Dp {
|
|||||||
}
|
}
|
||||||
return with(density) { fillPx.toDp() }
|
return with(density) { fillPx.toDp() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls to the top when [request] changes to a new non-null value, but not when it merely becomes
|
||||||
|
* non-null. Selecting a page passes it the current request; only a fresh request (a tab re-tap)
|
||||||
|
* should move the list, so a stale scroll position survives tab switches.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ScrollToTopOnRequest(
|
||||||
|
request: Any?,
|
||||||
|
state: LazyListState,
|
||||||
|
) {
|
||||||
|
var previous by remember { mutableStateOf(request) }
|
||||||
|
LaunchedEffect(request) {
|
||||||
|
val stale = previous
|
||||||
|
previous = request
|
||||||
|
if (request != null && stale != null && request != stale) state.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ScrollToTopOnRequest(
|
||||||
|
request: Any?,
|
||||||
|
state: LazyGridState,
|
||||||
|
) {
|
||||||
|
var previous by remember { mutableStateOf(request) }
|
||||||
|
LaunchedEffect(request) {
|
||||||
|
val stale = previous
|
||||||
|
previous = request
|
||||||
|
if (request != null && stale != null && request != stale) state.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.rounded.Close
|
import androidx.compose.material.icons.rounded.Close
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
import androidx.compose.material.icons.rounded.Delete
|
||||||
import androidx.compose.material.icons.rounded.ExpandLess
|
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.rounded.ExpandMore
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -274,7 +274,7 @@ private fun NodeRow(
|
|||||||
leadingContent = {
|
leadingContent = {
|
||||||
IconButton(onClick = onToggle) {
|
IconButton(onClick = onToggle) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
|
imageVector = if (expanded) Icons.Rounded.KeyboardArrowDown else Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||||
contentDescription = if (expanded) stringResource(R.string.collapse) else stringResource(R.string.expand),
|
contentDescription = if (expanded) stringResource(R.string.collapse) else stringResource(R.string.expand),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ data class DownloadArtistNode(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val bytes: Long,
|
val bytes: Long,
|
||||||
val albums: List<DownloadAlbumNode>,
|
val albums: List<DownloadAlbumNode>,
|
||||||
|
val sortName: String = name,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DownloadAlbumNode(
|
data class DownloadAlbumNode(
|
||||||
@@ -38,6 +39,7 @@ data class DownloadAlbumNode(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val bytes: Long,
|
val bytes: Long,
|
||||||
val songs: List<DownloadedSong>,
|
val songs: List<DownloadedSong>,
|
||||||
|
val sortName: String = name,
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@@ -105,6 +107,7 @@ internal fun buildTree(
|
|||||||
id = artistKey.first,
|
id = artistKey.first,
|
||||||
name = artistKey.second.ifBlank { unknownArtist },
|
name = artistKey.second.ifBlank { unknownArtist },
|
||||||
bytes = artistSongs.sumOf { it.size },
|
bytes = artistSongs.sumOf { it.size },
|
||||||
|
sortName = artistSongs.first().artistSort.ifBlank { artistKey.second },
|
||||||
albums =
|
albums =
|
||||||
artistSongs
|
artistSongs
|
||||||
.groupBy { it.albumId.orEmpty() to it.albumName.orEmpty() }
|
.groupBy { it.albumId.orEmpty() to it.albumName.orEmpty() }
|
||||||
@@ -113,9 +116,10 @@ internal fun buildTree(
|
|||||||
id = albumKey.first,
|
id = albumKey.first,
|
||||||
name = albumKey.second.ifBlank { unknownAlbum },
|
name = albumKey.second.ifBlank { unknownAlbum },
|
||||||
bytes = albumSongs.sumOf { it.size },
|
bytes = albumSongs.sumOf { it.size },
|
||||||
songs = albumSongs.sortedBy { it.title.lowercase() },
|
sortName = albumSongs.first().albumSort.ifBlank { albumKey.second },
|
||||||
|
songs = albumSongs,
|
||||||
)
|
)
|
||||||
}.sortedBy { it.name.lowercase() },
|
}.sortedBy { it.sortName.lowercase() },
|
||||||
)
|
)
|
||||||
}.sortedBy { it.name.lowercase() },
|
}.sortedBy { it.sortName.lowercase() },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -31,6 +33,7 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -54,13 +57,17 @@ import com.subtracks.data.model.Playlist
|
|||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.ArtistIcon
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
|
import com.subtracks.ui.components.ShuffleFab
|
||||||
import com.subtracks.ui.library.PlaylistsContent
|
import com.subtracks.ui.library.PlaylistsContent
|
||||||
import com.subtracks.ui.library.SongRow
|
import com.subtracks.ui.library.SongRow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
@@ -103,6 +110,7 @@ fun HomeListRoute(
|
|||||||
genres = genres,
|
genres = genres,
|
||||||
decades = decades,
|
decades = decades,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
playingSongId = playback.item?.id,
|
playingSongId = playback.item?.id,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
@@ -110,6 +118,7 @@ fun HomeListRoute(
|
|||||||
onPlaylistClick = onPlaylistClick,
|
onPlaylistClick = onPlaylistClick,
|
||||||
onSongClick = viewModel::play,
|
onSongClick = viewModel::play,
|
||||||
onLongClick = { contextMenuHost?.show(it, actions) },
|
onLongClick = { contextMenuHost?.show(it, actions) },
|
||||||
|
onShuffle = if (viewModel.canShuffle) viewModel::shuffle else null,
|
||||||
onGenreClick = onGenreClick,
|
onGenreClick = onGenreClick,
|
||||||
onDecadeClick = onDecadeClick,
|
onDecadeClick = onDecadeClick,
|
||||||
)
|
)
|
||||||
@@ -127,6 +136,7 @@ fun HomeListScreen(
|
|||||||
genres: List<String> = emptyList(),
|
genres: List<String> = emptyList(),
|
||||||
decades: List<Long> = emptyList(),
|
decades: List<Long> = emptyList(),
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
playingSongId: String? = null,
|
playingSongId: String? = null,
|
||||||
onBack: () -> Unit = {},
|
onBack: () -> Unit = {},
|
||||||
onAlbumClick: (Album) -> Unit = {},
|
onAlbumClick: (Album) -> Unit = {},
|
||||||
@@ -134,11 +144,14 @@ fun HomeListScreen(
|
|||||||
onPlaylistClick: (Playlist) -> Unit = {},
|
onPlaylistClick: (Playlist) -> Unit = {},
|
||||||
onSongClick: (Song) -> Unit = {},
|
onSongClick: (Song) -> Unit = {},
|
||||||
onLongClick: (MenuTarget) -> Unit = {},
|
onLongClick: (MenuTarget) -> Unit = {},
|
||||||
|
onShuffle: (() -> Unit)? = null,
|
||||||
onGenreClick: (String) -> Unit = {},
|
onGenreClick: (String) -> Unit = {},
|
||||||
onDecadeClick: (Long) -> Unit = {},
|
onDecadeClick: (Long) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val now = remember { System.currentTimeMillis() }
|
val now = remember { System.currentTimeMillis() }
|
||||||
val resources = LocalResources.current
|
val resources = LocalResources.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val songListState = rememberLazyListState()
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -149,8 +162,21 @@ fun HomeListScreen(
|
|||||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
modifier =
|
||||||
|
if (onShuffle != null) {
|
||||||
|
Modifier.clickable(onClickLabel = stringResource(R.string.scroll_to_top)) {
|
||||||
|
scope.launch { songListState.animateScrollToItem(0) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
if (onShuffle != null && songs.itemCount > 0) {
|
||||||
|
ShuffleFab(onClick = onShuffle)
|
||||||
|
}
|
||||||
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
val downloaded = request.downloaded
|
val downloaded = request.downloaded
|
||||||
if (downloaded != null) {
|
if (downloaded != null) {
|
||||||
@@ -169,7 +195,7 @@ fun HomeListScreen(
|
|||||||
OfflineListKind.Artists -> {
|
OfflineListKind.Artists -> {
|
||||||
ArtistsList(
|
ArtistsList(
|
||||||
artists = artists,
|
artists = artists,
|
||||||
coverArt = coverArt,
|
artistArt = artistArt,
|
||||||
info = { artistListInfo(request, it, now, resources) },
|
info = { artistListInfo(request, it, now, resources) },
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
@@ -184,6 +210,7 @@ fun HomeListScreen(
|
|||||||
playingSongId = playingSongId,
|
playingSongId = playingSongId,
|
||||||
onSongClick = onSongClick,
|
onSongClick = onSongClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
listState = songListState,
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -222,7 +249,7 @@ fun HomeListScreen(
|
|||||||
HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> {
|
HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> {
|
||||||
ArtistsList(
|
ArtistsList(
|
||||||
artists = artists,
|
artists = artists,
|
||||||
coverArt = coverArt,
|
artistArt = artistArt,
|
||||||
info = { artistListInfo(request, it, now, resources) },
|
info = { artistListInfo(request, it, now, resources) },
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
@@ -237,6 +264,7 @@ fun HomeListScreen(
|
|||||||
playingSongId = playingSongId,
|
playingSongId = playingSongId,
|
||||||
onSongClick = onSongClick,
|
onSongClick = onSongClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
listState = songListState,
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -254,6 +282,7 @@ fun HomeListScreen(
|
|||||||
playingSongId = playingSongId,
|
playingSongId = playingSongId,
|
||||||
onSongClick = onSongClick,
|
onSongClick = onSongClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
listState = songListState,
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -297,6 +326,7 @@ private fun SongsList(
|
|||||||
playingSongId: String?,
|
playingSongId: String?,
|
||||||
onSongClick: (Song) -> Unit,
|
onSongClick: (Song) -> Unit,
|
||||||
onLongClick: (MenuTarget) -> Unit,
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
|
listState: LazyListState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (songs.itemCount == 0 && songs.loadState.refresh is LoadState.Loading) {
|
if (songs.itemCount == 0 && songs.loadState.refresh is LoadState.Loading) {
|
||||||
@@ -304,8 +334,9 @@ private fun SongsList(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 24.dp),
|
contentPadding = PaddingValues(bottom = 88.dp),
|
||||||
) {
|
) {
|
||||||
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
|
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
|
||||||
val item = songs[index]
|
val item = songs[index]
|
||||||
@@ -416,7 +447,7 @@ internal fun AlbumListRow(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ArtistsList(
|
private fun ArtistsList(
|
||||||
artists: LazyPagingItems<Artist>,
|
artists: LazyPagingItems<Artist>,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
artistArt: (Artist) -> ArtistArt,
|
||||||
info: (Artist) -> String?,
|
info: (Artist) -> String?,
|
||||||
onArtistClick: (Artist) -> Unit,
|
onArtistClick: (Artist) -> Unit,
|
||||||
onLongClick: (MenuTarget) -> Unit,
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
@@ -433,12 +464,13 @@ private fun ArtistsList(
|
|||||||
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
||||||
val artist = artists[index]
|
val artist = artists[index]
|
||||||
if (artist != null) {
|
if (artist != null) {
|
||||||
|
val art = artistArt(artist)
|
||||||
ArtistListRow(
|
ArtistListRow(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
coverArt = coverArt,
|
art = art,
|
||||||
info = info(artist),
|
info = info(artist),
|
||||||
onClick = { onArtistClick(artist) },
|
onClick = { onArtistClick(artist) },
|
||||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
|
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -448,7 +480,7 @@ private fun ArtistsList(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun ArtistListRow(
|
internal fun ArtistListRow(
|
||||||
artist: Artist,
|
artist: Artist,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
art: ArtistArt,
|
||||||
info: String?,
|
info: String?,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
@@ -461,9 +493,9 @@ internal fun ArtistListRow(
|
|||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
CoverArt(
|
ArtistIcon(
|
||||||
ref = coverArt(artist.coverArt, true),
|
artist = artist,
|
||||||
name = artist.name,
|
art = art,
|
||||||
modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape),
|
modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
|
|||||||
@@ -16,14 +16,18 @@ import com.subtracks.data.prefs.PlaylistSort
|
|||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.artistArt
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class HomeListRequest(
|
data class HomeListRequest(
|
||||||
val title: String,
|
val title: String,
|
||||||
@@ -51,6 +55,11 @@ class HomeListViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
|
|
||||||
|
val canShuffle: Boolean =
|
||||||
|
request.genre != null ||
|
||||||
|
request.section == HomeSection.RecentlyStarredSongs ||
|
||||||
|
request.downloaded == OfflineListKind.Songs
|
||||||
|
|
||||||
val albums: Flow<PagingData<Album>> =
|
val albums: Flow<PagingData<Album>> =
|
||||||
sourceId
|
sourceId
|
||||||
.flatMapLatest { id ->
|
.flatMapLatest { id ->
|
||||||
@@ -149,6 +158,11 @@ class HomeListViewModel(
|
|||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
|
fun artistArt(
|
||||||
|
artist: Artist,
|
||||||
|
forIcon: Boolean = true,
|
||||||
|
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||||
|
|
||||||
fun play(song: Song) {
|
fun play(song: Song) {
|
||||||
val genre = request.genre
|
val genre = request.genre
|
||||||
when {
|
when {
|
||||||
@@ -173,4 +187,18 @@ class HomeListViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun shuffle() {
|
||||||
|
val genre = request.genre
|
||||||
|
val downloaded = request.downloaded
|
||||||
|
val starred = request.section == HomeSection.RecentlyStarredSongs
|
||||||
|
viewModelScope.launch {
|
||||||
|
val id = sourceId.first()
|
||||||
|
when {
|
||||||
|
genre != null -> playbackController.shuffleGenre(id, genre)
|
||||||
|
downloaded == OfflineListKind.Songs -> playbackController.shuffleDownloaded(id)
|
||||||
|
starred -> playbackController.shuffleStarred(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ import com.subtracks.data.model.Playlist
|
|||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.ArtistIcon
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
@@ -137,6 +139,7 @@ fun HomeRoute(
|
|||||||
playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(),
|
playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(),
|
||||||
songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value,
|
songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
playingSongId = playback.item?.id,
|
playingSongId = playback.item?.id,
|
||||||
topInset = topInset,
|
topInset = topInset,
|
||||||
bottomInset = bottomInset,
|
bottomInset = bottomInset,
|
||||||
@@ -152,6 +155,7 @@ fun HomeRoute(
|
|||||||
HomeScreen(
|
HomeScreen(
|
||||||
feed = feed,
|
feed = feed,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
playingSongId = playback.item?.id,
|
playingSongId = playback.item?.id,
|
||||||
topInset = topInset,
|
topInset = topInset,
|
||||||
bottomInset = bottomInset,
|
bottomInset = bottomInset,
|
||||||
@@ -173,6 +177,7 @@ fun HomeScreen(
|
|||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
playingSongId: String?,
|
playingSongId: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
topInset: Dp = 0.dp,
|
topInset: Dp = 0.dp,
|
||||||
bottomInset: Dp = 0.dp,
|
bottomInset: Dp = 0.dp,
|
||||||
onAlbumClick: (Album) -> Unit = {},
|
onAlbumClick: (Album) -> Unit = {},
|
||||||
@@ -215,6 +220,7 @@ fun HomeScreen(
|
|||||||
HomeSection.RecentlyPlayedArtists,
|
HomeSection.RecentlyPlayedArtists,
|
||||||
feed.recentlyPlayedArtists,
|
feed.recentlyPlayedArtists,
|
||||||
coverArt,
|
coverArt,
|
||||||
|
artistArt,
|
||||||
onArtistClick,
|
onArtistClick,
|
||||||
onLongClick,
|
onLongClick,
|
||||||
onMore,
|
onMore,
|
||||||
@@ -249,6 +255,7 @@ fun HomeScreen(
|
|||||||
HomeSection.MostPlayedArtists,
|
HomeSection.MostPlayedArtists,
|
||||||
feed.mostPlayedArtists,
|
feed.mostPlayedArtists,
|
||||||
coverArt,
|
coverArt,
|
||||||
|
artistArt,
|
||||||
onArtistClick,
|
onArtistClick,
|
||||||
onLongClick,
|
onLongClick,
|
||||||
onMore,
|
onMore,
|
||||||
@@ -296,6 +303,7 @@ fun OfflineHomeScreen(
|
|||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
playingSongId: String?,
|
playingSongId: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
topInset: Dp = 0.dp,
|
topInset: Dp = 0.dp,
|
||||||
bottomInset: Dp = 0.dp,
|
bottomInset: Dp = 0.dp,
|
||||||
onAlbumClick: (Album) -> Unit = {},
|
onAlbumClick: (Album) -> Unit = {},
|
||||||
@@ -312,7 +320,7 @@ fun OfflineHomeScreen(
|
|||||||
) {
|
) {
|
||||||
homeTitle()
|
homeTitle()
|
||||||
downloadedAlbumRow(albums, coverArt, onAlbumClick, onLongClick) { onMore(OfflineListKind.Albums) }
|
downloadedAlbumRow(albums, coverArt, onAlbumClick, onLongClick) { onMore(OfflineListKind.Albums) }
|
||||||
downloadedArtistRow(artists, coverArt, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
|
downloadedArtistRow(artists, artistArt, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
|
||||||
downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) }
|
downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) }
|
||||||
songList(
|
songList(
|
||||||
key = "downloaded-songs",
|
key = "downloaded-songs",
|
||||||
@@ -364,7 +372,7 @@ private fun LazyListScope.downloadedAlbumRow(
|
|||||||
|
|
||||||
private fun LazyListScope.downloadedArtistRow(
|
private fun LazyListScope.downloadedArtistRow(
|
||||||
artists: LazyPagingItems<Artist>,
|
artists: LazyPagingItems<Artist>,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
artistArt: (Artist) -> ArtistArt,
|
||||||
onArtistClick: (Artist) -> Unit,
|
onArtistClick: (Artist) -> Unit,
|
||||||
onLongClick: (MenuTarget) -> Unit,
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
onMore: () -> Unit,
|
onMore: () -> Unit,
|
||||||
@@ -382,12 +390,12 @@ private fun LazyListScope.downloadedArtistRow(
|
|||||||
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
||||||
val artist = artists[index]
|
val artist = artists[index]
|
||||||
if (artist != null) {
|
if (artist != null) {
|
||||||
val ref = coverArt(artist.coverArt, true)
|
val art = artistArt(artist)
|
||||||
ArtistTile(
|
ArtistTile(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
ref = ref,
|
art = art,
|
||||||
onClick = { onArtistClick(artist) },
|
onClick = { onArtistClick(artist) },
|
||||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
|
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -607,6 +615,7 @@ private fun LazyListScope.artistRow(
|
|||||||
section: HomeSection,
|
section: HomeSection,
|
||||||
artists: List<Artist>,
|
artists: List<Artist>,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
artistArt: (Artist) -> ArtistArt,
|
||||||
onArtistClick: (Artist) -> Unit,
|
onArtistClick: (Artist) -> Unit,
|
||||||
onLongClick: (MenuTarget) -> Unit,
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
onMore: (HomeSection) -> Unit,
|
onMore: (HomeSection) -> Unit,
|
||||||
@@ -625,12 +634,12 @@ private fun LazyListScope.artistRow(
|
|||||||
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
|
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
|
||||||
) {
|
) {
|
||||||
items(artists, key = { it.id }) { artist ->
|
items(artists, key = { it.id }) { artist ->
|
||||||
val ref = coverArt(artist.coverArt, true)
|
val art = artistArt(artist)
|
||||||
ArtistTile(
|
ArtistTile(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
ref = ref,
|
art = art,
|
||||||
onClick = { onArtistClick(artist) },
|
onClick = { onArtistClick(artist) },
|
||||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
|
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -779,7 +788,7 @@ private fun AlbumTile(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ArtistTile(
|
private fun ArtistTile(
|
||||||
artist: Artist,
|
artist: Artist,
|
||||||
ref: CoverArtRef?,
|
art: ArtistArt,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -787,9 +796,9 @@ private fun ArtistTile(
|
|||||||
modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
CoverArt(
|
ArtistIcon(
|
||||||
ref = ref,
|
artist = artist,
|
||||||
name = artist.name,
|
art = art,
|
||||||
modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape),
|
modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import com.subtracks.data.repo.rediscoverCutoff
|
|||||||
import com.subtracks.data.sync.SyncManager
|
import com.subtracks.data.sync.SyncManager
|
||||||
import com.subtracks.data.sync.SyncStatus
|
import com.subtracks.data.sync.SyncStatus
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.artistArt
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -25,7 +27,6 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -53,11 +54,6 @@ class HomeViewModel(
|
|||||||
private val syncManager: SyncManager,
|
private val syncManager: SyncManager,
|
||||||
private val playbackController: PlaybackController,
|
private val playbackController: PlaybackController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val syncing: StateFlow<Boolean> =
|
|
||||||
syncManager.status
|
|
||||||
.map { it == SyncStatus.Running }
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
|
||||||
|
|
||||||
val offline: StateFlow<Boolean> =
|
val offline: StateFlow<Boolean> =
|
||||||
sourceRepository.offline.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
sourceRepository.offline.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
@@ -133,6 +129,11 @@ class HomeViewModel(
|
|||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
|
fun artistArt(
|
||||||
|
artist: Artist,
|
||||||
|
forIcon: Boolean = true,
|
||||||
|
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||||
|
|
||||||
fun playStarred(song: Song) {
|
fun playStarred(song: Song) {
|
||||||
viewModelScope.playStarredList(libraryRepository, playbackController, song)
|
viewModelScope.playStarredList(libraryRepository, playbackController, song)
|
||||||
}
|
}
|
||||||
@@ -141,7 +142,9 @@ class HomeViewModel(
|
|||||||
viewModelScope.playDownloadedList(libraryRepository, playbackController, song)
|
viewModelScope.playDownloadedList(libraryRepository, playbackController, song)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sync() = syncManager.requestSync()
|
fun sync() {
|
||||||
|
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
||||||
|
}
|
||||||
|
|
||||||
private data class PlayRows(
|
private data class PlayRows(
|
||||||
val recentAlbums: List<Album>,
|
val recentAlbums: List<Album>,
|
||||||
@@ -217,7 +220,7 @@ internal fun CoroutineScope.playGenreList(
|
|||||||
song: Song,
|
song: Song,
|
||||||
) {
|
) {
|
||||||
launch {
|
launch {
|
||||||
val ordinal = libraryRepository.genreSongOrdinal(song.sourceId, genre, song.title, song.id)
|
val ordinal = libraryRepository.genreSongOrdinal(song.sourceId, genre, song.sortName, song.id)
|
||||||
playbackController.playGenre(song.sourceId, genre, ordinal)
|
playbackController.playGenre(song.sourceId, genre, ordinal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import com.subtracks.data.model.Song
|
|||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistChooserSheet
|
||||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||||
@@ -66,16 +67,20 @@ fun AlbumDetailRoute(
|
|||||||
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
|
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
|
||||||
playbackController: PlaybackController = koinInject(),
|
playbackController: PlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
|
val album by viewModel.album.collectAsStateWithLifecycle()
|
||||||
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
|
val songs by viewModel.songs.collectAsStateWithLifecycle()
|
||||||
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
|
val discs by viewModel.discs.collectAsStateWithLifecycle()
|
||||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
||||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
var showArtistChooser by remember { mutableStateOf(false) }
|
||||||
|
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) {
|
||||||
|
pendingDelete = null
|
||||||
|
showArtistChooser = false
|
||||||
|
}
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
val context = playback.context
|
val context = playback.context
|
||||||
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||||
@@ -126,9 +131,13 @@ fun AlbumDetailRoute(
|
|||||||
},
|
},
|
||||||
onArtistClick =
|
onArtistClick =
|
||||||
album
|
album
|
||||||
?.takeIf { !it.albumArtist.isNullOrBlank() }
|
?.takeIf { it.artistRefs.isNotEmpty() }
|
||||||
?.let { it.artistId }
|
?.let {
|
||||||
?.let { id -> { onViewArtist(id) } },
|
{
|
||||||
|
val artists = it.artistRefs
|
||||||
|
if (artists.size == 1) onViewArtist(artists.first().id) else showArtistChooser = true
|
||||||
|
}
|
||||||
|
},
|
||||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -140,6 +149,17 @@ fun AlbumDetailRoute(
|
|||||||
onDismiss = { pendingDelete = null },
|
onDismiss = { pendingDelete = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showArtistChooser) {
|
||||||
|
ArtistChooserSheet(
|
||||||
|
artists = album?.artistRefs.orEmpty(),
|
||||||
|
onArtistClick = { artist ->
|
||||||
|
showArtistChooser = false
|
||||||
|
onViewArtist(artist.id)
|
||||||
|
},
|
||||||
|
onDismiss = { showArtistChooser = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -195,6 +215,7 @@ fun AlbumDetailScreen(
|
|||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
title = album?.name.orEmpty(),
|
title = album?.name.orEmpty(),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
contentReady = songs.isNotEmpty(),
|
||||||
header = { controlsModifier, topInset ->
|
header = { controlsModifier, topInset ->
|
||||||
val artistName = album?.albumArtist?.takeIf { it.isNotBlank() }
|
val artistName = album?.albumArtist?.takeIf { it.isNotBlank() }
|
||||||
HeroHeader(
|
HeroHeader(
|
||||||
@@ -215,6 +236,7 @@ fun AlbumDetailScreen(
|
|||||||
onMore = onMore,
|
onMore = onMore,
|
||||||
topInset = topInset,
|
topInset = topInset,
|
||||||
controlsModifier = controlsModifier,
|
controlsModifier = controlsModifier,
|
||||||
|
nameMaxLines = Int.MAX_VALUE,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
content = { rowModifier ->
|
content = { rowModifier ->
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import com.subtracks.data.repo.LibraryRepository
|
|||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
@@ -35,13 +34,20 @@ class AlbumDetailViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
|
|
||||||
val album: Flow<Album?> = sourceId.flatMapLatest { libraryRepository.album(it, albumId) }
|
val album: StateFlow<Album?> =
|
||||||
|
sourceId
|
||||||
|
.flatMapLatest { libraryRepository.album(it, albumId) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
val songs: Flow<List<Song>> =
|
val songs: StateFlow<List<Song>> =
|
||||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||||
.flatMapLatest { (id, offline) -> libraryRepository.albumSongs(id, albumId, offline) }
|
.flatMapLatest { (id, offline) -> libraryRepository.albumSongs(id, albumId, offline) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
val discs: Flow<List<Disc>> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
val discs: StateFlow<List<Disc>> =
|
||||||
|
sourceId
|
||||||
|
.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
val downloads: StateFlow<Map<String, SongDownload>> = downloadRepository.states()
|
val downloads: StateFlow<Map<String, SongDownload>> = downloadRepository.states()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.subtracks.ui.library
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -15,6 +16,7 @@ import androidx.compose.foundation.lazy.grid.GridItemSpan
|
|||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -37,11 +39,14 @@ import com.subtracks.ui.components.ListDownloadIndicator
|
|||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.ResetScrollOnChange
|
import com.subtracks.ui.components.ResetScrollOnChange
|
||||||
|
import com.subtracks.ui.components.ScrollToTopOnRequest
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
||||||
|
|
||||||
const val ALBUM_COVER_TAG = "album-cover"
|
const val ALBUM_COVER_TAG = "album-cover"
|
||||||
|
|
||||||
|
const val ALBUM_PLACEHOLDER_TAG = "album-placeholder"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AlbumsContent(
|
fun AlbumsContent(
|
||||||
items: LazyPagingItems<Album>,
|
items: LazyPagingItems<Album>,
|
||||||
@@ -56,6 +61,7 @@ fun AlbumsContent(
|
|||||||
topInset: Dp = 0.dp,
|
topInset: Dp = 0.dp,
|
||||||
onSync: () -> Unit = {},
|
onSync: () -> Unit = {},
|
||||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||||
|
scrollToTopKey: Any? = null,
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||||
@@ -78,6 +84,7 @@ fun AlbumsContent(
|
|||||||
else -> {
|
else -> {
|
||||||
val gridState = rememberLazyGridState()
|
val gridState = rememberLazyGridState()
|
||||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { gridState.scrollToItem(0) }
|
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { gridState.scrollToItem(0) }
|
||||||
|
ScrollToTopOnRequest(scrollToTopKey, gridState)
|
||||||
val fill = rememberViewportFill(gridState)
|
val fill = rememberViewportFill(gridState)
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
state = gridState,
|
state = gridState,
|
||||||
@@ -114,6 +121,16 @@ fun AlbumsContent(
|
|||||||
scrim = true,
|
scrim = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.clip(RoundedCornerShape(2.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.testTag(ALBUM_PLACEHOLDER_TAG),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.defaultMinSize
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -27,12 +28,15 @@ import androidx.compose.foundation.layout.statusBars
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material.icons.rounded.Album
|
||||||
|
import androidx.compose.material.icons.rounded.Mic
|
||||||
import androidx.compose.material.icons.rounded.MoreHoriz
|
import androidx.compose.material.icons.rounded.MoreHoriz
|
||||||
import androidx.compose.material.icons.rounded.Shuffle
|
import androidx.compose.material.icons.rounded.Shuffle
|
||||||
import androidx.compose.material.icons.rounded.Star
|
import androidx.compose.material.icons.rounded.Star
|
||||||
@@ -52,40 +56,58 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Outline
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
import androidx.compose.ui.graphics.Shadow
|
import androidx.compose.ui.graphics.Shadow
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.TextMeasurer
|
import androidx.compose.ui.text.TextMeasurer
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistAlbums
|
||||||
import com.subtracks.data.model.BulkDownloadAction
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.ListDownloadStatus
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.ArtistHeroArt
|
||||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
@@ -107,10 +129,16 @@ import com.subtracks.ui.theme.heroBarColor
|
|||||||
import com.subtracks.ui.theme.rememberArtworkColors
|
import com.subtracks.ui.theme.rememberArtworkColors
|
||||||
import com.subtracks.ui.theme.rememberOverlaidNameBusy
|
import com.subtracks.ui.theme.rememberOverlaidNameBusy
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.ceil
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
private val ART_HEIGHT = 420.dp
|
private val ART_HEIGHT = 420.dp
|
||||||
private val TITLE_INSET = 16.dp
|
private val TITLE_INSET = 16.dp
|
||||||
@@ -121,6 +149,12 @@ private val SHUFFLE_ICON = 30.dp
|
|||||||
private val SHUFFLE_END = 22.dp
|
private val SHUFFLE_END = 22.dp
|
||||||
private val SHUFFLE_RESERVE = 86.dp
|
private val SHUFFLE_RESERVE = 86.dp
|
||||||
private val SHUFFLE_HANG = SHUFFLE_HEIGHT / 2
|
private val SHUFFLE_HANG = SHUFFLE_HEIGHT / 2
|
||||||
|
private val BIO_ACTION_RESERVE = 104.dp
|
||||||
|
private val BIO_ACTION_HEIGHT = 48.dp
|
||||||
|
private val BIO_ACTION_OFFSET = 16.dp
|
||||||
|
private val BIO_ACTION_END_OFFSET = 8.dp
|
||||||
|
private val BIO_TOP_GAP = 8.dp
|
||||||
|
private val BIO_BOTTOM_GAP = 8.dp
|
||||||
|
|
||||||
internal fun estimateScrollPx(
|
internal fun estimateScrollPx(
|
||||||
index: Int,
|
index: Int,
|
||||||
@@ -136,9 +170,18 @@ internal fun estimateScrollPx(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val FADE_LEAD = 24.dp
|
private val FADE_LEAD = 24.dp
|
||||||
|
private const val ARTIST_NAME_MAX_LINES = 6
|
||||||
private const val THEME_TRANSITION_MS = 100
|
private const val THEME_TRANSITION_MS = 100
|
||||||
private const val SCRIM_FADE_MS = 180
|
private const val SCRIM_FADE_MS = 180
|
||||||
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
|
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
|
||||||
|
internal const val ARTIST_BIO_MORE_TAG = "artistBioMore"
|
||||||
|
internal const val ARTIST_BIO_LESS_TAG = "artistBioLess"
|
||||||
|
|
||||||
|
private data class BioSnapshot(
|
||||||
|
val known: Boolean,
|
||||||
|
val present: Boolean,
|
||||||
|
val biography: String?,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ArtistDetailRoute(
|
fun ArtistDetailRoute(
|
||||||
@@ -152,11 +195,24 @@ fun ArtistDetailRoute(
|
|||||||
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
|
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
|
||||||
playbackController: PlaybackController = koinInject(),
|
playbackController: PlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
|
val artist by viewModel.artist.collectAsStateWithLifecycle()
|
||||||
val artistName by viewModel.artistName.collectAsStateWithLifecycle(initialValue = null)
|
val artistName by viewModel.artistName.collectAsStateWithLifecycle()
|
||||||
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = emptyList())
|
val albums by viewModel.albums.collectAsStateWithLifecycle()
|
||||||
|
val bioSnapshots =
|
||||||
|
remember(viewModel) {
|
||||||
|
viewModel.artistBio.map { BioSnapshot(known = true, present = it != null, biography = it?.biography) }
|
||||||
|
}
|
||||||
|
val bioSnapshot by
|
||||||
|
bioSnapshots.collectAsStateWithLifecycle(
|
||||||
|
initialValue = BioSnapshot(known = false, present = false, biography = null),
|
||||||
|
)
|
||||||
|
val bioLoading by viewModel.bioLoading.collectAsStateWithLifecycle()
|
||||||
|
val bioUnavailable by viewModel.bioUnavailable.collectAsStateWithLifecycle()
|
||||||
|
val bioExpanded by viewModel.bioExpanded.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(artistId) { viewModel.refreshBio() }
|
||||||
val art by viewModel.art.collectAsStateWithLifecycle()
|
val art by viewModel.art.collectAsStateWithLifecycle()
|
||||||
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
|
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
|
||||||
|
val heroArt by viewModel.heroArt.collectAsStateWithLifecycle()
|
||||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||||
@@ -187,10 +243,16 @@ fun ArtistDetailRoute(
|
|||||||
ArtistDetailScreen(
|
ArtistDetailScreen(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
artistName = artistName.orEmpty(),
|
artistName = artistName.orEmpty(),
|
||||||
isAlbumArtist = albums.any { it.artistId == artistId },
|
albums = albums ?: ArtistAlbums(),
|
||||||
albums = albums,
|
albumsLoaded = albums != null,
|
||||||
|
bio = bioSnapshot.biography,
|
||||||
|
bioLoading = bioLoading && !bioSnapshot.present,
|
||||||
|
bioUnavailable = bioUnavailable && !bioSnapshot.present,
|
||||||
|
bioPending = !bioSnapshot.known,
|
||||||
|
bioExpanded = bioExpanded,
|
||||||
art = art,
|
art = art,
|
||||||
artThumbnail = artThumbnail,
|
artThumbnail = artThumbnail,
|
||||||
|
heroArt = heroArt,
|
||||||
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
|
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
@@ -200,6 +262,8 @@ fun ArtistDetailRoute(
|
|||||||
onMore = { artist?.let { contextMenuHost?.show(MenuTarget.Artist(it, artThumbnail ?: art, downloadStatus), actions) } },
|
onMore = { artist?.let { contextMenuHost?.show(MenuTarget.Artist(it, artThumbnail ?: art, downloadStatus), actions) } },
|
||||||
starred = artist?.starred != null,
|
starred = artist?.starred != null,
|
||||||
onToggleStar = artist?.let { a -> { setStar(StarType.Artist, a.id, a.starred == null) } },
|
onToggleStar = artist?.let { a -> { setStar(StarType.Artist, a.id, a.starred == null) } },
|
||||||
|
onExpandBio = viewModel::expandBio,
|
||||||
|
onCollapseBio = viewModel::collapseBio,
|
||||||
onShuffle = viewModel::shuffle,
|
onShuffle = viewModel::shuffle,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -219,10 +283,16 @@ fun ArtistDetailScreen(
|
|||||||
artist: Artist?,
|
artist: Artist?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
artistName: String = "",
|
artistName: String = "",
|
||||||
isAlbumArtist: Boolean = false,
|
albums: ArtistAlbums = ArtistAlbums(),
|
||||||
albums: List<Album>,
|
albumsLoaded: Boolean = true,
|
||||||
|
bio: String? = null,
|
||||||
|
bioLoading: Boolean = false,
|
||||||
|
bioUnavailable: Boolean = false,
|
||||||
|
bioPending: Boolean = false,
|
||||||
|
bioExpanded: Boolean = false,
|
||||||
art: CoverArtRef?,
|
art: CoverArtRef?,
|
||||||
artThumbnail: CoverArtRef? = null,
|
artThumbnail: CoverArtRef? = null,
|
||||||
|
heroArt: ArtistArt? = null,
|
||||||
artwork: ArtworkColors? = null,
|
artwork: ArtworkColors? = null,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
@@ -232,9 +302,11 @@ fun ArtistDetailScreen(
|
|||||||
onMore: () -> Unit = {},
|
onMore: () -> Unit = {},
|
||||||
starred: Boolean = false,
|
starred: Boolean = false,
|
||||||
onToggleStar: (() -> Unit)? = null,
|
onToggleStar: (() -> Unit)? = null,
|
||||||
|
onExpandBio: () -> Unit = {},
|
||||||
|
onCollapseBio: () -> Unit = {},
|
||||||
onShuffle: () -> Unit = {},
|
onShuffle: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyGridState()
|
val listState = rememberRestorableLazyGridState(albumsLoaded)
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val lineHeight =
|
val lineHeight =
|
||||||
@@ -245,10 +317,31 @@ fun ArtistDetailScreen(
|
|||||||
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||||
val navBarBottom = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
|
val navBarBottom = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
|
||||||
val barHeight = statusBarTop + TopAppBarDefaults.TopAppBarExpandedHeight
|
val barHeight = statusBarTop + TopAppBarDefaults.TopAppBarExpandedHeight
|
||||||
val albumCount = albums.size.toLong()
|
val albumCount = albums.count.toLong()
|
||||||
val displayName = artistName.ifBlank { artist?.name.orEmpty() }
|
val displayName = artistName.ifBlank { artist?.name.orEmpty() }
|
||||||
val artistType = stringResource(if (isAlbumArtist) R.string.artist_type_album_artist else R.string.artist_type_artist)
|
val artistType =
|
||||||
val imageTitleTop = ART_HEIGHT - TITLE_INSET - lineHeight
|
stringResource(
|
||||||
|
if (albums.albumCredits.isNotEmpty()) R.string.artist_type_album_artist else R.string.artist_type_artist,
|
||||||
|
)
|
||||||
|
val albumCountText = pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount)
|
||||||
|
val metaText = if (albumsLoaded) stringResource(R.string.artist_meta, artistType, albumCountText) else ""
|
||||||
|
val hasBio = !bio.isNullOrBlank()
|
||||||
|
val bioCollapsed = hasBio && !bioExpanded
|
||||||
|
val bioText =
|
||||||
|
when {
|
||||||
|
bioPending -> ""
|
||||||
|
hasBio -> bio.orEmpty()
|
||||||
|
bioLoading -> stringResource(R.string.artist_bio_loading)
|
||||||
|
bioUnavailable -> stringResource(R.string.artist_bio_unavailable)
|
||||||
|
else -> stringResource(R.string.artist_bio_empty)
|
||||||
|
}
|
||||||
|
var titleHeightPx by remember { mutableFloatStateOf(0f) }
|
||||||
|
val titleHeight = if (titleHeightPx > 0f) with(density) { titleHeightPx.toDp() } else lineHeight
|
||||||
|
val titleMaxLines =
|
||||||
|
((ART_HEIGHT - TITLE_INSET - barHeight - FADE_LEAD) / lineHeight)
|
||||||
|
.toInt()
|
||||||
|
.coerceIn(1, ARTIST_NAME_MAX_LINES)
|
||||||
|
val imageTitleTop = ART_HEIGHT - TITLE_INSET - titleHeight
|
||||||
val fadeStartPx = with(density) { (imageTitleTop - barHeight - FADE_LEAD).toPx() }
|
val fadeStartPx = with(density) { (imageTitleTop - barHeight - FADE_LEAD).toPx() }
|
||||||
val fadeEndPx = with(density) { (imageTitleTop + lineHeight / 2 - barHeight).toPx() }
|
val fadeEndPx = with(density) { (imageTitleTop + lineHeight / 2 - barHeight).toPx() }
|
||||||
val barFraction by remember(fadeStartPx, fadeEndPx) {
|
val barFraction by remember(fadeStartPx, fadeEndPx) {
|
||||||
@@ -260,8 +353,8 @@ fun ArtistDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var imageHeightPx by remember { mutableFloatStateOf(0f) }
|
var imageHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
var albumHeightPx by remember { mutableFloatStateOf(0f) }
|
var albumHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
val albumRowGapPx = with(density) { ROW_GAP.toPx() }
|
val albumRowGapPx = with(density) { ROW_GAP.toPx() }
|
||||||
val scrollPx by remember {
|
val scrollPx by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
@@ -293,7 +386,9 @@ fun ArtistDetailScreen(
|
|||||||
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||||
val screenHeightPx = with(density) { maxHeight.toPx() }
|
val screenHeightPx = with(density) { maxHeight.toPx() }
|
||||||
val barHeightPx = with(density) { barHeight.toPx() }
|
val barHeightPx = with(density) { barHeight.toPx() }
|
||||||
val gradientScrollPx = scrollPx - imageHeightPx
|
// The gradient is anchored to the hero image, not the whole header, whose bio can expand.
|
||||||
|
val heroHeightPx = with(density) { ART_HEIGHT.toPx() }
|
||||||
|
val gradientScrollPx = scrollPx - heroHeightPx
|
||||||
val barColor = heroBarColor(effectiveArtwork, gradientScrollPx, barHeightPx, screenHeightPx)
|
val barColor = heroBarColor(effectiveArtwork, gradientScrollPx, barHeightPx, screenHeightPx)
|
||||||
HeroGradient(
|
HeroGradient(
|
||||||
colors = effectiveArtwork,
|
colors = effectiveArtwork,
|
||||||
@@ -315,11 +410,9 @@ fun ArtistDetailScreen(
|
|||||||
.onSizeChanged { imageHeightPx = it.height.toFloat() },
|
.onSizeChanged { imageHeightPx = it.height.toFloat() },
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxWidth()) {
|
Box(Modifier.fillMaxWidth()) {
|
||||||
CoverArt(
|
ArtistHeroArt(
|
||||||
ref = art,
|
art = heroArt ?: ArtistArt(image = art, thumbnail = artThumbnail),
|
||||||
name = displayName,
|
name = displayName,
|
||||||
thumbnailRef = artThumbnail,
|
|
||||||
showPlaceholder = art == null,
|
|
||||||
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
||||||
)
|
)
|
||||||
if (nameScrimAlpha > 0f) {
|
if (nameScrimAlpha > 0f) {
|
||||||
@@ -328,7 +421,7 @@ fun ArtistDetailScreen(
|
|||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(ART_HEIGHT * 0.4f)
|
.height(maxOf(ART_HEIGHT * 0.4f, titleHeight + TITLE_INSET * 2))
|
||||||
.graphicsLayer { alpha = nameScrimAlpha }
|
.graphicsLayer { alpha = nameScrimAlpha }
|
||||||
.background(
|
.background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
@@ -343,7 +436,13 @@ fun ArtistDetailScreen(
|
|||||||
style = imageNameStyle,
|
style = imageNameStyle,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
endReserve = SHUFFLE_RESERVE,
|
endReserve = SHUFFLE_RESERVE,
|
||||||
modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(TITLE_INSET),
|
maxLines = titleMaxLines,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(TITLE_INSET)
|
||||||
|
.onSizeChanged { titleHeightPx = it.height.toFloat() },
|
||||||
)
|
)
|
||||||
FilledIconButton(
|
FilledIconButton(
|
||||||
onClick = onShuffle,
|
onClick = onShuffle,
|
||||||
@@ -365,95 +464,71 @@ fun ArtistDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = TITLE_INSET),
|
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = TITLE_INSET),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = artistType,
|
text = metaText,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(end = SHUFFLE_RESERVE),
|
||||||
|
)
|
||||||
|
ArtistBio(
|
||||||
|
text = bioText,
|
||||||
|
color =
|
||||||
|
if (hasBio) {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.outline
|
||||||
|
},
|
||||||
|
collapsed = bioCollapsed,
|
||||||
|
expanded = hasBio && bioExpanded,
|
||||||
|
artist = artist,
|
||||||
|
starred = starred,
|
||||||
|
onToggleStar = onToggleStar,
|
||||||
|
onMore = onMore,
|
||||||
|
onExpand = onExpandBio,
|
||||||
|
onCollapse = onCollapseBio,
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(
|
||||||
|
bottom = if (hasBio) BIO_BOTTOM_GAP else 0.dp,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
// Hold the row at the button height even without the buttons, so the
|
|
||||||
// label-to-count gap is the same for every artist type.
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = 48.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
if (artist != null) {
|
|
||||||
IconButton(
|
|
||||||
onClick = { onToggleStar?.invoke() },
|
|
||||||
enabled = onToggleStar != null,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder,
|
|
||||||
contentDescription =
|
|
||||||
stringResource(
|
|
||||||
if (starred) R.string.actions_unstar else R.string.actions_star,
|
|
||||||
),
|
|
||||||
tint =
|
|
||||||
if (starred) {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(onClick = onMore) {
|
|
||||||
Icon(Icons.Rounded.MoreHoriz, contentDescription = stringResource(R.string.more_options))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
itemsIndexed(albums, key = { _, album -> album.id }) { index, album ->
|
if (albums.albumCredits.isNotEmpty()) {
|
||||||
val art = coverArt(album.coverArt, false)
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
val thumbnail = coverArt(album.coverArt, true)
|
ArtistSectionHeader(stringResource(R.string.artist_section_albums), Icons.Rounded.Album)
|
||||||
PrefetchArtworkSeeds(thumbnail)
|
}
|
||||||
Column(
|
itemsIndexed(albums.albumCredits, key = { _, album -> album.id }) { index, album ->
|
||||||
modifier =
|
ArtistAlbumCard(
|
||||||
Modifier
|
album = album,
|
||||||
.padding(
|
index = index,
|
||||||
start = if (index % 2 == 0) 16.dp else 0.dp,
|
coverArt = coverArt,
|
||||||
end = if (index % 2 == 1) 16.dp else 0.dp,
|
downloadStatus = albumDownloads[album.id],
|
||||||
).combinedClickable(
|
onClick = { onAlbumClick(album) },
|
||||||
onClick = { onAlbumClick(album) },
|
onLongClick = { thumbnail ->
|
||||||
onLongClick = { onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id])) },
|
onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id]))
|
||||||
).onSizeChanged { albumHeightPx = it.height.toFloat() },
|
},
|
||||||
) {
|
onSizeChanged = { albumHeightPx = it },
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
|
|
||||||
) {
|
|
||||||
CoverArt(
|
|
||||||
ref = art,
|
|
||||||
name = album.name,
|
|
||||||
thumbnailRef = thumbnail,
|
|
||||||
showPlaceholder = art == null,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
ListDownloadIndicator(
|
|
||||||
status = albumDownloads[album.id],
|
|
||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp),
|
|
||||||
scrim = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = album.name,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.padding(top = 6.dp),
|
|
||||||
)
|
)
|
||||||
Text(
|
}
|
||||||
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
|
}
|
||||||
style = MaterialTheme.typography.bodySmall,
|
if (albums.appearances.isNotEmpty()) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
ArtistSectionHeader(stringResource(R.string.artist_section_appearances), Icons.Rounded.Mic, topGap = 16.dp)
|
||||||
|
}
|
||||||
|
itemsIndexed(albums.appearances, key = { _, album -> album.id }) { index, album ->
|
||||||
|
ArtistAlbumCard(
|
||||||
|
album = album,
|
||||||
|
index = index,
|
||||||
|
coverArt = coverArt,
|
||||||
|
downloadStatus = albumDownloads[album.id],
|
||||||
|
onClick = { onAlbumClick(album) },
|
||||||
|
onLongClick = { thumbnail ->
|
||||||
|
onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id]))
|
||||||
|
},
|
||||||
|
onSizeChanged = { albumHeightPx = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -499,12 +574,441 @@ fun ArtistDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberRestorableLazyGridState(contentReady: Boolean): LazyGridState {
|
||||||
|
val state = rememberLazyGridState()
|
||||||
|
var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) }
|
||||||
|
var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) }
|
||||||
|
var pending by remember { mutableStateOf(index > 0 || offset > 0) }
|
||||||
|
LaunchedEffect(contentReady) {
|
||||||
|
if (contentReady && pending) {
|
||||||
|
val target = index.coerceAtMost((state.layoutInfo.totalItemsCount - 1).coerceAtLeast(0))
|
||||||
|
state.scrollToItem(target, offset)
|
||||||
|
pending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }
|
||||||
|
.collect { (currentIndex, currentOffset) ->
|
||||||
|
if (!pending) {
|
||||||
|
index = currentIndex
|
||||||
|
offset = currentOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the lines that overlap the action buttons are narrowed; later lines use the full width.
|
||||||
|
@Composable
|
||||||
|
private fun ArtistBio(
|
||||||
|
text: String,
|
||||||
|
color: Color,
|
||||||
|
collapsed: Boolean,
|
||||||
|
expanded: Boolean,
|
||||||
|
artist: Artist?,
|
||||||
|
starred: Boolean,
|
||||||
|
onToggleStar: (() -> Unit)?,
|
||||||
|
onMore: () -> Unit,
|
||||||
|
onExpand: () -> Unit,
|
||||||
|
onCollapse: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val style = MaterialTheme.typography.bodyMedium
|
||||||
|
val moreText = stringResource(R.string.artist_bio_more)
|
||||||
|
val lessText = stringResource(R.string.artist_bio_less)
|
||||||
|
BoxWithConstraints(modifier.fillMaxWidth().padding(top = BIO_TOP_GAP)) {
|
||||||
|
val fullWidth = with(density) { maxWidth.roundToPx() }
|
||||||
|
val reserve = with(density) { BIO_ACTION_RESERVE.roundToPx() }
|
||||||
|
val lineHeightPx =
|
||||||
|
remember(style) {
|
||||||
|
measurer.measure(AnnotatedString("X"), style, maxLines = 1).size.height
|
||||||
|
}.coerceAtLeast(1)
|
||||||
|
val reserveLines =
|
||||||
|
ceil(with(density) { (BIO_ACTION_HEIGHT - BIO_ACTION_OFFSET).toPx() } / lineHeightPx)
|
||||||
|
.toInt()
|
||||||
|
.coerceAtLeast(1)
|
||||||
|
val bioLines =
|
||||||
|
remember(
|
||||||
|
text,
|
||||||
|
fullWidth,
|
||||||
|
reserve,
|
||||||
|
reserveLines,
|
||||||
|
collapsed,
|
||||||
|
expanded,
|
||||||
|
moreText,
|
||||||
|
lessText,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
collapsed -> {
|
||||||
|
splitBioCollapsed(
|
||||||
|
measurer,
|
||||||
|
text,
|
||||||
|
style,
|
||||||
|
fullWidth,
|
||||||
|
reserve,
|
||||||
|
reserveLines,
|
||||||
|
moreText,
|
||||||
|
maxLines = 3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
expanded -> {
|
||||||
|
splitBioExpanded(measurer, text, style, fullWidth, reserve, reserveLines, lessText)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
BioLines(
|
||||||
|
splitBio(measurer, text, style, fullWidth, reserve, reserveLines).map {
|
||||||
|
AnnotatedString(it.ifEmpty { " " })
|
||||||
|
},
|
||||||
|
more = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box {
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
bioLines.lines.forEachIndexed { index, line ->
|
||||||
|
val isControl = index == bioLines.lines.lastIndex && (bioLines.more || bioLines.less)
|
||||||
|
Text(
|
||||||
|
text = line,
|
||||||
|
style = style,
|
||||||
|
color = color,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier =
|
||||||
|
when {
|
||||||
|
isControl && bioLines.more -> Modifier.testTag(ARTIST_BIO_MORE_TAG).clickable(onClick = onExpand)
|
||||||
|
isControl && bioLines.less -> Modifier.testTag(ARTIST_BIO_LESS_TAG).clickable(onClick = onCollapse)
|
||||||
|
else -> Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.offset(x = BIO_ACTION_END_OFFSET, y = -BIO_ACTION_OFFSET),
|
||||||
|
) {
|
||||||
|
ArtistActions(artist, starred, onToggleStar, onMore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bioLineWidth(
|
||||||
|
index: Int,
|
||||||
|
fullWidth: Int,
|
||||||
|
reserve: Int,
|
||||||
|
reserveLines: Int,
|
||||||
|
): Int = if (index < reserveLines) fullWidth - reserve else fullWidth
|
||||||
|
|
||||||
|
/** Splits text at every source line break, then wraps it to the width left by the actions. */
|
||||||
|
internal fun splitBio(
|
||||||
|
measurer: TextMeasurer,
|
||||||
|
text: String,
|
||||||
|
style: TextStyle,
|
||||||
|
fullWidth: Int,
|
||||||
|
reserve: Int,
|
||||||
|
reserveLines: Int,
|
||||||
|
): List<String> {
|
||||||
|
val lines = mutableListOf<String>()
|
||||||
|
text.split('\n').forEach { sourceLine ->
|
||||||
|
val words = sourceLine.trim().split(' ').filter { it.isNotEmpty() }
|
||||||
|
if (words.isEmpty()) {
|
||||||
|
lines += ""
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
var current = ""
|
||||||
|
for (word in words) {
|
||||||
|
val candidate = if (current.isEmpty()) word else "$current $word"
|
||||||
|
val available = bioLineWidth(lines.size, fullWidth, reserve, reserveLines)
|
||||||
|
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= available) {
|
||||||
|
current = candidate
|
||||||
|
} else if (current.isEmpty()) {
|
||||||
|
lines += candidate
|
||||||
|
} else {
|
||||||
|
lines += current
|
||||||
|
current = word
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current.isNotEmpty()) lines += current
|
||||||
|
}
|
||||||
|
while (lines.isNotEmpty() && lines.last().isBlank()) lines.removeAt(lines.lastIndex)
|
||||||
|
return lines.ifEmpty { listOf("") }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun splitBioExpanded(
|
||||||
|
measurer: TextMeasurer,
|
||||||
|
text: String,
|
||||||
|
style: TextStyle,
|
||||||
|
fullWidth: Int,
|
||||||
|
reserve: Int,
|
||||||
|
reserveLines: Int,
|
||||||
|
lessText: String,
|
||||||
|
): BioLines {
|
||||||
|
val lines = splitBio(measurer, text, style, fullWidth, reserve, reserveLines).mapTo(mutableListOf()) { AnnotatedString(it) }
|
||||||
|
appendBioControl(lines, lessText, measurer, style, fullWidth, reserve, reserveLines)
|
||||||
|
return BioLines(lines, more = false, less = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appendBioControl(
|
||||||
|
lines: MutableList<AnnotatedString>,
|
||||||
|
label: String,
|
||||||
|
measurer: TextMeasurer,
|
||||||
|
style: TextStyle,
|
||||||
|
fullWidth: Int,
|
||||||
|
reserve: Int,
|
||||||
|
reserveLines: Int,
|
||||||
|
) {
|
||||||
|
while (lines.isNotEmpty() && lines.last().text.isBlank()) lines.removeAt(lines.lastIndex)
|
||||||
|
val control =
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(" ")
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(label) }
|
||||||
|
}
|
||||||
|
if (lines.isEmpty()) {
|
||||||
|
lines += control
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val lastIndex = lines.lastIndex
|
||||||
|
val available = bioLineWidth(lastIndex, fullWidth, reserve, reserveLines)
|
||||||
|
val combined =
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(lines[lastIndex])
|
||||||
|
append(control)
|
||||||
|
}
|
||||||
|
if (measurer.measure(combined, style, maxLines = 1).size.width <= available) {
|
||||||
|
lines[lastIndex] = combined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val words = lines[lastIndex].text.split(' ').filter { it.isNotEmpty() }
|
||||||
|
if (words.size > 1) {
|
||||||
|
val tailLine =
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(words.last())
|
||||||
|
append(control)
|
||||||
|
}
|
||||||
|
if (measurer.measure(tailLine, style, maxLines = 1).size.width <= available) {
|
||||||
|
lines[lastIndex] = AnnotatedString(words.dropLast(1).joinToString(" "))
|
||||||
|
lines += tailLine
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines += control
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class BioLines(
|
||||||
|
val lines: List<AnnotatedString>,
|
||||||
|
val more: Boolean,
|
||||||
|
val less: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Collapses text to [maxLines] lines, appending a bold More only when it does not fit. */
|
||||||
|
internal fun splitBioCollapsed(
|
||||||
|
measurer: TextMeasurer,
|
||||||
|
text: String,
|
||||||
|
style: TextStyle,
|
||||||
|
fullWidth: Int,
|
||||||
|
reserve: Int,
|
||||||
|
reserveLines: Int,
|
||||||
|
moreText: String,
|
||||||
|
maxLines: Int = 2,
|
||||||
|
): BioLines {
|
||||||
|
val words =
|
||||||
|
text
|
||||||
|
.replace("\n\n", " ")
|
||||||
|
.replace('\n', ' ')
|
||||||
|
.split(' ')
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
if (words.isEmpty()) return BioLines(listOf(AnnotatedString("")), more = false)
|
||||||
|
val lines = mutableListOf<AnnotatedString>()
|
||||||
|
var index = 0
|
||||||
|
while (index < words.size && lines.size < maxLines - 1) {
|
||||||
|
var current = ""
|
||||||
|
while (index < words.size) {
|
||||||
|
val candidate = if (current.isEmpty()) words[index] else "$current ${words[index]}"
|
||||||
|
val available = bioLineWidth(lines.size, fullWidth, reserve, reserveLines)
|
||||||
|
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= available) {
|
||||||
|
current = candidate
|
||||||
|
index++
|
||||||
|
} else if (current.isEmpty()) {
|
||||||
|
current = words[index]
|
||||||
|
index++
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines += AnnotatedString(current)
|
||||||
|
}
|
||||||
|
if (index >= words.size) {
|
||||||
|
return BioLines(lines, more = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val remainingAvailable = bioLineWidth(lines.size, fullWidth, reserve, reserveLines)
|
||||||
|
val remaining = words.drop(index).joinToString(" ")
|
||||||
|
if (measurer.measure(AnnotatedString(remaining), style, maxLines = 1).size.width <= remainingAvailable) {
|
||||||
|
lines += AnnotatedString(remaining)
|
||||||
|
return BioLines(lines, more = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val more =
|
||||||
|
buildAnnotatedString {
|
||||||
|
append("… ")
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(moreText) }
|
||||||
|
}
|
||||||
|
val moreWidth =
|
||||||
|
measurer.measure(more, style, maxLines = 1).size.width +
|
||||||
|
measurer.measure(AnnotatedString(" "), style, maxLines = 1).size.width
|
||||||
|
val lastAvailable = (bioLineWidth(lines.size, fullWidth, reserve, reserveLines) - moreWidth).coerceAtLeast(1)
|
||||||
|
var truncated = ""
|
||||||
|
while (index < words.size) {
|
||||||
|
val candidate = if (truncated.isEmpty()) words[index] else "$truncated ${words[index]}"
|
||||||
|
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= lastAvailable) {
|
||||||
|
truncated = candidate
|
||||||
|
index++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val lastLine =
|
||||||
|
buildAnnotatedString {
|
||||||
|
if (truncated.isNotEmpty()) append("$truncated ")
|
||||||
|
append("… ")
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(moreText) }
|
||||||
|
}
|
||||||
|
lines += lastLine
|
||||||
|
return BioLines(lines, more = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ArtistSectionHeader(
|
||||||
|
text: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
topGap: Dp = 0.dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 16.dp, end = 16.dp, top = topGap, bottom = 4.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = text.uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
letterSpacing = 1.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ArtistActions(
|
||||||
|
artist: Artist?,
|
||||||
|
starred: Boolean,
|
||||||
|
onToggleStar: (() -> Unit)?,
|
||||||
|
onMore: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (artist == null) return
|
||||||
|
IconButton(
|
||||||
|
onClick = { onToggleStar?.invoke() },
|
||||||
|
enabled = onToggleStar != null,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder,
|
||||||
|
contentDescription =
|
||||||
|
stringResource(
|
||||||
|
if (starred) R.string.actions_unstar else R.string.actions_star,
|
||||||
|
),
|
||||||
|
tint =
|
||||||
|
if (starred) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onMore) {
|
||||||
|
Icon(Icons.Rounded.MoreHoriz, contentDescription = stringResource(R.string.more_options))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ArtistAlbumCard(
|
||||||
|
album: Album,
|
||||||
|
index: Int,
|
||||||
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
downloadStatus: ListDownloadStatus?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: (CoverArtRef?) -> Unit,
|
||||||
|
onSizeChanged: (Float) -> Unit,
|
||||||
|
) {
|
||||||
|
val art = coverArt(album.coverArt, false)
|
||||||
|
val thumbnail = coverArt(album.coverArt, true)
|
||||||
|
PrefetchArtworkSeeds(thumbnail)
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(
|
||||||
|
start = if (index % 2 == 0) 16.dp else 0.dp,
|
||||||
|
end = if (index % 2 == 1) 16.dp else 0.dp,
|
||||||
|
).combinedClickable(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = { onLongClick(thumbnail) },
|
||||||
|
).onSizeChanged { onSizeChanged(it.height.toFloat()) },
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
|
||||||
|
) {
|
||||||
|
CoverArt(
|
||||||
|
ref = art,
|
||||||
|
name = album.name,
|
||||||
|
thumbnailRef = thumbnail,
|
||||||
|
showPlaceholder = art == null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
ListDownloadIndicator(
|
||||||
|
status = downloadStatus,
|
||||||
|
modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp),
|
||||||
|
scrim = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = album.name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistTitle(
|
private fun ArtistTitle(
|
||||||
text: String,
|
text: String,
|
||||||
style: TextStyle,
|
style: TextStyle,
|
||||||
color: Color,
|
color: Color,
|
||||||
endReserve: Dp,
|
endReserve: Dp,
|
||||||
|
maxLines: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val measurer = rememberTextMeasurer()
|
val measurer = rememberTextMeasurer()
|
||||||
@@ -512,7 +1016,10 @@ private fun ArtistTitle(
|
|||||||
BoxWithConstraints(modifier) {
|
BoxWithConstraints(modifier) {
|
||||||
val fullWidth = with(density) { maxWidth.roundToPx() }
|
val fullWidth = with(density) { maxWidth.roundToPx() }
|
||||||
val reserve = with(density) { endReserve.roundToPx() }
|
val reserve = with(density) { endReserve.roundToPx() }
|
||||||
val lines = remember(text, style, fullWidth, reserve) { splitTitle(measurer, text, style, fullWidth, reserve) }
|
val lines =
|
||||||
|
remember(text, style, fullWidth, reserve, maxLines) {
|
||||||
|
splitTitle(measurer, text, style, fullWidth, reserve, maxLines)
|
||||||
|
}
|
||||||
Column {
|
Column {
|
||||||
lines.forEachIndexed { index, line ->
|
lines.forEachIndexed { index, line ->
|
||||||
Text(
|
Text(
|
||||||
@@ -537,6 +1044,7 @@ internal fun splitTitle(
|
|||||||
style: TextStyle,
|
style: TextStyle,
|
||||||
fullWidth: Int,
|
fullWidth: Int,
|
||||||
reserve: Int,
|
reserve: Int,
|
||||||
|
maxLines: Int = ARTIST_NAME_MAX_LINES,
|
||||||
): List<String> {
|
): List<String> {
|
||||||
if (text.isBlank()) return listOf(text)
|
if (text.isBlank()) return listOf(text)
|
||||||
if (measurer.measure(AnnotatedString(text), style, maxLines = 1).size.width <= fullWidth - reserve) {
|
if (measurer.measure(AnnotatedString(text), style, maxLines = 1).size.width <= fullWidth - reserve) {
|
||||||
@@ -544,18 +1052,36 @@ internal fun splitTitle(
|
|||||||
}
|
}
|
||||||
val words = text.split(' ').filter { it.isNotEmpty() }
|
val words = text.split(' ').filter { it.isNotEmpty() }
|
||||||
if (words.size <= 1) return listOf(text)
|
if (words.size <= 1) return listOf(text)
|
||||||
var count = 0
|
val lines = mutableListOf<String>()
|
||||||
var acc = ""
|
var index = 0
|
||||||
for (i in words.indices) {
|
while (index < words.size && lines.size < maxLines) {
|
||||||
val candidate = if (acc.isEmpty()) words[i] else "$acc ${words[i]}"
|
val remaining = words.subList(index, words.size).joinToString(" ")
|
||||||
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= fullWidth) {
|
if (lines.size == maxLines - 1 ||
|
||||||
acc = candidate
|
measurer.measure(AnnotatedString(remaining), style, maxLines = 1).size.width <= fullWidth - reserve
|
||||||
count = i + 1
|
) {
|
||||||
} else {
|
lines.add(remaining)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
var end = index
|
||||||
|
var acc = ""
|
||||||
|
while (end < words.size) {
|
||||||
|
val candidate = if (acc.isEmpty()) words[end] else "$acc ${words[end]}"
|
||||||
|
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= fullWidth) {
|
||||||
|
acc = candidate
|
||||||
|
end++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (end == index) {
|
||||||
|
acc = words[index]
|
||||||
|
end = index + 1
|
||||||
|
} else if (end >= words.size && end - index > 1) {
|
||||||
|
end -= 1
|
||||||
|
acc = words.subList(index, end).joinToString(" ")
|
||||||
|
}
|
||||||
|
lines.add(acc)
|
||||||
|
index = end
|
||||||
}
|
}
|
||||||
if (count >= words.size) count = words.size - 1
|
return lines
|
||||||
if (count < 1) count = 1
|
|
||||||
return listOf(words.take(count).joinToString(" "), words.drop(count).joinToString(" "))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ package com.subtracks.ui.library
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.subtracks.data.model.Album
|
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistAlbums
|
||||||
|
import com.subtracks.data.model.ArtistBio
|
||||||
import com.subtracks.data.model.BulkDownloadAction
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.DownloadList
|
import com.subtracks.data.model.DownloadList
|
||||||
@@ -12,9 +13,10 @@ import com.subtracks.data.repo.DownloadRepository
|
|||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.artistArt
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -36,21 +38,26 @@ class ArtistDetailViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
|
|
||||||
val artist: Flow<Artist?> = sourceId.flatMapLatest { libraryRepository.artist(it, artistId) }
|
val artist: StateFlow<Artist?> =
|
||||||
|
sourceId
|
||||||
|
.flatMapLatest { libraryRepository.artist(it, artistId) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
val artistName: Flow<String?> =
|
val artistName: StateFlow<String?> =
|
||||||
sourceId.flatMapLatest { id ->
|
sourceId
|
||||||
combine(
|
.flatMapLatest { id ->
|
||||||
libraryRepository.artist(id, artistId),
|
combine(
|
||||||
libraryRepository.artistName(id, artistId),
|
libraryRepository.artist(id, artistId),
|
||||||
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
|
libraryRepository.artistName(id, artistId),
|
||||||
}
|
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||||
|
|
||||||
val albums: Flow<List<Album>> =
|
val albums: StateFlow<ArtistAlbums?> =
|
||||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||||
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
|
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
val downloadStatus: StateFlow<ListDownloadStatus> =
|
val downloadStatus: StateFlow<ListDownloadStatus> =
|
||||||
sourceId
|
sourceId
|
||||||
@@ -62,6 +69,55 @@ class ArtistDetailViewModel(
|
|||||||
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
|
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||||
|
|
||||||
|
val artistBio: StateFlow<ArtistBio?> =
|
||||||
|
sourceId
|
||||||
|
.flatMapLatest { libraryRepository.artistBio(it, artistId) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
|
private val _bioLoading = MutableStateFlow(true)
|
||||||
|
val bioLoading: StateFlow<Boolean> = _bioLoading
|
||||||
|
|
||||||
|
private val _bioUnavailable = MutableStateFlow(false)
|
||||||
|
val bioUnavailable: StateFlow<Boolean> = _bioUnavailable
|
||||||
|
|
||||||
|
private val _bioExpanded = MutableStateFlow(false)
|
||||||
|
val bioExpanded: StateFlow<Boolean> = _bioExpanded
|
||||||
|
|
||||||
|
private var bioRefresh: Job? = null
|
||||||
|
|
||||||
|
fun refreshBio() {
|
||||||
|
if (bioRefresh?.isActive == true) return
|
||||||
|
bioRefresh =
|
||||||
|
viewModelScope.launch {
|
||||||
|
_bioUnavailable.value = false
|
||||||
|
if (sourceRepository.offline.value) {
|
||||||
|
_bioLoading.value = false
|
||||||
|
_bioUnavailable.value = true
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val id = sourceRepository.activeSourceIdOnce()
|
||||||
|
if (id == null) {
|
||||||
|
_bioLoading.value = false
|
||||||
|
_bioUnavailable.value = true
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
_bioLoading.value = true
|
||||||
|
try {
|
||||||
|
_bioUnavailable.value = libraryRepository.refreshArtistBio(id, artistId).isFailure
|
||||||
|
} finally {
|
||||||
|
_bioLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun expandBio() {
|
||||||
|
_bioExpanded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun collapseBio() {
|
||||||
|
_bioExpanded.value = false
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun downloadedBytes(
|
suspend fun downloadedBytes(
|
||||||
list: DownloadList,
|
list: DownloadList,
|
||||||
refId: String,
|
refId: String,
|
||||||
@@ -84,30 +140,36 @@ class ArtistDetailViewModel(
|
|||||||
private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null)
|
private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null)
|
||||||
val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail
|
val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail
|
||||||
|
|
||||||
|
private val _heroArt = MutableStateFlow<ArtistArt?>(null)
|
||||||
|
val heroArt: StateFlow<ArtistArt?> = _heroArt
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
artist
|
sourceRepository.activeSource
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.collectLatest { loaded ->
|
.flatMapLatest { sourceId ->
|
||||||
val (full, thumbnail) = resolveArt(loaded)
|
combine(
|
||||||
_art.value = full
|
libraryRepository.artist(sourceId, artistId),
|
||||||
_artThumbnail.value = thumbnail
|
libraryRepository.artistCoverArts(sourceId, artistId),
|
||||||
|
sourceRepository.offline,
|
||||||
|
) { loaded, covers, offline -> ArtistArtInput(loaded, artistId, offline, sourceId, covers) }
|
||||||
|
}.collectLatest { input ->
|
||||||
|
val resolved = resolveArtistArtInput(input)
|
||||||
|
if (resolved == null) {
|
||||||
|
_art.value = null
|
||||||
|
_artThumbnail.value = null
|
||||||
|
_heroArt.value = null
|
||||||
|
return@collectLatest
|
||||||
|
}
|
||||||
|
val hero = sourceRepository.artistArt(resolved, forIcon = false)
|
||||||
|
val icon = sourceRepository.artistArt(resolved, forIcon = true)
|
||||||
|
_art.value = hero.image ?: hero.covers.firstOrNull()
|
||||||
|
_artThumbnail.value = icon.image ?: icon.covers.firstOrNull()
|
||||||
|
_heroArt.value = hero
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun resolveArt(artist: Artist): Pair<CoverArtRef?, CoverArtRef?> {
|
|
||||||
if (artist.coverArt == null) return null to null
|
|
||||||
repeat(3) { attempt ->
|
|
||||||
val full = sourceRepository.coverArt(artist.coverArt)
|
|
||||||
if (full != null) {
|
|
||||||
return full to sourceRepository.coverArt(artist.coverArt, thumbnail = true)
|
|
||||||
}
|
|
||||||
if (attempt < 2) delay(300)
|
|
||||||
}
|
|
||||||
return null to null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun coverArt(
|
fun coverArt(
|
||||||
coverArt: String?,
|
coverArt: String?,
|
||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
@@ -119,3 +181,35 @@ class ArtistDetailViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal data class ArtistArtInput(
|
||||||
|
val artist: Artist?,
|
||||||
|
val artistId: String,
|
||||||
|
val offline: Boolean,
|
||||||
|
val sourceId: Long,
|
||||||
|
val covers: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun resolveArtistArtInput(input: ArtistArtInput): Artist? {
|
||||||
|
val artist = input.artist
|
||||||
|
if (input.covers.isEmpty()) return artist
|
||||||
|
if (artist == null) {
|
||||||
|
return Artist(
|
||||||
|
sourceId = input.sourceId,
|
||||||
|
id = input.artistId,
|
||||||
|
name = "",
|
||||||
|
albumCount = 0,
|
||||||
|
starred = null,
|
||||||
|
coverArts = input.covers,
|
||||||
|
downloadedCoverArts = if (input.offline) input.covers else emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return if (input.offline) {
|
||||||
|
artist.copy(
|
||||||
|
coverArts = input.covers,
|
||||||
|
downloadedCoverArts = artist.downloadedCoverArts.ifEmpty { input.covers },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
artist.copy(coverArts = input.covers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,13 +29,15 @@ import com.subtracks.R
|
|||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.ListDownloadStatus
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.ArtistIcon
|
||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
import com.subtracks.ui.components.FilteredEmptyState
|
import com.subtracks.ui.components.FilteredEmptyState
|
||||||
import com.subtracks.ui.components.ListDownloadIndicator
|
import com.subtracks.ui.components.ListDownloadIndicator
|
||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.ResetScrollOnChange
|
import com.subtracks.ui.components.ResetScrollOnChange
|
||||||
|
import com.subtracks.ui.components.ScrollToTopOnRequest
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
||||||
|
|
||||||
@@ -53,6 +55,8 @@ fun ArtistsContent(
|
|||||||
topInset: Dp = 0.dp,
|
topInset: Dp = 0.dp,
|
||||||
onSync: () -> Unit = {},
|
onSync: () -> Unit = {},
|
||||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||||
|
scrollToTopKey: Any? = null,
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||||
@@ -70,6 +74,7 @@ fun ArtistsContent(
|
|||||||
else -> {
|
else -> {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
||||||
|
ScrollToTopOnRequest(scrollToTopKey, listState)
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
@@ -79,7 +84,8 @@ fun ArtistsContent(
|
|||||||
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||||
val artist = items[index]
|
val artist = items[index]
|
||||||
if (artist != null) {
|
if (artist != null) {
|
||||||
PrefetchArtworkSeeds(coverArt(artist.coverArt, true))
|
val art = artistArt(artist)
|
||||||
|
PrefetchArtworkSeeds(art.primary)
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text(
|
Text(
|
||||||
@@ -101,9 +107,9 @@ fun ArtistsContent(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
CoverArt(
|
ArtistIcon(
|
||||||
ref = coverArt(artist.coverArt, true),
|
artist = artist,
|
||||||
name = artist.name,
|
art = art,
|
||||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -114,7 +120,7 @@ fun ArtistsContent(
|
|||||||
Modifier.combinedClickable(
|
Modifier.combinedClickable(
|
||||||
onClick = { onArtistClick(artist) },
|
onClick = { onArtistClick(artist) },
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true), downloadStatuses[artist.id]))
|
onLongClick(MenuTarget.Artist(artist, art.primary, downloadStatuses[artist.id]))
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.subtracks.ui.library
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -24,6 +26,7 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.PagerState
|
import androidx.compose.foundation.pager.PagerState
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -40,7 +43,9 @@ import androidx.compose.material.icons.rounded.Person
|
|||||||
import androidx.compose.material.icons.rounded.Remove
|
import androidx.compose.material.icons.rounded.Remove
|
||||||
import androidx.compose.material.icons.rounded.Search
|
import androidx.compose.material.icons.rounded.Search
|
||||||
import androidx.compose.material.icons.rounded.Settings
|
import androidx.compose.material.icons.rounded.Settings
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.FilterChipDefaults
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
@@ -69,7 +74,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
@@ -91,12 +95,15 @@ import androidx.compose.ui.layout.layout
|
|||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.Constraints
|
import androidx.compose.ui.unit.Constraints
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
@@ -117,6 +124,7 @@ import com.subtracks.data.prefs.PlaylistSort
|
|||||||
import com.subtracks.data.prefs.StarredFilter
|
import com.subtracks.data.prefs.StarredFilter
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||||
import com.subtracks.ui.components.DismissOnRequest
|
import com.subtracks.ui.components.DismissOnRequest
|
||||||
@@ -243,6 +251,7 @@ fun LibraryRoute(
|
|||||||
var previousTabName by remember { mutableStateOf(selectedTab.name) }
|
var previousTabName by remember { mutableStateOf(selectedTab.name) }
|
||||||
val previousTab = LibraryTab.entries.firstOrNull { it.name == previousTabName } ?: LibraryTab.Albums
|
val previousTab = LibraryTab.entries.firstOrNull { it.name == previousTabName } ?: LibraryTab.Albums
|
||||||
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
||||||
|
val refreshing by viewModel.refreshing.collectAsStateWithLifecycle()
|
||||||
val listTab = selectedTab.listTab()
|
val listTab = selectedTab.listTab()
|
||||||
LaunchedEffect(selectedTab) {
|
LaunchedEffect(selectedTab) {
|
||||||
if (previousTab != selectedTab) {
|
if (previousTab != selectedTab) {
|
||||||
@@ -270,6 +279,7 @@ fun LibraryRoute(
|
|||||||
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
|
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
|
||||||
val downloads = downloadsState?.value ?: emptyMap()
|
val downloads = downloadsState?.value ?: emptyMap()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
|
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
|
||||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
||||||
val itemActions =
|
val itemActions =
|
||||||
@@ -296,19 +306,24 @@ fun LibraryRoute(
|
|||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
onTabSelected = { tab -> if (viewModel.selectedTab.value != tab) viewModel.selectTab(tab) },
|
onTabSelected = { tab -> if (viewModel.selectedTab.value != tab) viewModel.selectTab(tab) },
|
||||||
syncing = syncing,
|
syncing = syncing,
|
||||||
|
refreshing = refreshing,
|
||||||
bottomInset = bottomInset,
|
bottomInset = bottomInset,
|
||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
albums = viewModel.albums,
|
albums = viewModel.albums,
|
||||||
artists = viewModel.artists,
|
artists = viewModel.artists,
|
||||||
playlists = viewModel.playlists,
|
playlists = viewModel.playlists,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onPlaylistClick = onPlaylistClick,
|
onPlaylistClick = onPlaylistClick,
|
||||||
onSync = viewModel::sync,
|
onSync = viewModel::sync,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onItemLongClick = { contextMenuHost?.show(it, itemActions) },
|
onItemLongClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
contextMenuHost?.show(it, itemActions)
|
||||||
|
},
|
||||||
listQuery = displayQuery,
|
listQuery = displayQuery,
|
||||||
resetKeys = resetKeys,
|
resetKeys = resetKeys,
|
||||||
offline = offline,
|
offline = offline,
|
||||||
@@ -387,11 +402,13 @@ fun LibraryScreen(
|
|||||||
onSync: () -> Unit,
|
onSync: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
onOpenSearch: () -> Unit = {},
|
onOpenSearch: () -> Unit = {},
|
||||||
offline: Boolean = false,
|
offline: Boolean = false,
|
||||||
onExitOffline: () -> Unit = {},
|
onExitOffline: () -> Unit = {},
|
||||||
onItemLongClick: (MenuTarget) -> Unit = {},
|
onItemLongClick: (MenuTarget) -> Unit = {},
|
||||||
syncing: Boolean = false,
|
syncing: Boolean = false,
|
||||||
|
refreshing: Boolean = false,
|
||||||
bottomInset: Dp = 0.dp,
|
bottomInset: Dp = 0.dp,
|
||||||
listQuery: ListQuery = ListQuery(""),
|
listQuery: ListQuery = ListQuery(""),
|
||||||
resetKeys: Map<LibraryTab, Any?> = emptyMap(),
|
resetKeys: Map<LibraryTab, Any?> = emptyMap(),
|
||||||
@@ -418,10 +435,16 @@ fun LibraryScreen(
|
|||||||
)
|
)
|
||||||
var showOptions by rememberSaveable { mutableStateOf(false) }
|
var showOptions by rememberSaveable { mutableStateOf(false) }
|
||||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var topRequest by remember { mutableIntStateOf(0) }
|
||||||
val searchFocus = remember { FocusRequester() }
|
val searchFocus = remember { FocusRequester() }
|
||||||
|
val closeSearch = {
|
||||||
|
searchActive = false
|
||||||
|
onSearchChange("")
|
||||||
|
}
|
||||||
|
|
||||||
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
|
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
|
||||||
DismissOnRequest(dismissals) { showOptions = false }
|
DismissOnRequest(dismissals) { showOptions = false }
|
||||||
|
BackHandler(enabled = searchActive, onBack = closeSearch)
|
||||||
|
|
||||||
LaunchedEffect(pagerState) {
|
LaunchedEffect(pagerState) {
|
||||||
snapshotFlow { pagerState.settledPage }.collect { page ->
|
snapshotFlow { pagerState.settledPage }.collect { page ->
|
||||||
@@ -440,14 +463,8 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismissSearch() {
|
|
||||||
if (searchActive) {
|
|
||||||
searchActive = false
|
|
||||||
onSearchChange("")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||||
val filtersActive = listQuery.starred != StarredFilter.Any || listQuery.downloaded || search.isNotEmpty()
|
val filtersActive = listQuery.starred != StarredFilter.Any || listQuery.downloaded || search.isNotEmpty()
|
||||||
var tabBarHeightPx by remember { mutableFloatStateOf(with(density) { (TAB_BAR_CONTENT_HEIGHT + bottomInset).toPx() }) }
|
var tabBarHeightPx by remember { mutableFloatStateOf(with(density) { (TAB_BAR_CONTENT_HEIGHT + bottomInset).toPx() }) }
|
||||||
@@ -470,15 +487,16 @@ fun LibraryScreen(
|
|||||||
) { page ->
|
) { page ->
|
||||||
val pageTab = LibraryTab.entries[page]
|
val pageTab = LibraryTab.entries[page]
|
||||||
val resetKey = resetKeys[pageTab]
|
val resetKey = resetKeys[pageTab]
|
||||||
|
val scrollToTopKey = if (pageTab == selectedTab) topRequest else null
|
||||||
val pullToRefreshState = rememberPullToRefreshState()
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
PullToRefreshBox(
|
PullToRefreshBox(
|
||||||
isRefreshing = syncing,
|
isRefreshing = refreshing,
|
||||||
onRefresh = onSync,
|
onRefresh = onSync,
|
||||||
state = pullToRefreshState,
|
state = pullToRefreshState,
|
||||||
indicator = {
|
indicator = {
|
||||||
PullToRefreshDefaults.Indicator(
|
PullToRefreshDefaults.Indicator(
|
||||||
state = pullToRefreshState,
|
state = pullToRefreshState,
|
||||||
isRefreshing = syncing,
|
isRefreshing = refreshing,
|
||||||
modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding(),
|
modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding(),
|
||||||
containerColor = headerColor,
|
containerColor = headerColor,
|
||||||
color = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.primary,
|
color = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.primary,
|
||||||
@@ -497,7 +515,7 @@ fun LibraryScreen(
|
|||||||
coverArt,
|
coverArt,
|
||||||
listBottomInset,
|
listBottomInset,
|
||||||
{
|
{
|
||||||
dismissSearch()
|
focusManager.clearFocus()
|
||||||
onAlbumClick(it)
|
onAlbumClick(it)
|
||||||
},
|
},
|
||||||
onLongClick = onItemLongClick,
|
onLongClick = onItemLongClick,
|
||||||
@@ -507,6 +525,7 @@ fun LibraryScreen(
|
|||||||
topInset = listTopInset,
|
topInset = listTopInset,
|
||||||
onSync = onSync,
|
onSync = onSync,
|
||||||
downloadStatuses = albumDownloads,
|
downloadStatuses = albumDownloads,
|
||||||
|
scrollToTopKey = scrollToTopKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,7 +535,7 @@ fun LibraryScreen(
|
|||||||
coverArt,
|
coverArt,
|
||||||
listBottomInset,
|
listBottomInset,
|
||||||
{
|
{
|
||||||
dismissSearch()
|
focusManager.clearFocus()
|
||||||
onArtistClick(it)
|
onArtistClick(it)
|
||||||
},
|
},
|
||||||
onLongClick = onItemLongClick,
|
onLongClick = onItemLongClick,
|
||||||
@@ -526,6 +545,8 @@ fun LibraryScreen(
|
|||||||
topInset = listTopInset,
|
topInset = listTopInset,
|
||||||
onSync = onSync,
|
onSync = onSync,
|
||||||
downloadStatuses = artistDownloads,
|
downloadStatuses = artistDownloads,
|
||||||
|
scrollToTopKey = scrollToTopKey,
|
||||||
|
artistArt = artistArt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,7 +556,7 @@ fun LibraryScreen(
|
|||||||
coverArt,
|
coverArt,
|
||||||
listBottomInset,
|
listBottomInset,
|
||||||
{
|
{
|
||||||
dismissSearch()
|
focusManager.clearFocus()
|
||||||
onPlaylistClick(it)
|
onPlaylistClick(it)
|
||||||
},
|
},
|
||||||
onLongClick = onItemLongClick,
|
onLongClick = onItemLongClick,
|
||||||
@@ -545,6 +566,7 @@ fun LibraryScreen(
|
|||||||
topInset = listTopInset,
|
topInset = listTopInset,
|
||||||
onSync = onSync,
|
onSync = onSync,
|
||||||
downloadStatuses = playlistDownloads,
|
downloadStatuses = playlistDownloads,
|
||||||
|
scrollToTopKey = scrollToTopKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -570,11 +592,12 @@ fun LibraryScreen(
|
|||||||
) {
|
) {
|
||||||
LibraryTabs(
|
LibraryTabs(
|
||||||
pagerState = pagerState,
|
pagerState = pagerState,
|
||||||
onTabSelected = onTabSelected,
|
onTabSelected = { tab ->
|
||||||
|
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
|
||||||
|
},
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
offline = offline,
|
|
||||||
onExitOffline = onExitOffline,
|
|
||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
|
syncing = syncing,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,10 +606,7 @@ fun LibraryScreen(
|
|||||||
SearchField(
|
SearchField(
|
||||||
value = search,
|
value = search,
|
||||||
onValueChange = onSearchChange,
|
onValueChange = onSearchChange,
|
||||||
onClose = {
|
onClose = closeSearch,
|
||||||
searchActive = false
|
|
||||||
onSearchChange("")
|
|
||||||
},
|
|
||||||
focusRequester = searchFocus,
|
focusRequester = searchFocus,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -597,11 +617,29 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!searchActive) {
|
if (!searchActive) {
|
||||||
|
val fabContainerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
val fabContentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground
|
||||||
|
if (offline) {
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onExitOffline,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.padding(start = 16.dp, bottom = tabBarHeight + 16.dp)
|
||||||
|
.size(48.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.CloudOff,
|
||||||
|
contentDescription = stringResource(R.string.offline_mode_status),
|
||||||
|
modifier = Modifier.size(24.dp).offset(y = (-0.75).dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
val searchFab = selectedTab == LibraryTab.Home
|
val searchFab = selectedTab == LibraryTab.Home
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = { if (searchFab) onOpenSearch() else showOptions = true },
|
onClick = { if (searchFab) onOpenSearch() else showOptions = true },
|
||||||
containerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh,
|
containerColor = fabContainerColor,
|
||||||
contentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground,
|
contentColor = fabContentColor,
|
||||||
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp),
|
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp),
|
||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp),
|
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp),
|
||||||
) {
|
) {
|
||||||
@@ -620,11 +658,18 @@ fun LibraryScreen(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.offset(x = 4.dp, y = (-4).dp)
|
.offset(x = 1.dp, y = 2.5.dp)
|
||||||
.size(8.dp)
|
.size(11.dp)
|
||||||
.clip(CircleShape)
|
.background(fabContainerColor, CircleShape),
|
||||||
.background(artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.primary),
|
contentAlignment = Alignment.Center,
|
||||||
)
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(7.dp)
|
||||||
|
.background(fabContentColor, CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -684,7 +729,7 @@ private fun ListOptionsSheet(
|
|||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
Text(stringResource(R.string.search_this_list), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(stringResource(R.string.search_this_list), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
@@ -806,6 +851,7 @@ private fun ListOptionsSheet(
|
|||||||
|
|
||||||
private val BOTTOM_CLEARANCE = 80.dp
|
private val BOTTOM_CLEARANCE = 80.dp
|
||||||
private val TAB_BAR_CONTENT_HEIGHT = 52.dp
|
private val TAB_BAR_CONTENT_HEIGHT = 52.dp
|
||||||
|
private val TAB_STRIP_LEADING_GAP = 8.dp
|
||||||
private val TAB_ICON_SIZE = 24.dp
|
private val TAB_ICON_SIZE = 24.dp
|
||||||
private val TAB_VERTICAL_PADDING = 6.dp
|
private val TAB_VERTICAL_PADDING = 6.dp
|
||||||
private val TOUCH_TARGET = 48.dp
|
private val TOUCH_TARGET = 48.dp
|
||||||
@@ -838,12 +884,12 @@ internal fun LibraryTabs(
|
|||||||
pagerState: PagerState,
|
pagerState: PagerState,
|
||||||
onTabSelected: (LibraryTab) -> Unit,
|
onTabSelected: (LibraryTab) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
offline: Boolean = false,
|
|
||||||
onExitOffline: () -> Unit = {},
|
|
||||||
artwork: ArtworkColors?,
|
artwork: ArtworkColors?,
|
||||||
|
syncing: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val indicatorStretch = 18.dp
|
val indicatorStretch = 18.dp
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
val bounds = remember { mutableStateMapOf<Int, Rect>() }
|
val bounds = remember { mutableStateMapOf<Int, Rect>() }
|
||||||
val page = pagerState.currentPage
|
val page = pagerState.currentPage
|
||||||
val fraction = pagerState.currentPageOffsetFraction
|
val fraction = pagerState.currentPageOffsetFraction
|
||||||
@@ -875,68 +921,111 @@ internal fun LibraryTabs(
|
|||||||
val indicatorColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.onBackground
|
val indicatorColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.onBackground
|
||||||
val tabContentHeightPx = with(density) { (TAB_ICON_SIZE + TAB_VERTICAL_PADDING * 2).toPx() }
|
val tabContentHeightPx = with(density) { (TAB_ICON_SIZE + TAB_VERTICAL_PADDING * 2).toPx() }
|
||||||
|
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
var viewportWidth by remember { mutableIntStateOf(0) }
|
||||||
|
var contentWidth by remember { mutableIntStateOf(0) }
|
||||||
|
var revealed by remember { mutableStateOf(false) }
|
||||||
|
val leadingGap = with(density) { TAB_STRIP_LEADING_GAP.toPx() }
|
||||||
|
LaunchedEffect(pagerState.settledPage, viewportWidth, contentWidth, layoutDirection, bounds[pagerState.settledPage]) {
|
||||||
|
if (viewportWidth == 0) return@LaunchedEffect
|
||||||
|
val settled = bounds[pagerState.settledPage] ?: return@LaunchedEffect
|
||||||
|
val start = if (layoutDirection == LayoutDirection.Rtl) contentWidth - settled.right else leadingGap + settled.left
|
||||||
|
val end = if (layoutDirection == LayoutDirection.Rtl) contentWidth - settled.left else leadingGap + settled.right
|
||||||
|
val target =
|
||||||
|
when {
|
||||||
|
start - leadingGap < scrollState.value -> start - leadingGap
|
||||||
|
end > scrollState.value + viewportWidth -> end - viewportWidth
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (target != null) {
|
||||||
|
val offset = target.roundToInt().coerceIn(0, scrollState.maxValue)
|
||||||
|
if (revealed) scrollState.animateScrollTo(offset) else scrollState.scrollTo(offset)
|
||||||
|
}
|
||||||
|
revealed = true
|
||||||
|
}
|
||||||
|
|
||||||
Box(Modifier.fillMaxWidth()) {
|
Box(Modifier.fillMaxWidth()) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(headerColor)
|
.background(headerColor)
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
.padding(top = 2.dp, end = 8.dp, bottom = 2.dp),
|
||||||
) {
|
) {
|
||||||
Box(
|
Row(
|
||||||
modifier =
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Modifier.drawBehind {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
if (current != null && indicatorLeft != null && indicatorRight != null && indicatorRight > indicatorLeft) {
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
val height = tabContentHeightPx.coerceAtMost(current.height)
|
|
||||||
drawRoundRect(
|
|
||||||
color = indicatorColor,
|
|
||||||
topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f),
|
|
||||||
size = Size(indicatorRight - indicatorLeft, height),
|
|
||||||
cornerRadius = CornerRadius(8.dp.toPx()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.onSizeChanged { viewportWidth = it.width }
|
||||||
|
.horizontalScroll(scrollState)
|
||||||
|
.onSizeChanged { contentWidth = it.width },
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
) {
|
||||||
LibraryTab.entries.forEachIndexed { index, tab ->
|
Spacer(Modifier.width(TAB_STRIP_LEADING_GAP))
|
||||||
TabButton(
|
Box(
|
||||||
tab = tab,
|
modifier =
|
||||||
artwork = artwork,
|
Modifier
|
||||||
progress =
|
.drawBehind {
|
||||||
when (index) {
|
if (current != null &&
|
||||||
page -> 1f - transition
|
indicatorLeft != null &&
|
||||||
page + (if (fraction >= 0f) 1 else -1) -> transition
|
indicatorRight != null &&
|
||||||
else -> 0f
|
indicatorRight > indicatorLeft
|
||||||
|
) {
|
||||||
|
val height = tabContentHeightPx.coerceAtMost(current.height)
|
||||||
|
drawRoundRect(
|
||||||
|
color = indicatorColor,
|
||||||
|
topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f),
|
||||||
|
size = Size(indicatorRight - indicatorLeft, height),
|
||||||
|
cornerRadius = CornerRadius(8.dp.toPx()),
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
indicatorLeft = indicatorLeft,
|
) {
|
||||||
indicatorRight = indicatorRight,
|
Row(
|
||||||
onClick = { onTabSelected(tab) },
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier =
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
Modifier.onGloballyPositioned { coordinates ->
|
) {
|
||||||
bounds[index] = coordinates.boundsInParent()
|
LibraryTab.entries.forEachIndexed { index, tab ->
|
||||||
},
|
TabButton(
|
||||||
)
|
tab = tab,
|
||||||
}
|
artwork = artwork,
|
||||||
Spacer(Modifier.weight(1f))
|
progress =
|
||||||
if (offline) {
|
when (index) {
|
||||||
IconButton(onClick = onExitOffline) {
|
page -> 1f - transition
|
||||||
Icon(
|
page + (if (fraction >= 0f) 1 else -1) -> transition
|
||||||
imageVector = Icons.Rounded.CloudOff,
|
else -> 0f
|
||||||
contentDescription = stringResource(R.string.offline_mode_status),
|
},
|
||||||
tint = MaterialTheme.colorScheme.onBackground,
|
indicatorLeft = indicatorLeft,
|
||||||
)
|
indicatorRight = indicatorRight,
|
||||||
|
onClick = { onTabSelected(tab) },
|
||||||
|
modifier =
|
||||||
|
Modifier.onGloballyPositioned { coordinates ->
|
||||||
|
bounds[index] = coordinates.boundsInParent()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = onOpenSettings) {
|
}
|
||||||
|
IconButton(onClick = onOpenSettings) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Settings,
|
imageVector = Icons.Rounded.Settings,
|
||||||
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
||||||
tint = MaterialTheme.colorScheme.onBackground,
|
tint = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
|
if (syncing) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import com.subtracks.data.repo.SourceRepository
|
|||||||
import com.subtracks.data.sync.SyncManager
|
import com.subtracks.data.sync.SyncManager
|
||||||
import com.subtracks.data.sync.SyncStatus
|
import com.subtracks.data.sync.SyncStatus
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.artistArt
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -117,7 +119,12 @@ class LibraryViewModel(
|
|||||||
|
|
||||||
val syncing: StateFlow<Boolean> =
|
val syncing: StateFlow<Boolean> =
|
||||||
syncManager.status
|
syncManager.status
|
||||||
.map { it == SyncStatus.Running }
|
.map { it is SyncStatus.Running }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
|
val refreshing: StateFlow<Boolean> =
|
||||||
|
syncManager.status
|
||||||
|
.map { it is SyncStatus.Running && !it.silent }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
private val activeSourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val activeSourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
@@ -206,7 +213,14 @@ class LibraryViewModel(
|
|||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
fun sync() = syncManager.requestSync()
|
fun artistArt(
|
||||||
|
artist: Artist,
|
||||||
|
forIcon: Boolean = true,
|
||||||
|
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||||
|
|
||||||
|
fun sync() {
|
||||||
|
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name
|
internal fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ fun PlaylistDetailRoute(
|
|||||||
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
|
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
|
||||||
playbackController: PlaybackController = koinInject(),
|
playbackController: PlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
|
val playlist by viewModel.playlist.collectAsStateWithLifecycle()
|
||||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
@@ -138,6 +138,7 @@ fun PlaylistDetailScreen(
|
|||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
title = playlist?.name.orEmpty(),
|
title = playlist?.name.orEmpty(),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
contentReady = songs.itemCount > 0,
|
||||||
header = { controlsModifier, topInset ->
|
header = { controlsModifier, topInset ->
|
||||||
HeroHeader(
|
HeroHeader(
|
||||||
art = coverArt(playlist?.coverArt, false),
|
art = coverArt(playlist?.coverArt, false),
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ class PlaylistDetailViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
|
|
||||||
val playlist: Flow<Playlist?> = sourceId.flatMapLatest { libraryRepository.playlist(it, playlistId) }
|
val playlist: StateFlow<Playlist?> =
|
||||||
|
sourceId
|
||||||
|
.flatMapLatest { libraryRepository.playlist(it, playlistId) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
val songs: Flow<PagingData<PlaylistSongItem>> =
|
val songs: Flow<PagingData<PlaylistSongItem>> =
|
||||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
|
|||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.ResetScrollOnChange
|
import com.subtracks.ui.components.ResetScrollOnChange
|
||||||
|
import com.subtracks.ui.components.ScrollToTopOnRequest
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -52,6 +53,7 @@ fun PlaylistsContent(
|
|||||||
topInset: Dp = 0.dp,
|
topInset: Dp = 0.dp,
|
||||||
onSync: () -> Unit = {},
|
onSync: () -> Unit = {},
|
||||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||||
|
scrollToTopKey: Any? = null,
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||||
@@ -74,6 +76,7 @@ fun PlaylistsContent(
|
|||||||
else -> {
|
else -> {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
||||||
|
ScrollToTopOnRequest(scrollToTopKey, listState)
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import com.subtracks.data.model.DownloadStatus
|
|||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
|
import com.subtracks.ui.components.artistSubtitle
|
||||||
|
|
||||||
private val TRACK_COLUMN_WIDTH = 24.dp
|
private val TRACK_COLUMN_WIDTH = 24.dp
|
||||||
private val TIME_COLUMN_WIDTH = 36.dp
|
private val TIME_COLUMN_WIDTH = 36.dp
|
||||||
@@ -146,7 +147,7 @@ fun SongRow(
|
|||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Text(
|
Text(
|
||||||
text = song.artist.orEmpty().ifEmpty { "\u00A0" },
|
text = song.artistSubtitle().orEmpty().ifEmpty { "\u00A0" },
|
||||||
color = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
color = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import com.subtracks.R
|
|||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.playback.PlaybackState
|
import com.subtracks.playback.PlaybackState
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
|
import com.subtracks.ui.components.artistSubtitle
|
||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
import com.subtracks.ui.theme.ArtworkTheme
|
import com.subtracks.ui.theme.ArtworkTheme
|
||||||
import com.subtracks.ui.theme.playerSurfaceColor
|
import com.subtracks.ui.theme.playerSurfaceColor
|
||||||
@@ -139,7 +140,7 @@ fun MiniPlayer(
|
|||||||
modifier = Modifier.basicMarquee(),
|
modifier = Modifier.basicMarquee(),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = item.artist ?: item.album.orEmpty(),
|
text = artistSubtitle(item.artist, item.artists) ?: item.album.orEmpty(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|||||||
@@ -58,9 +58,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.keepScreenOn
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
@@ -77,6 +77,7 @@ import com.subtracks.R
|
|||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.QueueKind
|
import com.subtracks.data.model.QueueKind
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
@@ -84,10 +85,12 @@ import com.subtracks.playback.PlaybackController
|
|||||||
import com.subtracks.playback.PlaybackState
|
import com.subtracks.playback.PlaybackState
|
||||||
import com.subtracks.playback.QueueItem
|
import com.subtracks.playback.QueueItem
|
||||||
import com.subtracks.playback.RepeatMode
|
import com.subtracks.playback.RepeatMode
|
||||||
|
import com.subtracks.ui.components.ArtistChooserSheet
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
|
import com.subtracks.ui.components.artistSubtitle
|
||||||
import com.subtracks.ui.components.prefetchArtworkRatio
|
import com.subtracks.ui.components.prefetchArtworkRatio
|
||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
import com.subtracks.ui.theme.ArtworkSeedCache
|
import com.subtracks.ui.theme.ArtworkSeedCache
|
||||||
@@ -111,6 +114,10 @@ private const val SWIPE_SETTLE_MS = 180
|
|||||||
private const val SWIPE_EDGE_RESISTANCE = 0.3f
|
private const val SWIPE_EDGE_RESISTANCE = 0.3f
|
||||||
private const val HAND_BACK_TIMEOUT_MS = 750L
|
private const val HAND_BACK_TIMEOUT_MS = 750L
|
||||||
|
|
||||||
|
// The strip is unclipped, so this spacing is what keeps the neighbours off-screen, and it has to
|
||||||
|
// clear the content inset plus a tile's shadow. It doubles as the gap between tiles while dragging.
|
||||||
|
private val TILE_GAP = 40.dp
|
||||||
|
|
||||||
data class NowPlayingArt(
|
data class NowPlayingArt(
|
||||||
val id: String?,
|
val id: String?,
|
||||||
val ref: CoverArtRef?,
|
val ref: CoverArtRef?,
|
||||||
@@ -188,14 +195,18 @@ fun NowPlayingRoute(
|
|||||||
controller: PlaybackController = koinInject(),
|
controller: PlaybackController = koinInject(),
|
||||||
libraryRepository: LibraryRepository = koinInject(),
|
libraryRepository: LibraryRepository = koinInject(),
|
||||||
downloadRepository: DownloadRepository = koinInject(),
|
downloadRepository: DownloadRepository = koinInject(),
|
||||||
|
userPreferences: UserPreferences = koinInject(),
|
||||||
) {
|
) {
|
||||||
val state by controller.state.collectAsStateWithLifecycle()
|
val state by controller.state.collectAsStateWithLifecycle()
|
||||||
|
val keepScreenOn by remember { userPreferences.keepScreenOn() }.collectAsStateWithLifecycle(initialValue = false)
|
||||||
val positionMs by controller.positionMs.collectAsStateWithLifecycle()
|
val positionMs by controller.positionMs.collectAsStateWithLifecycle()
|
||||||
val sourceId = state.context?.sourceId
|
val sourceId = state.context?.sourceId
|
||||||
val songId = state.item?.id
|
val songId = state.item?.id
|
||||||
val downloads by downloadRepository.states().collectAsStateWithLifecycle()
|
val downloads by downloadRepository.states().collectAsStateWithLifecycle()
|
||||||
val download = songId?.let { downloads[it] }
|
val download = songId?.let { downloads[it] }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
var showArtistChooser by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(songId) { showArtistChooser = false }
|
||||||
val song by
|
val song by
|
||||||
produceState<Song?>(null, sourceId, songId) {
|
produceState<Song?>(null, sourceId, songId) {
|
||||||
if (sourceId != null && songId != null) {
|
if (sourceId != null && songId != null) {
|
||||||
@@ -234,7 +245,7 @@ fun NowPlayingRoute(
|
|||||||
name = item.title,
|
name = item.title,
|
||||||
)
|
)
|
||||||
var adjacent by remember { mutableStateOf(AdjacentArt()) }
|
var adjacent by remember { mutableStateOf(AdjacentArt()) }
|
||||||
LaunchedEffect(state.item?.id, state.layout) {
|
LaunchedEffect(state.item?.id, state.layout, state.repeat) {
|
||||||
if (state.item == null) {
|
if (state.item == null) {
|
||||||
adjacent = AdjacentArt()
|
adjacent = AdjacentArt()
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
@@ -265,12 +276,32 @@ fun NowPlayingRoute(
|
|||||||
onRepeat = controller::cycleRepeat,
|
onRepeat = controller::cycleRepeat,
|
||||||
onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } },
|
onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } },
|
||||||
onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } },
|
onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } },
|
||||||
onArtistClick = song?.artistId?.let { id -> { onViewArtist(id) } },
|
onArtistClick =
|
||||||
|
song
|
||||||
|
?.takeIf { it.artistRefs.isNotEmpty() }
|
||||||
|
?.let {
|
||||||
|
{
|
||||||
|
val artists = it.artistRefs
|
||||||
|
if (artists.size == 1) onViewArtist(artists.first().id) else showArtistChooser = true
|
||||||
|
}
|
||||||
|
},
|
||||||
starred = song?.starred != null,
|
starred = song?.starred != null,
|
||||||
onToggleStar = song?.let { s -> { setStar(StarType.Song, s.id, s.starred == null) } },
|
onToggleStar = song?.let { s -> { setStar(StarType.Song, s.id, s.starred == null) } },
|
||||||
onSeek = controller::seekTo,
|
onSeek = controller::seekTo,
|
||||||
|
keepScreenOn = keepScreenOn,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (showArtistChooser) {
|
||||||
|
ArtistChooserSheet(
|
||||||
|
artists = song?.artistRefs.orEmpty(),
|
||||||
|
onArtistClick = { artist ->
|
||||||
|
showArtistChooser = false
|
||||||
|
onViewArtist(artist.id)
|
||||||
|
},
|
||||||
|
onDismiss = { showArtistChooser = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -287,6 +318,7 @@ fun NowPlayingScreen(
|
|||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
keepScreenOn: Boolean = false,
|
||||||
onSkipPrevious: () -> Unit = onPrevious,
|
onSkipPrevious: () -> Unit = onPrevious,
|
||||||
onShuffle: () -> Unit = {},
|
onShuffle: () -> Unit = {},
|
||||||
onRepeat: () -> Unit = {},
|
onRepeat: () -> Unit = {},
|
||||||
@@ -329,6 +361,7 @@ fun NowPlayingScreen(
|
|||||||
val strip = frozenStrip ?: liveStrip
|
val strip = frozenStrip ?: liveStrip
|
||||||
val canGoNext = strip.getOrNull(2) != null
|
val canGoNext = strip.getOrNull(2) != null
|
||||||
val canGoPrevious = strip.getOrNull(0) != null
|
val canGoPrevious = strip.getOrNull(0) != null
|
||||||
|
val stridePx = coverWidthPx + with(LocalDensity.current) { TILE_GAP.toPx() }
|
||||||
|
|
||||||
fun animateSwipe(target: Float) {
|
fun animateSwipe(target: Float) {
|
||||||
settleJob?.cancel()
|
settleJob?.cancel()
|
||||||
@@ -340,7 +373,7 @@ fun NowPlayingScreen(
|
|||||||
|
|
||||||
fun commitSwipe(direction: Int) {
|
fun commitSwipe(direction: Int) {
|
||||||
frozenStrip = rotateStrip(strip, direction)
|
frozenStrip = rotateStrip(strip, direction)
|
||||||
swipeOffsetX += direction * coverWidthPx
|
swipeOffsetX += direction * stridePx
|
||||||
if (direction > 0) onNext() else onSkipPrevious()
|
if (direction > 0) onNext() else onSkipPrevious()
|
||||||
animateSwipe(0f)
|
animateSwipe(0f)
|
||||||
}
|
}
|
||||||
@@ -362,7 +395,12 @@ fun NowPlayingScreen(
|
|||||||
|
|
||||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||||
ArtworkTheme(artwork) {
|
ArtworkTheme(artwork) {
|
||||||
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
Box(
|
||||||
|
modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.then(if (keepScreenOn) Modifier.keepScreenOn() else Modifier),
|
||||||
|
) {
|
||||||
HeroGradient(
|
HeroGradient(
|
||||||
colors = artwork,
|
colors = artwork,
|
||||||
scrollPx = { 0f },
|
scrollPx = { 0f },
|
||||||
@@ -410,13 +448,14 @@ fun NowPlayingScreen(
|
|||||||
.padding(horizontal = 24.dp),
|
.padding(horizontal = 24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
|
// Deliberately unclipped: a tile that fills the width would lose its drop
|
||||||
|
// shadow. TILE_GAP keeps the neighbours off-screen instead.
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 2.dp)
|
.padding(top = 2.dp)
|
||||||
.clipToBounds()
|
|
||||||
.onSizeChanged { coverWidthPx = it.width.toFloat() }
|
.onSizeChanged { coverWidthPx = it.width.toFloat() }
|
||||||
.draggable(
|
.draggable(
|
||||||
orientation = Orientation.Horizontal,
|
orientation = Orientation.Horizontal,
|
||||||
@@ -428,7 +467,7 @@ fun NowPlayingScreen(
|
|||||||
delta < 0 && !canGoNext -> delta * SWIPE_EDGE_RESISTANCE
|
delta < 0 && !canGoNext -> delta * SWIPE_EDGE_RESISTANCE
|
||||||
else -> delta
|
else -> delta
|
||||||
}
|
}
|
||||||
swipeOffsetX = (swipeOffsetX + resisted).coerceIn(-coverWidthPx, coverWidthPx)
|
swipeOffsetX = (swipeOffsetX + resisted).coerceIn(-stridePx, stridePx)
|
||||||
},
|
},
|
||||||
onDragStarted = {
|
onDragStarted = {
|
||||||
dragging = true
|
dragging = true
|
||||||
@@ -484,7 +523,7 @@ fun NowPlayingScreen(
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
translationX = (index - 1) * coverWidthPx + swipeOffsetX
|
translationX = (index - 1) * stridePx + swipeOffsetX
|
||||||
}.then(
|
}.then(
|
||||||
when (index) {
|
when (index) {
|
||||||
0 -> Modifier.testTag(NOW_PLAYING_PREVIOUS_TAG)
|
0 -> Modifier.testTag(NOW_PLAYING_PREVIOUS_TAG)
|
||||||
@@ -538,7 +577,10 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
Box(Modifier.height(subtitleHeight), contentAlignment = Alignment.CenterStart) {
|
Box(Modifier.height(subtitleHeight), contentAlignment = Alignment.CenterStart) {
|
||||||
Text(
|
Text(
|
||||||
text = state.item?.artist.orEmpty(),
|
text =
|
||||||
|
state.item
|
||||||
|
?.let { artistSubtitle(it.artist, it.artists) }
|
||||||
|
.orEmpty(),
|
||||||
style = subtitleStyle,
|
style = subtitleStyle,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ class QueueViewModel(
|
|||||||
var generation by mutableIntStateOf(0)
|
var generation by mutableIntStateOf(0)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
private var reorderPending = false
|
||||||
|
|
||||||
private var nextId = 0L
|
private var nextId = 0L
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
@@ -249,12 +251,19 @@ class QueueViewModel(
|
|||||||
fun move(
|
fun move(
|
||||||
from: Long,
|
from: Long,
|
||||||
to: Long,
|
to: Long,
|
||||||
) {
|
): Boolean {
|
||||||
if (from == to) return
|
if (from == to) return false
|
||||||
|
if (reorderPending) return false
|
||||||
|
reorderPending = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playbackController.move(from, to)
|
try {
|
||||||
mutex.withLock { reconcileLocked() }
|
playbackController.move(from, to)
|
||||||
|
mutex.withLock { reconcileLocked() }
|
||||||
|
} finally {
|
||||||
|
reorderPending = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(position: Long) {
|
fun remove(position: Long) {
|
||||||
@@ -331,7 +340,7 @@ fun QueueScreen(
|
|||||||
onPlay: (Long) -> Unit,
|
onPlay: (Long) -> Unit,
|
||||||
onRemove: (Long) -> Unit,
|
onRemove: (Long) -> Unit,
|
||||||
onReorder: (Int, Int) -> Unit,
|
onReorder: (Int, Int) -> Unit,
|
||||||
onMove: (Long, Long) -> Unit,
|
onMove: (Long, Long) -> Boolean,
|
||||||
onLoadOlder: () -> Unit,
|
onLoadOlder: () -> Unit,
|
||||||
onLoadNewer: () -> Unit,
|
onLoadNewer: () -> Unit,
|
||||||
onUndo: () -> Unit,
|
onUndo: () -> Unit,
|
||||||
@@ -347,7 +356,6 @@ fun QueueScreen(
|
|||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var dragId by remember { mutableStateOf<Long?>(null) }
|
var dragId by remember { mutableStateOf<Long?>(null) }
|
||||||
var dragFrom by remember { mutableStateOf<Long?>(null) }
|
|
||||||
val undoLabel = stringResource(R.string.undo)
|
val undoLabel = stringResource(R.string.undo)
|
||||||
val queueReordered = stringResource(R.string.queue_reordered)
|
val queueReordered = stringResource(R.string.queue_reordered)
|
||||||
val queueRemoved = stringResource(R.string.queue_removed)
|
val queueRemoved = stringResource(R.string.queue_removed)
|
||||||
@@ -470,28 +478,14 @@ fun QueueScreen(
|
|||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
Modifier.draggableHandle(
|
Modifier.draggableHandle(
|
||||||
onDragStarted = {
|
onDragStarted = { dragId = row.id },
|
||||||
dragId = row.id
|
|
||||||
dragFrom = row.position
|
|
||||||
},
|
|
||||||
onDragStopped = {
|
onDragStopped = {
|
||||||
val id = dragId
|
dragId?.let { id ->
|
||||||
val from = dragFrom
|
rows.dropMoveFor(id)?.let { move ->
|
||||||
if (id != null && from != null) {
|
if (onMove(move.from, move.to)) showUndo(queueReordered)
|
||||||
val draggedIndex = rows.indexOfFirst { it.id == id }
|
|
||||||
val to =
|
|
||||||
if (draggedIndex < 0) {
|
|
||||||
from
|
|
||||||
} else {
|
|
||||||
dropTarget(rows, draggedIndex, from)
|
|
||||||
}
|
|
||||||
if (to != from) {
|
|
||||||
onMove(from, to)
|
|
||||||
showUndo(queueReordered)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dragId = null
|
dragId = null
|
||||||
dragFrom = null
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -563,6 +557,19 @@ internal fun dropTarget(
|
|||||||
return (before - if (before > from) 1 else 0) + 1
|
return (before - if (before > from) 1 else 0) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal data class QueueMove(
|
||||||
|
val from: Long,
|
||||||
|
val to: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun List<QueueRow>.dropMoveFor(draggedId: Long): QueueMove? {
|
||||||
|
val index = indexOfFirst { it.id == draggedId }
|
||||||
|
if (index < 0) return null
|
||||||
|
val from = this[index].position
|
||||||
|
val to = dropTarget(this, index, from)
|
||||||
|
return if (to == from) null else QueueMove(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("ModifierParameter")
|
@Suppress("ModifierParameter")
|
||||||
@Composable
|
@Composable
|
||||||
private fun QueueRowItem(
|
private fun QueueRowItem(
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -45,6 +46,7 @@ import com.subtracks.data.model.Playlist
|
|||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
@@ -85,6 +87,7 @@ fun SearchRoute(
|
|||||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||||
val results by viewModel.results.collectAsStateWithLifecycle()
|
val results by viewModel.results.collectAsStateWithLifecycle()
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
var filterMask by rememberSaveable { mutableIntStateOf(allFilterMask()) }
|
var filterMask by rememberSaveable { mutableIntStateOf(allFilterMask()) }
|
||||||
val actions =
|
val actions =
|
||||||
ItemActions(
|
ItemActions(
|
||||||
@@ -104,13 +107,29 @@ fun SearchRoute(
|
|||||||
if (next != 0) filterMask = next
|
if (next != 0) filterMask = next
|
||||||
},
|
},
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
playingSongId = playback.item?.id,
|
playingSongId = playback.item?.id,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = {
|
||||||
onArtistClick = onArtistClick,
|
focusManager.clearFocus()
|
||||||
onPlaylistClick = onPlaylistClick,
|
onAlbumClick(it)
|
||||||
onSongClick = viewModel::play,
|
},
|
||||||
onLongClick = { contextMenuHost?.show(it, actions) },
|
onArtistClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
onArtistClick(it)
|
||||||
|
},
|
||||||
|
onPlaylistClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
onPlaylistClick(it)
|
||||||
|
},
|
||||||
|
onSongClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
viewModel.play(it)
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
contextMenuHost?.show(it, actions)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +148,7 @@ fun SearchScreen(
|
|||||||
filters: Set<SearchFilter> = ALL_FILTERS,
|
filters: Set<SearchFilter> = ALL_FILTERS,
|
||||||
onToggleFilter: (SearchFilter) -> Unit = {},
|
onToggleFilter: (SearchFilter) -> Unit = {},
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
playingSongId: String? = null,
|
playingSongId: String? = null,
|
||||||
onBack: () -> Unit = {},
|
onBack: () -> Unit = {},
|
||||||
onAlbumClick: (Album) -> Unit = {},
|
onAlbumClick: (Album) -> Unit = {},
|
||||||
@@ -198,7 +218,7 @@ fun SearchScreen(
|
|||||||
contentPadding = PaddingValues(bottom = 24.dp),
|
contentPadding = PaddingValues(bottom = 24.dp),
|
||||||
) {
|
) {
|
||||||
if (showPlaylists) playlistResults(results.playlists, playlistsLabel, coverArt, onPlaylistClick, onLongClick)
|
if (showPlaylists) playlistResults(results.playlists, playlistsLabel, coverArt, onPlaylistClick, onLongClick)
|
||||||
if (showArtists) artistResults(results.artists, artistsLabel, coverArt, onArtistClick, onLongClick)
|
if (showArtists) artistResults(results.artists, artistsLabel, artistArt, onArtistClick, onLongClick)
|
||||||
if (showAlbums) albumResults(results.albums, albumsLabel, coverArt, onAlbumClick, onLongClick)
|
if (showAlbums) albumResults(results.albums, albumsLabel, coverArt, onAlbumClick, onLongClick)
|
||||||
if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick)
|
if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick)
|
||||||
}
|
}
|
||||||
@@ -291,19 +311,20 @@ private fun LazyListScope.albumResults(
|
|||||||
private fun LazyListScope.artistResults(
|
private fun LazyListScope.artistResults(
|
||||||
artists: List<Artist>,
|
artists: List<Artist>,
|
||||||
header: String,
|
header: String,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
artistArt: (Artist) -> ArtistArt,
|
||||||
onClick: (Artist) -> Unit,
|
onClick: (Artist) -> Unit,
|
||||||
onLongClick: (MenuTarget) -> Unit,
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (artists.isEmpty()) return
|
if (artists.isEmpty()) return
|
||||||
resultHeader(header)
|
resultHeader(header)
|
||||||
items(artists, key = { "artist-${it.id}" }) { artist ->
|
items(artists, key = { "artist-${it.id}" }) { artist ->
|
||||||
|
val art = artistArt(artist)
|
||||||
ArtistListRow(
|
ArtistListRow(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
coverArt = coverArt,
|
art = art,
|
||||||
info = null,
|
info = null,
|
||||||
onClick = { onClick(artist) },
|
onClick = { onClick(artist) },
|
||||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
|
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import com.subtracks.data.model.Song
|
|||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.artistArt
|
||||||
import com.subtracks.ui.home.playSongInContext
|
import com.subtracks.ui.home.playSongInContext
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -74,6 +76,11 @@ class SearchViewModel(
|
|||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
|
fun artistArt(
|
||||||
|
artist: Artist,
|
||||||
|
forIcon: Boolean = true,
|
||||||
|
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||||
|
|
||||||
fun play(song: Song) {
|
fun play(song: Song) {
|
||||||
viewModelScope.playSongInContext(libraryRepository, playbackController, song)
|
viewModelScope.playSongInContext(libraryRepository, playbackController, song)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.subtracks.ui.settings
|
||||||
|
|
||||||
|
import android.content.res.Resources
|
||||||
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
|
||||||
|
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
|
||||||
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
internal const val REPLAY_GAIN_PREAMP_STEP_DB = 0.5f
|
||||||
|
|
||||||
|
internal val REPLAY_GAIN_PREAMP_STEPS =
|
||||||
|
((MAX_REPLAY_GAIN_PREAMP_DB - MIN_REPLAY_GAIN_PREAMP_DB) / REPLAY_GAIN_PREAMP_STEP_DB).toInt() - 1
|
||||||
|
|
||||||
|
// Compose Material 3's Slider snaps in pixel space and converts back, so the value it reports is
|
||||||
|
// near, but not on, the grid it was configured with (for example -0.99999905 for -1.0). Snap before
|
||||||
|
// showing or storing it.
|
||||||
|
internal fun snapReplayGainPreampDb(value: Float): Float =
|
||||||
|
((value / REPLAY_GAIN_PREAMP_STEP_DB).roundToInt() * REPLAY_GAIN_PREAMP_STEP_DB)
|
||||||
|
.coerceIn(MIN_REPLAY_GAIN_PREAMP_DB, MAX_REPLAY_GAIN_PREAMP_DB)
|
||||||
|
|
||||||
|
internal fun stepReplayGainPreampDb(
|
||||||
|
value: Float,
|
||||||
|
steps: Int,
|
||||||
|
): Float = snapReplayGainPreampDb(value + steps * REPLAY_GAIN_PREAMP_STEP_DB)
|
||||||
|
|
||||||
|
internal fun replayGainModeLabel(
|
||||||
|
resources: Resources,
|
||||||
|
mode: ReplayGainMode,
|
||||||
|
): String =
|
||||||
|
when (mode) {
|
||||||
|
ReplayGainMode.Off -> resources.getString(R.string.settings_replay_gain_mode_off)
|
||||||
|
ReplayGainMode.Track -> resources.getString(R.string.settings_replay_gain_mode_track)
|
||||||
|
ReplayGainMode.Album -> resources.getString(R.string.settings_replay_gain_mode_album)
|
||||||
|
ReplayGainMode.Auto -> resources.getString(R.string.settings_replay_gain_mode_auto)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun replayGainPreampLabel(
|
||||||
|
resources: Resources,
|
||||||
|
decibels: Float,
|
||||||
|
): String {
|
||||||
|
val sign = if (decibels > 0f) "+" else ""
|
||||||
|
val magnitude = String.format(resources.configuration.locales[0], "%.1f", decibels)
|
||||||
|
return resources.getString(R.string.settings_replay_gain_preamp_value, "$sign$magnitude")
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.subtracks.ui.settings
|
package com.subtracks.ui.settings
|
||||||
|
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -27,6 +28,8 @@ import androidx.compose.material.icons.rounded.Edit
|
|||||||
import androidx.compose.material.icons.rounded.Favorite
|
import androidx.compose.material.icons.rounded.Favorite
|
||||||
import androidx.compose.material.icons.rounded.Info
|
import androidx.compose.material.icons.rounded.Info
|
||||||
import androidx.compose.material.icons.rounded.Language
|
import androidx.compose.material.icons.rounded.Language
|
||||||
|
import androidx.compose.material.icons.rounded.Remove
|
||||||
|
import androidx.compose.material.icons.rounded.Share
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -37,6 +40,8 @@ import androidx.compose.material3.ListItemDefaults
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.RadioButton
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.SliderDefaults
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.SwitchDefaults
|
import androidx.compose.material3.SwitchDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -44,28 +49,42 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalResources
|
import androidx.compose.ui.platform.LocalResources
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.subtracks.BuildConfig
|
import com.subtracks.BuildConfig
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
|
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||||
|
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
|
||||||
|
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
|
||||||
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
|
import com.subtracks.data.prefs.SortNameMode
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
|
import com.subtracks.data.prefs.SyncMode
|
||||||
|
import com.subtracks.log.Log
|
||||||
import com.subtracks.ui.components.DismissOnRequest
|
import com.subtracks.ui.components.DismissOnRequest
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
|
private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
|
||||||
@@ -74,7 +93,17 @@ private val streamFormats = listOf(null, "mp3", "opus", "ogg", "webm", "aac", "f
|
|||||||
private const val PROJECT_HOMEPAGE = "https://github.com/austinried/subtracks"
|
private const val PROJECT_HOMEPAGE = "https://github.com/austinried/subtracks"
|
||||||
private const val SUPPORT_URL = "https://ko-fi.com/austinried"
|
private const val SUPPORT_URL = "https://ko-fi.com/austinried"
|
||||||
|
|
||||||
private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality, SyncConcurrency }
|
private enum class SettingsDialog {
|
||||||
|
WifiQuality,
|
||||||
|
MobileQuality,
|
||||||
|
DownloadQuality,
|
||||||
|
SyncConcurrency,
|
||||||
|
SyncInterval,
|
||||||
|
SyncMode,
|
||||||
|
ReplayGain,
|
||||||
|
ReplayGainPreamp,
|
||||||
|
SortNames,
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsRoute(
|
fun SettingsRoute(
|
||||||
@@ -91,32 +120,72 @@ fun SettingsRoute(
|
|||||||
val wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle()
|
val wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle()
|
||||||
val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle()
|
val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle()
|
||||||
val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle()
|
val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle()
|
||||||
|
val syncMode by viewModel.syncMode.collectAsStateWithLifecycle()
|
||||||
|
val syncIntervalMinutes by viewModel.syncIntervalMinutes.collectAsStateWithLifecycle()
|
||||||
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
|
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
|
||||||
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
|
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
|
||||||
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
||||||
|
val keepScreenOn by viewModel.keepScreenOn.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
|
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
|
||||||
|
val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle()
|
||||||
|
val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle()
|
||||||
|
val sortNameMode by viewModel.sortNameMode.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
sources = sources,
|
sources = sources,
|
||||||
activeSourceId = activeSourceId,
|
activeSourceId = activeSourceId,
|
||||||
wifiQuality = wifiQuality,
|
wifiQuality = wifiQuality,
|
||||||
mobileQuality = mobileQuality,
|
mobileQuality = mobileQuality,
|
||||||
syncConcurrency = syncConcurrency,
|
syncConcurrency = syncConcurrency,
|
||||||
|
syncMode = syncMode,
|
||||||
|
syncInterval = syncIntervalMinutes,
|
||||||
downloadQuality = downloadQuality,
|
downloadQuality = downloadQuality,
|
||||||
downloadOverMetered = downloadOverMetered,
|
downloadOverMetered = downloadOverMetered,
|
||||||
scrobbling = scrobbling,
|
scrobbling = scrobbling,
|
||||||
|
replayGainMode = replayGainMode,
|
||||||
|
replayGainPreamp = replayGainPreamp,
|
||||||
|
sortNameMode = sortNameMode,
|
||||||
|
keepScreenOn = keepScreenOn,
|
||||||
offline = offline,
|
offline = offline,
|
||||||
|
verboseLogging = verboseLogging,
|
||||||
onSelectSource = viewModel::selectSource,
|
onSelectSource = viewModel::selectSource,
|
||||||
onEditServer = onEditServer,
|
onEditServer = onEditServer,
|
||||||
onWifiQualityChange = viewModel::setWifiQuality,
|
onWifiQualityChange = viewModel::setWifiQuality,
|
||||||
onMobileQualityChange = viewModel::setMobileQuality,
|
onMobileQualityChange = viewModel::setMobileQuality,
|
||||||
onSyncConcurrencyChange = viewModel::setSyncConcurrency,
|
onSyncConcurrencyChange = viewModel::setSyncConcurrency,
|
||||||
|
onSyncModeChange = viewModel::setSyncMode,
|
||||||
|
onSyncIntervalChange = viewModel::setSyncIntervalMinutes,
|
||||||
onDownloadQualityChange = viewModel::setDownloadQuality,
|
onDownloadQualityChange = viewModel::setDownloadQuality,
|
||||||
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
|
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
|
||||||
onScrobblingChange = viewModel::setScrobbling,
|
onScrobblingChange = viewModel::setScrobbling,
|
||||||
|
onReplayGainModeChange = viewModel::setReplayGainMode,
|
||||||
|
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
|
||||||
|
onSortNameModeChange = viewModel::setSortNameMode,
|
||||||
|
onKeepScreenOnChange = viewModel::setKeepScreenOn,
|
||||||
onOfflineChange = viewModel::setOfflineMode,
|
onOfflineChange = viewModel::setOfflineMode,
|
||||||
|
onVerboseLoggingChange = viewModel::setVerboseLogging,
|
||||||
onAddServer = onAddServer,
|
onAddServer = onAddServer,
|
||||||
onOpenDownloads = onOpenDownloads,
|
onOpenDownloads = onOpenDownloads,
|
||||||
onOpenLicenses = onOpenLicenses,
|
onOpenLicenses = onOpenLicenses,
|
||||||
|
onShareLogs = {
|
||||||
|
scope.launch {
|
||||||
|
val result =
|
||||||
|
runCatching {
|
||||||
|
val zip = withContext(Dispatchers.IO) { Log.exportZip() }
|
||||||
|
if (zip != null) Log.share(context, zip)
|
||||||
|
zip
|
||||||
|
}
|
||||||
|
val message =
|
||||||
|
when {
|
||||||
|
result.isFailure -> R.string.settings_about_share_logs_failed
|
||||||
|
result.getOrNull() == null -> R.string.settings_about_share_logs_empty
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
message?.let { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() }
|
||||||
|
}
|
||||||
|
},
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
dismissals = dismissals,
|
dismissals = dismissals,
|
||||||
)
|
)
|
||||||
@@ -134,19 +203,34 @@ fun SettingsScreen(
|
|||||||
downloadOverMetered: Boolean,
|
downloadOverMetered: Boolean,
|
||||||
scrobbling: Boolean,
|
scrobbling: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
keepScreenOn: Boolean = false,
|
||||||
offline: Boolean = false,
|
offline: Boolean = false,
|
||||||
|
verboseLogging: Boolean = false,
|
||||||
|
syncMode: SyncMode = SyncMode.WifiOnly,
|
||||||
|
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
||||||
|
replayGainMode: ReplayGainMode = ReplayGainMode.Auto,
|
||||||
|
replayGainPreamp: Float = 0f,
|
||||||
|
sortNameMode: SortNameMode = SortNameMode.Name,
|
||||||
onSelectSource: (Long) -> Unit,
|
onSelectSource: (Long) -> Unit,
|
||||||
onEditServer: (Long) -> Unit,
|
onEditServer: (Long) -> Unit,
|
||||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||||
onMobileQualityChange: (StreamQuality) -> Unit,
|
onMobileQualityChange: (StreamQuality) -> Unit,
|
||||||
onSyncConcurrencyChange: (Int) -> Unit,
|
onSyncConcurrencyChange: (Int) -> Unit,
|
||||||
|
onSyncModeChange: (SyncMode) -> Unit = {},
|
||||||
|
onSyncIntervalChange: (Int) -> Unit = {},
|
||||||
onDownloadQualityChange: (StreamQuality) -> Unit,
|
onDownloadQualityChange: (StreamQuality) -> Unit,
|
||||||
onDownloadOverMeteredChange: (Boolean) -> Unit,
|
onDownloadOverMeteredChange: (Boolean) -> Unit,
|
||||||
onScrobblingChange: (Boolean) -> Unit,
|
onScrobblingChange: (Boolean) -> Unit,
|
||||||
|
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
|
||||||
|
onReplayGainPreampChange: (Float) -> Unit = {},
|
||||||
|
onSortNameModeChange: (SortNameMode) -> Unit = {},
|
||||||
|
onKeepScreenOnChange: (Boolean) -> Unit = {},
|
||||||
onOfflineChange: (Boolean) -> Unit = {},
|
onOfflineChange: (Boolean) -> Unit = {},
|
||||||
|
onVerboseLoggingChange: (Boolean) -> Unit = {},
|
||||||
onAddServer: () -> Unit,
|
onAddServer: () -> Unit,
|
||||||
onOpenDownloads: () -> Unit,
|
onOpenDownloads: () -> Unit,
|
||||||
onOpenLicenses: () -> Unit,
|
onOpenLicenses: () -> Unit,
|
||||||
|
onShareLogs: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
dismissals: Flow<Unit> = emptyFlow(),
|
dismissals: Flow<Unit> = emptyFlow(),
|
||||||
) {
|
) {
|
||||||
@@ -247,6 +331,22 @@ fun SettingsScreen(
|
|||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(stringResource(R.string.settings_sync_auto_title)) },
|
||||||
|
supportingContent = { Text(syncModeLabel(LocalResources.current, syncMode)) },
|
||||||
|
modifier = Modifier.clickable { dialog = SettingsDialog.SyncMode },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(stringResource(R.string.sync_interval)) },
|
||||||
|
supportingContent = { Text(syncIntervalLabel(LocalResources.current, syncInterval)) },
|
||||||
|
modifier = Modifier.clickable { dialog = SettingsDialog.SyncInterval },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(stringResource(R.string.sync_concurrency)) },
|
headlineContent = { Text(stringResource(R.string.sync_concurrency)) },
|
||||||
@@ -255,6 +355,15 @@ fun SettingsScreen(
|
|||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item { SectionHeader(stringResource(R.string.settings_library_name)) }
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(stringResource(R.string.settings_sort_names_title)) },
|
||||||
|
supportingContent = { Text(sortNameModeLabel(LocalResources.current, sortNameMode)) },
|
||||||
|
modifier = Modifier.clickable { dialog = SettingsDialog.SortNames },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
item { SectionHeader(stringResource(R.string.settings_music_name)) }
|
item { SectionHeader(stringResource(R.string.settings_music_name)) }
|
||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
@@ -276,6 +385,42 @@ fun SettingsScreen(
|
|||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(stringResource(R.string.settings_replay_gain_title)) },
|
||||||
|
supportingContent = { Text(replayGainModeLabel(LocalResources.current, replayGainMode)) },
|
||||||
|
modifier = Modifier.clickable { dialog = SettingsDialog.ReplayGain },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(stringResource(R.string.settings_replay_gain_preamp_title)) },
|
||||||
|
supportingContent = { Text(replayGainPreampLabel(LocalResources.current, replayGainPreamp)) },
|
||||||
|
modifier = Modifier.clickable { dialog = SettingsDialog.ReplayGainPreamp },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(stringResource(R.string.settings_music_options_keep_screen_on_title)) },
|
||||||
|
supportingContent = { Text(stringResource(R.string.settings_music_options_keep_screen_on_description)) },
|
||||||
|
trailingContent = {
|
||||||
|
Switch(
|
||||||
|
checked = keepScreenOn,
|
||||||
|
onCheckedChange = onKeepScreenOnChange,
|
||||||
|
colors =
|
||||||
|
SwitchDefaults.colors(
|
||||||
|
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
uncheckedBorderColor = MaterialTheme.colorScheme.outline,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable { onKeepScreenOnChange(!keepScreenOn) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
item { SectionHeader(stringResource(R.string.downloads_title)) }
|
item { SectionHeader(stringResource(R.string.downloads_title)) }
|
||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
@@ -321,12 +466,50 @@ fun SettingsScreen(
|
|||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item { SectionHeader(stringResource(R.string.settings_troubleshooting_name)) }
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(stringResource(R.string.settings_about_actions_share_logs)) },
|
||||||
|
leadingContent = { Icon(Icons.Rounded.Share, contentDescription = null) },
|
||||||
|
modifier = Modifier.clickable(onClick = onShareLogs),
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(stringResource(R.string.settings_about_verbose_logging)) },
|
||||||
|
supportingContent = { Text(stringResource(R.string.settings_about_verbose_logging_description)) },
|
||||||
|
trailingContent = {
|
||||||
|
Switch(
|
||||||
|
checked = verboseLogging,
|
||||||
|
onCheckedChange = onVerboseLoggingChange,
|
||||||
|
colors =
|
||||||
|
SwitchDefaults.colors(
|
||||||
|
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
uncheckedBorderColor = MaterialTheme.colorScheme.outline,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable { onVerboseLoggingChange(!verboseLogging) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
item { SectionHeader(stringResource(R.string.settings_about_name)) }
|
item { SectionHeader(stringResource(R.string.settings_about_name)) }
|
||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(stringResource(R.string.app_name)) },
|
headlineContent = { Text(stringResource(R.string.app_name)) },
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Text(stringResource(R.string.settings_about_version, BuildConfig.VERSION_NAME))
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(stringResource(R.string.settings_about_version, BuildConfig.VERSION_NAME))
|
||||||
|
if (BuildConfig.COMMIT_HASH.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
BuildConfig.COMMIT_HASH,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
leadingContent = { Icon(Icons.Rounded.Info, contentDescription = null) },
|
leadingContent = { Icon(Icons.Rounded.Info, contentDescription = null) },
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
@@ -404,6 +587,46 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsDialog.SyncInterval -> {
|
||||||
|
SyncIntervalDialog(
|
||||||
|
selected = syncInterval,
|
||||||
|
onSelect = onSyncIntervalChange,
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsDialog.SyncMode -> {
|
||||||
|
SyncModeDialog(
|
||||||
|
selected = syncMode,
|
||||||
|
onSelect = onSyncModeChange,
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsDialog.ReplayGain -> {
|
||||||
|
ReplayGainModeDialog(
|
||||||
|
selected = replayGainMode,
|
||||||
|
onSelect = onReplayGainModeChange,
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsDialog.ReplayGainPreamp -> {
|
||||||
|
ReplayGainPreampDialog(
|
||||||
|
selected = replayGainPreamp,
|
||||||
|
onSelect = onReplayGainPreampChange,
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsDialog.SortNames -> {
|
||||||
|
SortNameModeDialog(
|
||||||
|
selected = sortNameMode,
|
||||||
|
onSelect = onSortNameModeChange,
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
null -> {
|
null -> {
|
||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
@@ -429,11 +652,56 @@ private fun bitrateLabel(
|
|||||||
|
|
||||||
private val syncConcurrencyOptions = listOf(1, 2, 4, 8, 16)
|
private val syncConcurrencyOptions = listOf(1, 2, 4, 8, 16)
|
||||||
|
|
||||||
|
private val syncIntervalOptions = listOf(15, 60, 360, 720, DEFAULT_SYNC_INTERVAL_MINUTES)
|
||||||
|
|
||||||
private fun concurrencyLabel(
|
private fun concurrencyLabel(
|
||||||
resources: Resources,
|
resources: Resources,
|
||||||
value: Int,
|
value: Int,
|
||||||
): String = if (value <= 1) resources.getString(R.string.sync_concurrency_sequential) else value.toString()
|
): String = if (value <= 1) resources.getString(R.string.sync_concurrency_sequential) else value.toString()
|
||||||
|
|
||||||
|
private fun syncIntervalLabel(
|
||||||
|
resources: Resources,
|
||||||
|
minutes: Int,
|
||||||
|
): String =
|
||||||
|
if (minutes >= 60 && minutes % 60 == 0) {
|
||||||
|
resources.getQuantityString(R.plurals.sync_interval_hours, minutes / 60, minutes / 60)
|
||||||
|
} else {
|
||||||
|
resources.getQuantityString(R.plurals.sync_interval_minutes, minutes, minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SyncIntervalDialog(
|
||||||
|
selected: Int,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val resources = LocalResources.current
|
||||||
|
var draft by remember(selected) { mutableIntStateOf(selected) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.sync_interval)) },
|
||||||
|
text = {
|
||||||
|
ChoiceGroup(
|
||||||
|
header = stringResource(R.string.sync_interval_prompt),
|
||||||
|
options = syncIntervalOptions.map { it to syncIntervalLabel(resources, it) },
|
||||||
|
selected = draft,
|
||||||
|
onSelect = { draft = it },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onSelect(draft)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.action_done)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConcurrencyDialog(
|
private fun ConcurrencyDialog(
|
||||||
selected: Int,
|
selected: Int,
|
||||||
@@ -467,6 +735,209 @@ private fun ConcurrencyDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val syncModeOptions = SyncMode.entries
|
||||||
|
|
||||||
|
private fun syncModeLabel(
|
||||||
|
resources: Resources,
|
||||||
|
mode: SyncMode,
|
||||||
|
): String =
|
||||||
|
when (mode) {
|
||||||
|
SyncMode.Off -> resources.getString(R.string.sync_mode_off)
|
||||||
|
SyncMode.WifiOnly -> resources.getString(R.string.sync_mode_wifi)
|
||||||
|
SyncMode.Always -> resources.getString(R.string.sync_mode_always)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SyncModeDialog(
|
||||||
|
selected: SyncMode,
|
||||||
|
onSelect: (SyncMode) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val resources = LocalResources.current
|
||||||
|
var draft by remember(selected) { mutableStateOf(selected) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.settings_sync_auto_title)) },
|
||||||
|
text = {
|
||||||
|
ChoiceGroup(
|
||||||
|
header = stringResource(R.string.sync_mode_prompt),
|
||||||
|
options = syncModeOptions.map { it to syncModeLabel(resources, it) },
|
||||||
|
selected = draft,
|
||||||
|
onSelect = { draft = it },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onSelect(draft)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.action_done)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sortNameModeOptions =
|
||||||
|
listOf(SortNameMode.Name, SortNameMode.SortName, SortNameMode.NonLatin)
|
||||||
|
|
||||||
|
private fun sortNameModeLabel(
|
||||||
|
resources: Resources,
|
||||||
|
mode: SortNameMode,
|
||||||
|
): String =
|
||||||
|
when (mode) {
|
||||||
|
SortNameMode.SortName -> resources.getString(R.string.sort_name_mode_sort_name)
|
||||||
|
SortNameMode.Name -> resources.getString(R.string.sort_name_mode_name)
|
||||||
|
SortNameMode.NonLatin -> resources.getString(R.string.sort_name_mode_non_latin)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SortNameModeDialog(
|
||||||
|
selected: SortNameMode,
|
||||||
|
onSelect: (SortNameMode) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val resources = LocalResources.current
|
||||||
|
var draft by remember(selected) { mutableStateOf(selected) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.settings_sort_names_title)) },
|
||||||
|
text = {
|
||||||
|
ChoiceGroup(
|
||||||
|
header = stringResource(R.string.settings_sort_names_prompt),
|
||||||
|
options = sortNameModeOptions.map { it to sortNameModeLabel(resources, it) },
|
||||||
|
selected = draft,
|
||||||
|
onSelect = { draft = it },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onSelect(draft)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.action_done)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReplayGainModeDialog(
|
||||||
|
selected: ReplayGainMode,
|
||||||
|
onSelect: (ReplayGainMode) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val resources = LocalResources.current
|
||||||
|
var draft by remember(selected) { mutableStateOf(selected) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.settings_replay_gain_title)) },
|
||||||
|
text = {
|
||||||
|
ChoiceGroup(
|
||||||
|
header = stringResource(R.string.settings_replay_gain_prompt),
|
||||||
|
options = ReplayGainMode.entries.map { it to replayGainModeLabel(resources, it) },
|
||||||
|
selected = draft,
|
||||||
|
onSelect = { draft = it },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onSelect(draft)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.action_done)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun ReplayGainPreampDialog(
|
||||||
|
selected: Float,
|
||||||
|
onSelect: (Float) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val resources = LocalResources.current
|
||||||
|
var draft by remember(selected) { mutableFloatStateOf(snapReplayGainPreampDb(selected)) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.settings_replay_gain_preamp_title)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.settings_replay_gain_preamp_prompt),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { draft = stepReplayGainPreampDb(draft, -1) },
|
||||||
|
enabled = draft > MIN_REPLAY_GAIN_PREAMP_DB,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.Remove,
|
||||||
|
contentDescription = stringResource(R.string.settings_replay_gain_preamp_decrease),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Slider(
|
||||||
|
value = draft,
|
||||||
|
onValueChange = { draft = snapReplayGainPreampDb(it) },
|
||||||
|
valueRange = MIN_REPLAY_GAIN_PREAMP_DB..MAX_REPLAY_GAIN_PREAMP_DB,
|
||||||
|
steps = REPLAY_GAIN_PREAMP_STEPS,
|
||||||
|
// Compose Material 3 draws one track tick per step, and at 59 steps they
|
||||||
|
// smear together, so the ticks are suppressed and the snapping kept.
|
||||||
|
track = { state -> SliderDefaults.Track(sliderState = state, drawTick = { _, _ -> }) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = { draft = stepReplayGainPreampDb(draft, 1) },
|
||||||
|
enabled = draft < MAX_REPLAY_GAIN_PREAMP_DB,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.Add,
|
||||||
|
contentDescription = stringResource(R.string.settings_replay_gain_preamp_increase),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
replayGainPreampLabel(resources, draft),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = { draft = 0f },
|
||||||
|
enabled = draft != 0f,
|
||||||
|
) { Text(stringResource(R.string.settings_replay_gain_preamp_reset)) }
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onSelect(draft)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.action_done)) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun QualityDialog(
|
private fun QualityDialog(
|
||||||
title: String,
|
title: String,
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
|
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||||
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
|
import com.subtracks.data.prefs.SortNameMode
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
|
import com.subtracks.data.prefs.SyncMode
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
|
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
|
||||||
@@ -48,17 +52,56 @@ class SettingsViewModel(
|
|||||||
.syncConcurrency()
|
.syncConcurrency()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_FETCH_CONCURRENCY)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_FETCH_CONCURRENCY)
|
||||||
|
|
||||||
|
val syncMode: StateFlow<SyncMode> =
|
||||||
|
userPreferences
|
||||||
|
.syncMode()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SyncMode.WifiOnly)
|
||||||
|
|
||||||
|
val syncIntervalMinutes: StateFlow<Int> =
|
||||||
|
userPreferences
|
||||||
|
.syncIntervalMinutes()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_SYNC_INTERVAL_MINUTES)
|
||||||
|
|
||||||
val scrobbling: StateFlow<Boolean> =
|
val scrobbling: StateFlow<Boolean> =
|
||||||
userPreferences
|
userPreferences
|
||||||
.scrobbling()
|
.scrobbling()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
|
||||||
|
|
||||||
|
val replayGainMode: StateFlow<ReplayGainMode> =
|
||||||
|
userPreferences
|
||||||
|
.replayGainMode()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ReplayGainMode.Auto)
|
||||||
|
|
||||||
|
val replayGainPreamp: StateFlow<Float> =
|
||||||
|
userPreferences
|
||||||
|
.replayGainPreampDb()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f)
|
||||||
|
|
||||||
|
val sortNameMode: StateFlow<SortNameMode> =
|
||||||
|
userPreferences
|
||||||
|
.sortNameMode()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SortNameMode.Name)
|
||||||
|
|
||||||
|
val keepScreenOn: StateFlow<Boolean> =
|
||||||
|
userPreferences
|
||||||
|
.keepScreenOn()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||||
|
|
||||||
|
val verboseLogging: StateFlow<Boolean> =
|
||||||
|
userPreferences
|
||||||
|
.verboseLogging()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
fun setOfflineMode(enabled: Boolean) {
|
fun setOfflineMode(enabled: Boolean) {
|
||||||
sourceRepository.setOfflineMode(enabled)
|
sourceRepository.setOfflineMode(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setVerboseLogging(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { userPreferences.setVerboseLogging(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
fun selectSource(id: Long) {
|
fun selectSource(id: Long) {
|
||||||
viewModelScope.launch { sourceRepository.selectSource(id) }
|
viewModelScope.launch { sourceRepository.selectSource(id) }
|
||||||
}
|
}
|
||||||
@@ -83,7 +126,31 @@ class SettingsViewModel(
|
|||||||
viewModelScope.launch { userPreferences.setSyncConcurrency(value) }
|
viewModelScope.launch { userPreferences.setSyncConcurrency(value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setSyncMode(mode: SyncMode) {
|
||||||
|
viewModelScope.launch { userPreferences.setSyncMode(mode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSyncIntervalMinutes(value: Int) {
|
||||||
|
viewModelScope.launch { userPreferences.setSyncIntervalMinutes(value) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setScrobbling(enabled: Boolean) {
|
fun setScrobbling(enabled: Boolean) {
|
||||||
viewModelScope.launch { userPreferences.setScrobbling(enabled) }
|
viewModelScope.launch { userPreferences.setScrobbling(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setReplayGainMode(mode: ReplayGainMode) {
|
||||||
|
viewModelScope.launch { userPreferences.setReplayGainMode(mode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setReplayGainPreamp(value: Float) {
|
||||||
|
viewModelScope.launch { userPreferences.setReplayGainPreampDb(value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setKeepScreenOn(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { userPreferences.setKeepScreenOn(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSortNameMode(mode: SortNameMode) {
|
||||||
|
viewModelScope.launch { userPreferences.setSortNameMode(mode) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user