mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-12 15:46:34 +01:00
Compare commits
261
Commits
eef94eff63
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9af47323a | ||
|
|
02404ef562 | ||
|
|
d05fb6fc07 | ||
|
|
2bbff7e477 | ||
|
|
e94b365495 | ||
|
|
d62bff5ace | ||
|
|
29335f7ddf | ||
|
|
11276c2e1f | ||
|
|
eb55858cc4 | ||
|
|
95ad993996 | ||
|
|
11abd4afbd | ||
|
|
69f41b3459 | ||
|
|
09baaeb77d | ||
|
|
484cc55385 | ||
|
|
599924bdd0 | ||
|
|
d6ff3ee086 | ||
|
|
b2a1716be2 | ||
|
|
51792f836b | ||
|
|
446a35d26e | ||
|
|
b18e0891f6 | ||
|
|
c53f1aef06 | ||
|
|
87e0609c05 | ||
|
|
c3a5382c27 | ||
|
|
e8cbb93714 | ||
|
|
6f1e05d15a | ||
|
|
c165a6cc88 | ||
|
|
18c24ca566 | ||
|
|
8d6f7e2341 | ||
|
|
29935b7bee | ||
|
|
4bfe67ec19 | ||
|
|
060a5f036f | ||
|
|
c169549ef4 | ||
|
|
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 | ||
|
|
de4bd473f9 | ||
|
|
7f421470b2 | ||
|
|
4bea164906 | ||
|
|
9d43b6d104 | ||
|
|
323bfde41b | ||
|
|
18d06f750d | ||
|
|
44bc3f927e | ||
|
|
b0a0bc1867 | ||
|
|
e700c99796 | ||
|
|
6af43c9c16 | ||
|
|
4f04129b0e | ||
|
|
772ab5f563 | ||
|
|
1eb7c136bf | ||
|
|
17ba581f88 | ||
|
|
76f2e4014c | ||
|
|
3d2ec5e931 | ||
|
|
368f53d589 | ||
|
|
3f3db71f14 | ||
|
|
e60b2d9910 | ||
|
|
f6d1d548e4 | ||
|
|
e74360fe07 | ||
|
|
d3373261d2 | ||
|
|
7f4e9703d4 | ||
|
|
ff8011eaf4 | ||
|
|
b4b5f66d81 | ||
|
|
8fb930c2ce | ||
|
|
af3741a549 | ||
|
|
fded9949af | ||
|
|
5c219c9f06 | ||
|
|
7145b9c3c7 | ||
|
|
4db8645691 | ||
|
|
eaab5368d0 | ||
|
|
6b6a99e51c | ||
|
|
21d6c57bc2 | ||
|
|
650d8c49e6 | ||
|
|
c76a79c3ed | ||
|
|
23911fceb5 | ||
|
|
f506ac0410 | ||
|
|
293561d81f | ||
|
|
2de31735f7 | ||
|
|
327aa08763 | ||
|
|
8aee86fc60 | ||
|
|
cc65f238ae | ||
|
|
acd087cfe0 | ||
|
|
05de10c912 |
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,79 @@
|
||||
name: Build
|
||||
description: Set up Nix, optionally sign, run Gradle and report the version code
|
||||
|
||||
inputs:
|
||||
gradle-args:
|
||||
description: Gradle tasks and flags to run
|
||||
required: true
|
||||
sign:
|
||||
description: Decode and use the store keystore when "true"
|
||||
required: false
|
||||
default: "false"
|
||||
keystore-base64:
|
||||
description: Base64 of the release keystore
|
||||
required: false
|
||||
default: ""
|
||||
keyproperties-base64:
|
||||
description: Base64 of the key.properties holding the key alias
|
||||
required: false
|
||||
default: ""
|
||||
keystore-password:
|
||||
description: Keystore (store) password
|
||||
required: false
|
||||
default: ""
|
||||
key-password:
|
||||
description: Signing key password
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
outputs:
|
||||
version-code:
|
||||
description: versionCode read from app/build.gradle.kts
|
||||
value: ${{ steps.version.outputs.code }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: ./.github/actions/nix
|
||||
- uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-v2-
|
||||
- uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.m2/repository/org/robolectric
|
||||
key: robolectric-m2-v1-${{ hashFiles('gradle/libs.versions.toml', 'app/src/test/resources/robolectric.properties') }}
|
||||
restore-keys: robolectric-m2-v1-
|
||||
- id: signing
|
||||
if: inputs.sign == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ inputs.keystore-base64 }}
|
||||
KEYPROPERTIES_BASE64: ${{ inputs.keyproperties-base64 }}
|
||||
run: |
|
||||
umask 077
|
||||
echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
|
||||
alias=$(printf '%s' "$KEYPROPERTIES_BASE64" | base64 -d | sed -n 's/^keyAlias=//p' | tr -d '\r')
|
||||
test -n "$alias" || { echo "no keyAlias in keyproperties" >&2; exit 1; }
|
||||
echo "alias=$alias" >> "$GITHUB_OUTPUT"
|
||||
- shell: bash
|
||||
env:
|
||||
SIGN: ${{ inputs.sign }}
|
||||
KEYSTORE_PASSWORD: ${{ inputs.keystore-password }}
|
||||
KEY_ALIAS: ${{ steps.signing.outputs.alias }}
|
||||
KEY_PASSWORD: ${{ inputs.key-password }}
|
||||
run: |
|
||||
if [ "$SIGN" = "true" ]; then
|
||||
export RELEASE_KEYSTORE="$RUNNER_TEMP/release.keystore"
|
||||
export RELEASE_KEYSTORE_PASSWORD="$KEYSTORE_PASSWORD"
|
||||
export RELEASE_KEY_ALIAS="$KEY_ALIAS"
|
||||
export RELEASE_KEY_PASSWORD="$KEY_PASSWORD"
|
||||
fi
|
||||
nix develop --command gradle ${{ inputs.gradle-args }}
|
||||
- if: always()
|
||||
shell: bash
|
||||
run: rm -f "$RUNNER_TEMP/release.keystore"
|
||||
- id: version
|
||||
shell: bash
|
||||
run: echo "code=$(grep -oP 'versionCode = \K\d+' app/build.gradle.kts)" >> "$GITHUB_OUTPUT"
|
||||
@@ -0,0 +1,14 @@
|
||||
name: Nix
|
||||
description: Install Nix and restore the store cache
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
with:
|
||||
extra_nix_config: experimental-features = nix-command flakes
|
||||
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
|
||||
with:
|
||||
primary-key: nix-v3-${{ hashFiles('flake.lock', '**/*.nix') }}
|
||||
restore-prefixes-first-match: nix-v3-
|
||||
gc-max-store-size-linux: 10G
|
||||
+29
-20
@@ -9,37 +9,46 @@ concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.daemon=false
|
||||
NIX_CONFIG: experimental-features = nix-command flakes
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.daemon=false
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
|
||||
with:
|
||||
primary-key: nix-v2-${{ hashFiles('flake.lock', '**/*.nix') }}
|
||||
restore-prefixes-first-match: nix-v2-
|
||||
gc-max-store-size-linux: 6G
|
||||
- uses: actions/cache@v6
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/build
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-
|
||||
gradle-args: ":app:ktlintCheck :app:lintDebug :app:testDebugUnitTest :app:assembleDebug :app:assembleNightly --no-configuration-cache --console=plain --no-daemon"
|
||||
- name: Collect nightly builds
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
cp app/build/outputs/apk/debug/app-debug.apk subtracks-nightly-debug.apk
|
||||
cp app/build/outputs/apk/nightly/app-nightly.apk subtracks-nightly.apk
|
||||
- name: Checksums
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: sha256sum subtracks-nightly.apk subtracks-nightly-debug.apk | tee SHA256SUMS
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: github.ref == 'refs/heads/main'
|
||||
with:
|
||||
path: subtracks-nightly.apk
|
||||
archive: false
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: github.ref == 'refs/heads/main'
|
||||
with:
|
||||
path: subtracks-nightly-debug.apk
|
||||
archive: false
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: github.ref == 'refs/heads/main'
|
||||
with:
|
||||
path: SHA256SUMS
|
||||
archive: false
|
||||
- uses: actions/cache@v6
|
||||
with:
|
||||
path: .integration/music
|
||||
key: integration-music-v1
|
||||
- run: nix develop --command gradle :app:ktlintCheck :app:lintDebug :app:testDebugUnitTest --no-configuration-cache --console=plain
|
||||
key: integration-music-v3
|
||||
- run: nix develop .#integration --command nu tools/integration-test.nu
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
name: Release
|
||||
|
||||
run-name: Release ${{ github.ref_name }}
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
version-code: ${{ steps.build.outputs.version-code }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check tag matches versionName
|
||||
run: |
|
||||
version=$(grep -oP 'versionName = "\K[^"]+' app/build.gradle.kts)
|
||||
case "$GITHUB_REF_NAME" in
|
||||
"v$version" | "v$version"-*) ;;
|
||||
*)
|
||||
echo "tag $GITHUB_REF_NAME does not match versionName $version" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
- id: build
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
gradle-args: ":app:assembleRelease :app:bundleRelease --no-daemon"
|
||||
sign: "true"
|
||||
keystore-base64: ${{ secrets.SUBTRACKS_KEYSTORE_BASE64 }}
|
||||
keyproperties-base64: ${{ secrets.SUBTRACKS_KEYPROPERTIES_BASE64 }}
|
||||
keystore-password: ${{ secrets.SUBTRACKS_UPLOAD_STORE_PASSWORD }}
|
||||
key-password: ${{ secrets.SUBTRACKS_UPLOAD_KEY_PASSWORD }}
|
||||
- name: Export release certificate
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.SUBTRACKS_KEYSTORE_BASE64 }}
|
||||
KEYPROPERTIES_BASE64: ${{ secrets.SUBTRACKS_KEYPROPERTIES_BASE64 }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.SUBTRACKS_UPLOAD_STORE_PASSWORD }}
|
||||
run: |
|
||||
umask 077
|
||||
trap 'rm -f "$RUNNER_TEMP/keystore"' EXIT
|
||||
echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/keystore"
|
||||
alias=$(printf '%s' "$KEYPROPERTIES_BASE64" | base64 -d | sed -n 's/^keyAlias=//p' | tr -d '\r')
|
||||
nix develop --command keytool -exportcert -rfc -keystore "$RUNNER_TEMP/keystore" -alias "$alias" -storepass "$KEYSTORE_PASSWORD" -file app/build/outputs/release-certificate.pem
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release
|
||||
path: app/build/outputs
|
||||
|
||||
publish:
|
||||
needs: build
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release
|
||||
path: dist
|
||||
- uses: ./.github/actions/nix
|
||||
- name: Detect stable tag
|
||||
id: stable
|
||||
run: |
|
||||
if [[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "value=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "value=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Check release notes exist
|
||||
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
|
||||
if: steps.stable.outputs.value == 'true'
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
umask 077
|
||||
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > "$RUNNER_TEMP/play.json"
|
||||
- name: Upload to Google Play
|
||||
if: steps.stable.outputs.value == 'true'
|
||||
run: |
|
||||
nix develop .#release --command fastlane supply \
|
||||
--aab dist/bundle/release/app-release.aab \
|
||||
--package_name com.subtracks \
|
||||
--track "${{ vars.PLAY_TRACK || 'internal' }}" \
|
||||
--json_key "$RUNNER_TEMP/play.json" \
|
||||
--mapping dist/mapping/release/mapping.txt
|
||||
- name: Remove Play service account
|
||||
if: always()
|
||||
run: rm -f "$RUNNER_TEMP/play.json"
|
||||
- name: Checksums and signature
|
||||
id: checksums
|
||||
run: |
|
||||
cp dist/apk/release/app-release.apk subtracks.apk
|
||||
cp dist/bundle/release/app-release.aab subtracks.aab
|
||||
gzip -n -9 -c dist/mapping/release/mapping.txt > mapping.txt.gz
|
||||
sha256sum subtracks.apk subtracks.aab > SHA256SUMS
|
||||
echo "Artifact SHA-256:"
|
||||
sha256sum subtracks.apk subtracks.aab
|
||||
echo "APK signature:"
|
||||
certs=$(nix develop --command bash -c '"$ANDROID_HOME/build-tools/37.0.0/apksigner" verify --print-certs subtracks.apk')
|
||||
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/:$//')
|
||||
echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT"
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
args=(--verify-tag --title "$GITHUB_REF_NAME" --notes-file "docs/releases/$GITHUB_REF_NAME.md")
|
||||
if [ "${{ steps.stable.outputs.value }}" != "true" ]; then
|
||||
args+=(--prerelease)
|
||||
fi
|
||||
gh release create "$GITHUB_REF_NAME" subtracks.apk subtracks.aab SHA256SUMS dist/release-certificate.pem mapping.txt.gz "${args[@]}"
|
||||
@@ -1,5 +1,6 @@
|
||||
.gradle/
|
||||
build/
|
||||
!.github/actions/build/
|
||||
local.properties
|
||||
|
||||
*.apk
|
||||
|
||||
@@ -4,7 +4,7 @@ Guidance for agents (and humans) working in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
A native Android rewrite of Subtracks, a client for Subsonic-compatible servers (Navidrome, gonic, Airsonic, ...), in Kotlin and Jetpack Compose. The `main` history still contains the earlier Flutter app; the native app is being built fresh under `app/` and ships as `com.subtracks.next` for side-by-side beta. The aim is a clean, F-Droid-friendly client: free/libre dependencies only, no telemetry.
|
||||
A native Android rewrite of Subtracks, a client for Subsonic-compatible servers (Navidrome, gonic, Airsonic, ...), in Kotlin and Jetpack Compose. The `main` history still contains the earlier Flutter app; the native app is being built fresh under `app/` and ships as `com.subtracks`. The aim is a clean, F-Droid-friendly client: free/libre dependencies only, no telemetry.
|
||||
|
||||
## Environment
|
||||
|
||||
@@ -34,11 +34,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
|
||||
|
||||
## CI
|
||||
|
||||
- Forgejo Actions, `.forgejo/workflows/ci.yml`, a single `ci` job that runs the lint, unit test and integration test steps (one cache restore/save and Gradle warm-up instead of three).
|
||||
- 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`.
|
||||
- The CI workflows live under `.forgejo/` and `.github/`, and the GitHub mirror and the GitHub PR import under `.forgejo/`; see `.forgejo/README.md`.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -59,8 +56,7 @@ 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)`.
|
||||
- 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`.
|
||||
- `applicationId` is `com.subtracks.next` for the beta; change it to `com.subtracks` before any store release.
|
||||
- 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).
|
||||
- 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.
|
||||
- The store screenshots in `metadata/en-US/images/phoneScreenshots/` must be 24-bit no-alpha PNGs (Play rejects palette and alpha PNGs for screenshots). `:app:demoScreenshots` writes its captures in that form, so copy them across as-is and don't run a palette optimizer over them.
|
||||
|
||||
@@ -26,21 +26,22 @@ subtracks is an open source Android client for [Subsonic-compatible](http://www.
|
||||
<a href="https://github.com/austinried/subtracks/releases/"><img src=".assets/github-badge.png" width="250"/></a>
|
||||
</div>
|
||||
|
||||
See [signing/README.md](signing/README.md) for the fingerprints and the commands to verify a download.
|
||||
|
||||
## Features
|
||||
|
||||
- Home screen to browse your library over many categories, genres, and more
|
||||
- Album and artist artwork, shown full-resolution in the detail and now playing views
|
||||
- Downloads with album, artist and playlist bulk downloads, download quality and a metered-connection toggle
|
||||
- Search across albums, artists, playlists and songs, with per-list sort and filter controls
|
||||
- Gapless playback, an editable queue you can reorder and jump through, and loop and shuffle modes
|
||||
- Up next queue to add items into the currently playing queue without rewriting it
|
||||
- Multiple server support with quick switching
|
||||
- Stream quality (bitrate and format) settings for Wi-Fi and mobile and automatic switching
|
||||
- Scrobbling and starred items, cached while offline and sent back to the server when online
|
||||
- Media notification and headset/Bluetooth controls
|
||||
- Adaptive theming from the current song's artwork
|
||||
|
||||
The full library is mirrored at sync, so browsing, searching, sorting and filtering all work without waiting on the server. Only media and artwork are fetched on demand, and not even that when they're already downloaded.
|
||||
- **Large Libraries:** Built for very large libraries, from syncing the whole collection to playing playlists with thousands of tracks.
|
||||
- **Downloads:** Song and bulk downloads, with download quality (encoding, bitrate) and metered-connection settings.
|
||||
- **Offline Mode:** Stars, scrobbles and play counts queue up while you're offline and sync back to the server when you're online again.
|
||||
- **Artwork-first:** Album and artist art everywhere, shown full-resolution in the detail and now playing views.
|
||||
- **Adaptive Theming:** Colours drawn from the current song's artwork.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Multiple Servers:** Connect several servers and switch between them quickly.
|
||||
- **Stream Quality:** Bitrate and format settings for Wi-Fi and mobile, with automatic switching.
|
||||
- **System Controls:** Media notification and headset/Bluetooth controls.
|
||||
|
||||
## Building
|
||||
|
||||
@@ -55,6 +56,14 @@ gradle :app:assembleDebug
|
||||
|
||||
Test, lint and screenshot commands, plus the integration harness, are documented in [AGENTS.md](AGENTS.md). The architecture is described in [docs/architecture.md](docs/architecture.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
The GitHub repository [austinried/subtracks](https://github.com/austinried/subtracks) is a mirror of the repo hosted on my personal (private) code forge that syncs whenever I commit to it.
|
||||
|
||||
Bug reports and feature requests are accepted on GitHub, as well as pull requests, but:
|
||||
* A well-crafted issue with clear reproduction and logs for bugs or mock-ups and use case details for features is preferred over a pull request
|
||||
* Pull requests will be pulled in as patches to my code forge and then synced back (you'll still be the author, I'll be shown as the committer)
|
||||
|
||||
## Translations
|
||||
|
||||
Want to see subtracks in your language? Visit the project on [Weblate](https://hosted.weblate.org/engage/subtracks/) to help!
|
||||
@@ -65,7 +74,7 @@ Want to see subtracks in your language? Visit the project on [Weblate](https://h
|
||||
|
||||
## AI usage disclosure
|
||||
|
||||
This project contains code and documentation generated by LLM, which is created via human prompting, iteration and review.
|
||||
This project contains code and documentation generated by LLM, which is created via human prompting, iteration and review. The current native rewrite is based on two (and a half) other iterations of this project that I coded by hand, so the architecture and other decisions behind that are all still coming from me.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+103
-6
@@ -14,27 +14,48 @@ ktlint {
|
||||
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 {
|
||||
namespace = "com.subtracks"
|
||||
compileSdk = 37
|
||||
buildToolsVersion = "37.0.0"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.subtracks.next"
|
||||
applicationId = "com.subtracks"
|
||||
minSdk = 24
|
||||
targetSdk = 37
|
||||
versionCode = 13
|
||||
versionName = "3.0.0"
|
||||
versionCode = 14
|
||||
versionName = "3.1.0"
|
||||
buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"")
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
androidResources {
|
||||
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 {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
@@ -43,7 +64,28 @@ android {
|
||||
|
||||
lint {
|
||||
checkReleaseBuilds = false
|
||||
disable += setOf("MissingTranslation", "MissingQuantity")
|
||||
warningsAsErrors = true
|
||||
disable +=
|
||||
setOf(
|
||||
"MissingTranslation",
|
||||
"MissingQuantity",
|
||||
// Version nags are handled by the dependency-update process, not the build.
|
||||
"AndroidGradlePluginVersion",
|
||||
"GradleDependency",
|
||||
"NewerVersionAvailable",
|
||||
)
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
// Prebuilt .so files with no symbol table; leaving them unstripped avoids the strip warning.
|
||||
keepDebugSymbols +=
|
||||
setOf(
|
||||
"**/libandroidx.graphics.path.so",
|
||||
"**/libdatastore_shared_counter.so",
|
||||
"**/libsqliteJni.so",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
@@ -57,12 +99,43 @@ android {
|
||||
.kotlin.directories
|
||||
.add("src/integrationTest/kotlin")
|
||||
|
||||
signingConfigs {
|
||||
// Committed on purpose: a public key for debug and nightly builds, never for distribution.
|
||||
getByName("debug") {
|
||||
storeFile = file("debug.keystore")
|
||||
storePassword = "android"
|
||||
keyAlias = "androiddebugkey"
|
||||
keyPassword = "android"
|
||||
}
|
||||
val keystorePath = providers.environmentVariable("RELEASE_KEYSTORE").orNull
|
||||
if (keystorePath != null) {
|
||||
create("release") {
|
||||
storeFile = file(keystorePath)
|
||||
storePassword = providers.environmentVariable("RELEASE_KEYSTORE_PASSWORD").orNull
|
||||
keyAlias = providers.environmentVariable("RELEASE_KEY_ALIAS").orNull
|
||||
keyPassword = providers.environmentVariable("RELEASE_KEY_PASSWORD").orNull
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("debug").res.directories.add("src/nightly/res")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".nightly"
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||
// Debug key so `installRelease` works locally; store and F-Droid builds re-sign.
|
||||
// Release signing when the keystore is supplied (CI); debug elsewhere so local build/install works.
|
||||
signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
|
||||
}
|
||||
create("nightly") {
|
||||
initWith(getByName("release"))
|
||||
applicationIdSuffix = ".nightly"
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
@@ -71,6 +144,7 @@ android {
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
allWarningsAsErrors.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +156,8 @@ ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
|
||||
// Resolving the variant classpaths here makes AGP warn that a configuration was resolved during
|
||||
// configuration time; upstream, tracked in mikepenz/AboutLibraries#1369.
|
||||
aboutLibraries {
|
||||
collect {
|
||||
configPath = file("config")
|
||||
@@ -102,15 +178,20 @@ val integrationTestClasses =
|
||||
|
||||
val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
|
||||
|
||||
val queryTimingClass = "com.subtracks.data.db.QueryTimingMeasurementTest"
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
// Robolectric fetches its Android SDK jar itself, outside Gradle's repositories.
|
||||
systemProperty("robolectric.dependency.repo.url", providers.gradleProperty("mavenCentralMirrorUrl").get())
|
||||
if (name.endsWith("UnitTest")) {
|
||||
filter {
|
||||
(integrationTestClasses + demoScreenshotClass).forEach { excludeTestsMatching(it) }
|
||||
(integrationTestClasses + demoScreenshotClass + queryTimingClass).forEach { excludeTestsMatching(it) }
|
||||
}
|
||||
}
|
||||
testLogging {
|
||||
exceptionFormat = TestExceptionFormat.FULL
|
||||
}
|
||||
outputs.cacheIf { false }
|
||||
}
|
||||
|
||||
tasks.register<Test>("integrationTest") {
|
||||
@@ -141,6 +222,20 @@ tasks.register<Test>("demoScreenshots") {
|
||||
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 {
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.core.ktx)
|
||||
@@ -166,7 +261,9 @@ dependencies {
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.media3.exoplayer)
|
||||
implementation(libs.androidx.media3.session)
|
||||
implementation(libs.androidx.media3.datasource)
|
||||
implementation(libs.androidx.media3.datasource.okhttp)
|
||||
implementation(libs.androidx.media3.database)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"uniqueId": "com.subtracks:next",
|
||||
"uniqueId": "com.subtracks",
|
||||
"name": "Subtracks",
|
||||
"description": "Subtracks itself, a client for Subsonic-compatible servers.",
|
||||
"website": "https://github.com/austinried/subtracks",
|
||||
|
||||
Binary file not shown.
+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
+2
-2
@@ -56,7 +56,7 @@ class SubsonicSourceIntegrationTest(
|
||||
assertNotNull(kosmo.starred)
|
||||
assertTrue(kosmo.created <= System.currentTimeMillis() / 1000)
|
||||
|
||||
assertNull(albums.first { it.name == "Retroconnaissance EP" }.starred)
|
||||
assertNull(albums.first { it.name == "Retroconnaissance" }.starred)
|
||||
assertNull(albums.first { it.name == "I Don't Know What I'm Doing" }.starred)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ class SubsonicSourceIntegrationTest(
|
||||
.filter { it.artistId == artists.first { a -> a.name == "Ugress" }.id }
|
||||
.map { it.name }
|
||||
.sorted()
|
||||
assertEquals(listOf("Kosmonaut", "Retroconnaissance EP"), ugressAlbums)
|
||||
assertEquals(listOf("Kosmonaut", "Retroconnaissance"), ugressAlbums)
|
||||
|
||||
val bradAlbums =
|
||||
albums
|
||||
|
||||
+2
-2
@@ -22,7 +22,7 @@ class SubsonicWriteIntegrationTest(
|
||||
.albums()
|
||||
.toList()
|
||||
.flatten()
|
||||
.first { it.name == "Retroconnaissance EP" }
|
||||
.first { it.name == "Retroconnaissance" }
|
||||
|
||||
source.setStar(StarType.Album, album.id, true)
|
||||
try {
|
||||
@@ -94,7 +94,7 @@ class SubsonicWriteIntegrationTest(
|
||||
.songs()
|
||||
.toList()
|
||||
.flatten()
|
||||
.first { it.album == "Retroconnaissance EP" }
|
||||
.first { it.album == "Retroconnaissance" }
|
||||
|
||||
source.setStar(StarType.Song, song.id, true)
|
||||
try {
|
||||
|
||||
+2
-11
@@ -1,11 +1,7 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.source.subsonic.TestServers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -18,12 +14,7 @@ class MultiSourceSyncIntegrationTest {
|
||||
@Test
|
||||
fun syncsEveryServerIntoOneLibraryWithoutInterference() =
|
||||
runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
val db = createTestDatabase()
|
||||
try {
|
||||
TestServers.all.forEachIndexed { index, server ->
|
||||
val id = index + 1L
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.source.subsonic.SubsonicClient
|
||||
import com.subtracks.data.source.subsonic.SubsonicSource
|
||||
@@ -28,12 +24,7 @@ class PruneSyncIntegrationTest {
|
||||
val removed = File(musicDir, REMOVED_ALBUM_DIR)
|
||||
check(removed.isDirectory) { "$removed is missing; the harness must prepare the prune library" }
|
||||
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
val db = createTestDatabase()
|
||||
try {
|
||||
db.sourcesDao().upsertSource(
|
||||
Source(id = 1, name = "prune", address = baseUrl, isActive = true, createdAt = 0),
|
||||
@@ -93,7 +84,7 @@ class PruneSyncIntegrationTest {
|
||||
?: error("$name is not set; run the integration harness (tools/integration-test.nu)")
|
||||
|
||||
private companion object {
|
||||
const val REMOVED_ALBUM_DIR = "197"
|
||||
const val REMOVED_ALBUM_NAME = "Retroconnaissance EP"
|
||||
const val REMOVED_ALBUM_DIR = "7GZYpfn8wypaTR00LWeNI4"
|
||||
const val REMOVED_ALBUM_NAME = "Retroconnaissance"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-11
@@ -1,11 +1,7 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.source.subsonic.TestServers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -19,12 +15,7 @@ class SyncServiceIntegrationTest {
|
||||
fun syncMirrorsTheLibraryIntoRoomForEveryServer() =
|
||||
runBlocking {
|
||||
servers.forEach { server ->
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
val db = createTestDatabase()
|
||||
try {
|
||||
db.sourcesDao().upsertSource(
|
||||
Source(id = 1, name = server.name, address = server.baseUrl, isActive = true, createdAt = 0),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
|
||||
@@ -18,7 +19,8 @@
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Subtracks">
|
||||
android:theme="@style/Theme.Subtracks"
|
||||
tools:ignore="DataExtractionRules,UnusedAttribute">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
@@ -35,7 +37,8 @@
|
||||
<service
|
||||
android:name=".playback.PlaybackService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||
</intent-filter>
|
||||
@@ -44,6 +47,16 @@
|
||||
<receiver
|
||||
android:name=".data.download.DownloadCancelReceiver"
|
||||
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>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.subtracks.data.sync.AutoSync
|
||||
import com.subtracks.ui.NowPlayingLauncher
|
||||
import com.subtracks.ui.SubtracksRoot
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
@@ -19,10 +20,21 @@ import org.koin.core.context.GlobalContext
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val launchViewModel: NowPlayingLaunchViewModel by viewModels()
|
||||
private val nowPlayingLauncher: NowPlayingLauncher by lazy { GlobalContext.get().get() }
|
||||
private val autoSync: AutoSync by lazy { GlobalContext.get().get() }
|
||||
|
||||
private val requestNotifications =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
autoSync.setForeground(true)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
autoSync.setForeground(false)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestNotificationPermission()
|
||||
|
||||
@@ -5,20 +5,28 @@ import coil3.ImageLoader
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.annotation.DelicateCoilApi
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import com.subtracks.data.LegacyDataCleanup
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.repo.ArtworkSeedStore
|
||||
import com.subtracks.di.appModule
|
||||
import com.subtracks.log.Log
|
||||
import com.subtracks.ui.theme.ArtworkSeedCache
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
|
||||
class SubtracksApp : Application() {
|
||||
private val http = OkHttpClient()
|
||||
private val http =
|
||||
UserAgent
|
||||
.httpClient()
|
||||
.newBuilder()
|
||||
.addInterceptor(Log.interceptor())
|
||||
.build()
|
||||
|
||||
@OptIn(DelicateCoilApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.init(this)
|
||||
LegacyDataCleanup.run(this)
|
||||
stopKoin()
|
||||
SingletonImageLoader.reset()
|
||||
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)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import androidx.room3.RoomRawQuery
|
||||
|
||||
internal val CREDITED_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery())
|
||||
internal val DOWNLOADED_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery(downloaded = true))
|
||||
internal val DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST =
|
||||
albumCreditRows(AlbumCreditQuery(downloaded = true, artistScoped = true, selectAlbums = true))
|
||||
internal val DOWNLOADED_COVER_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery(downloaded = true, coverOnly = true))
|
||||
internal val ARTIST_COVER_CREDITS = albumCreditRows(AlbumCreditQuery(coverOnly = true))
|
||||
internal val ARTIST_COVER_CREDITS_FOR_ARTIST = albumCreditRows(AlbumCreditQuery(coverOnly = true, artistScoped = true))
|
||||
|
||||
private data class AlbumCreditQuery(
|
||||
val artistScoped: Boolean = false,
|
||||
val coverOnly: Boolean = false,
|
||||
val downloaded: Boolean = false,
|
||||
val selectAlbums: Boolean = false,
|
||||
)
|
||||
|
||||
private fun albumCreditRows(query: AlbumCreditQuery): String =
|
||||
if (query.selectAlbums) {
|
||||
listOf(
|
||||
primaryAlbumCredits(query),
|
||||
albumRelationCredits(query),
|
||||
trackRelationCredits(query),
|
||||
trackCredits(query),
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
primaryAlbumCredits(query),
|
||||
albumRelationCredits(query),
|
||||
trackCredits(query),
|
||||
trackRelationCredits(query),
|
||||
)
|
||||
}.joinToString(if (query.downloaded) " UNION " else " UNION ALL ")
|
||||
|
||||
private fun primaryAlbumCredits(query: AlbumCreditQuery): String =
|
||||
buildString {
|
||||
append(
|
||||
when {
|
||||
query.selectAlbums -> "SELECT albums.* FROM albums "
|
||||
query.downloaded -> "SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums "
|
||||
else -> "SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType FROM albums "
|
||||
},
|
||||
)
|
||||
append("WHERE albums.sourceId = :sourceId AND ${query.directArtist("albums.artistId")} ")
|
||||
append(query.coverFilter())
|
||||
if (query.downloaded) append("AND $ALBUM_DOWNLOADED ")
|
||||
}.trim()
|
||||
|
||||
private fun albumRelationCredits(query: AlbumCreditQuery): String =
|
||||
buildString {
|
||||
append(
|
||||
when {
|
||||
query.selectAlbums -> "SELECT albums.* FROM album_artists "
|
||||
query.downloaded -> "SELECT albums.id, album_artists.artistId FROM album_artists "
|
||||
else -> "SELECT album_artists.artistId, albums.id, 'album' FROM album_artists "
|
||||
},
|
||||
)
|
||||
append("JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId ")
|
||||
append("WHERE album_artists.sourceId = :sourceId ")
|
||||
if (query.artistScoped) append("AND album_artists.artistId = :artistId ")
|
||||
append(query.coverFilter())
|
||||
if (query.downloaded) append("AND $ALBUM_DOWNLOADED ")
|
||||
}.trim()
|
||||
|
||||
private fun trackCredits(query: AlbumCreditQuery): String =
|
||||
buildString {
|
||||
append(
|
||||
when {
|
||||
query.selectAlbums -> "SELECT albums.* FROM songs "
|
||||
query.downloaded -> "SELECT songs.albumId, songs.artistId FROM songs "
|
||||
else -> "SELECT songs.artistId, songs.albumId, 'track' FROM songs "
|
||||
},
|
||||
)
|
||||
append(SONG_ALBUM_JOIN)
|
||||
if (query.downloaded) append(TRACK_DOWNLOAD_JOIN)
|
||||
append("WHERE songs.sourceId = :sourceId AND ${query.directArtist("songs.artistId")} ")
|
||||
append(query.coverFilter())
|
||||
if (query.downloaded) append("AND sd.status = 'Completed' ")
|
||||
}.trim()
|
||||
|
||||
private fun trackRelationCredits(query: AlbumCreditQuery): String =
|
||||
buildString {
|
||||
append(
|
||||
when {
|
||||
query.selectAlbums -> "SELECT albums.* FROM song_artists "
|
||||
query.downloaded -> "SELECT songs.albumId, song_artists.artistId FROM song_artists "
|
||||
else -> "SELECT song_artists.artistId, songs.albumId, 'track' FROM song_artists "
|
||||
},
|
||||
)
|
||||
append("JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId ")
|
||||
append(SONG_ALBUM_JOIN)
|
||||
if (query.downloaded) append(TRACK_DOWNLOAD_JOIN)
|
||||
append("WHERE song_artists.sourceId = :sourceId ")
|
||||
if (query.artistScoped) append("AND song_artists.artistId = :artistId ")
|
||||
append(query.coverFilter())
|
||||
if (query.downloaded) append("AND sd.status = 'Completed' ")
|
||||
}.trim()
|
||||
|
||||
private fun AlbumCreditQuery.directArtist(column: String): String = if (artistScoped) "$column = :artistId" else "$column IS NOT NULL"
|
||||
|
||||
private fun AlbumCreditQuery.coverFilter(): String = if (coverOnly) "AND albums.coverArt IS NOT NULL " else ""
|
||||
|
||||
private const val SONG_ALBUM_JOIN = "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId "
|
||||
|
||||
private const val TRACK_DOWNLOAD_JOIN = "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id "
|
||||
|
||||
private const val ALBUM_DOWNLOADED =
|
||||
"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')"
|
||||
|
||||
internal fun downloadedArtistArtQuery(
|
||||
sourceId: Long,
|
||||
artistIds: List<String>,
|
||||
): RoomRawQuery {
|
||||
val bindings = mutableMapOf<String, Any?>("sourceId" to sourceId)
|
||||
val placeholders =
|
||||
artistIds
|
||||
.mapIndexed { index, artistId ->
|
||||
val name = "artistId$index"
|
||||
bindings[name] = artistId
|
||||
":$name"
|
||||
}.ifEmpty { listOf("NULL") }
|
||||
return rawQuery(DOWNLOADED_ARTIST_ART_UPDATE_SQL.replace(":artistIds", placeholders.joinToString(",")), bindings)
|
||||
}
|
||||
@@ -2,11 +2,19 @@ package com.subtracks.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.RoomDatabase
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import com.subtracks.log.Log
|
||||
|
||||
fun createAndroidDatabase(context: Context): SubtracksDatabase =
|
||||
Room
|
||||
fun createAndroidDatabase(context: Context): SubtracksDatabase {
|
||||
val driver = LoggingSQLiteDriver(BundledSQLiteDriver(), Log::sql)
|
||||
return Room
|
||||
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.addMigrations(*MIGRATIONS)
|
||||
.setDriver(driver)
|
||||
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
|
||||
.addSubtracksDatabaseCallbacks()
|
||||
.build()
|
||||
}
|
||||
|
||||
internal fun RoomDatabase.Builder<SubtracksDatabase>.addSubtracksDatabaseCallbacks(): RoomDatabase.Builder<SubtracksDatabase> =
|
||||
addCallback(ArtistSearchTriggerOptimization).addCallback(SqliteLifecycleLogging)
|
||||
|
||||
@@ -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
|
||||
// downloads are visible even though they lead no albums of their own.
|
||||
private const val ARTIST_DOWNLOAD_ROWS =
|
||||
"SELECT albums.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
||||
"FROM songs " +
|
||||
// Starts from songs so status totals include every song the artist is credited on.
|
||||
internal const val ARTIST_DOWNLOAD_ROWS =
|
||||
"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 " +
|
||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"WHERE song_downloads.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT songs.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
||||
"FROM songs " +
|
||||
"SELECT album_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 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 songs.sourceId = :sourceId AND songs.artistId IS NOT NULL"
|
||||
"JOIN album_artists ON album_artists.sourceId = albums.sourceId AND album_artists.albumId = albums.id " +
|
||||
"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
|
||||
interface DownloadDao {
|
||||
@@ -38,10 +63,15 @@ interface DownloadDao {
|
||||
suspend fun all(): List<SongDownload>
|
||||
|
||||
@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 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(
|
||||
sourceId: Long,
|
||||
@@ -89,12 +119,15 @@ interface DownloadDao {
|
||||
fun albumStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
|
||||
|
||||
@Query(
|
||||
"SELECT id, " +
|
||||
"COUNT(DISTINCT songId) AS total, " +
|
||||
"SELECT statuses.id, artists.songCount AS total, statuses.downloaded, statuses.downloading " +
|
||||
"FROM (SELECT id, " +
|
||||
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
|
||||
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
|
||||
"FROM (" + ARTIST_DOWNLOAD_ROWS + ") GROUP BY id " +
|
||||
"HAVING downloaded > 0 OR downloading > 0",
|
||||
"FROM (" + DOWNLOADED_ARTIST_ROWS + ") " +
|
||||
"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>>
|
||||
|
||||
@@ -114,24 +147,30 @@ interface DownloadDao {
|
||||
|
||||
@Query(
|
||||
"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, " +
|
||||
"d.bytes AS bytes, d.total AS total " +
|
||||
"al.artistId AS artistId, COALESCE(ar.name, al.albumArtist) AS artistName, d.status AS status, " +
|
||||
"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 " +
|
||||
"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 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>>
|
||||
|
||||
@Query(
|
||||
"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, " +
|
||||
"ar.id AS artistRow, ar.coverArt AS artistCoverArt " +
|
||||
"ar.id AS artistRow, ar.coverArt AS artistCoverArt, " +
|
||||
"aar.coverArt AS albumArtistCoverArt " +
|
||||
"FROM song_downloads d " +
|
||||
"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 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>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,45 +5,163 @@ import androidx.sqlite.SQLiteStatement
|
||||
import com.subtracks.data.prefs.AlbumSort
|
||||
import com.subtracks.data.prefs.ArtistSort
|
||||
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
|
||||
// the resulting PagingSource is not invalidated by download writes (including progress updates).
|
||||
internal const val ALBUMS_FILTER_BASE =
|
||||
"FROM albums WHERE sourceId = ? " +
|
||||
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
||||
"AND (? = '' " +
|
||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
||||
"WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
||||
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
||||
"OR instr(lower(albumArtist), lower(?)) > 0))) "
|
||||
// The default filters intentionally omit song_downloads: the unfiltered lists do not need the
|
||||
// join, and their paging sources do not observe download writes (including progress updates).
|
||||
internal const val ALBUMS_FILTER = """
|
||||
FROM albums WHERE sourceId = :sourceId
|
||||
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||
AND (:search = ''
|
||||
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM album_search
|
||||
WHERE album_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), 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 ARTISTS_FILTER_BASE =
|
||||
"FROM artists WHERE sourceId = ? " +
|
||||
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
||||
"AND (? = '' " +
|
||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
||||
"WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
||||
"OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) "
|
||||
internal const val ALBUMS_FILTER_DOWNLOADED = """
|
||||
FROM albums WHERE sourceId = :sourceId
|
||||
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS 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')
|
||||
AND (:search = ''
|
||||
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM album_search
|
||||
WHERE album_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), 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 PLAYLISTS_FILTER_BASE =
|
||||
"FROM playlists WHERE sourceId = ? " +
|
||||
"AND (? = '' " +
|
||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " +
|
||||
"WHERE playlist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
||||
"OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) "
|
||||
internal const val ARTISTS_FILTER = """
|
||||
FROM artists WHERE sourceId = :sourceId
|
||||
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||
AND $ARTIST_CREDITED
|
||||
AND (:search = ''
|
||||
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM artist_search
|
||||
WHERE artist_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
|
||||
OR instr(lower(sortName), lower(:search)) > 0)))
|
||||
"""
|
||||
|
||||
internal fun albumsDefaultQuery(
|
||||
internal const val ARTISTS_FILTER_DOWNLOADED = """
|
||||
FROM artists WHERE sourceId = :sourceId
|
||||
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||
AND $ARTIST_CREDITED
|
||||
AND EXISTS (SELECT 1 FROM ($ARTIST_DOWNLOAD_ROWS) dl
|
||||
WHERE dl.id = artists.id AND dl.status = 'Completed')
|
||||
AND (:search = ''
|
||||
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM artist_search
|
||||
WHERE artist_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
|
||||
OR instr(lower(sortName), lower(:search)) > 0)))
|
||||
"""
|
||||
|
||||
// The downloaded list counts the albums that actually have a download so the number under an artist
|
||||
// matches the albums its detail screen lists.
|
||||
internal val ARTIST_COLUMNS_DOWNLOADED = """
|
||||
artists.sourceId, artists.id, artists.name,
|
||||
(SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)) AS albumCount,
|
||||
$ARTIST_COLUMNS_TAIL
|
||||
"""
|
||||
|
||||
internal const val PLAYLISTS_FILTER = """
|
||||
FROM playlists WHERE sourceId = :sourceId
|
||||
AND (:search = ''
|
||||
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM playlist_search
|
||||
WHERE playlist_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||
OR (length(:search) < $SEARCH_MIN_LENGTH AND instr(lower(name), lower(:search)) > 0))
|
||||
"""
|
||||
|
||||
internal const val PLAYLISTS_FILTER_DOWNLOADED = """
|
||||
FROM playlists WHERE sourceId = :sourceId
|
||||
AND EXISTS (SELECT 1 FROM playlist_songs ps
|
||||
JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId
|
||||
WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id AND sd.status = 'Completed')
|
||||
AND (:search = ''
|
||||
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM playlist_search
|
||||
WHERE playlist_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||
OR (length(:search) < $SEARCH_MIN_LENGTH AND instr(lower(name), lower(:search)) > 0))
|
||||
"""
|
||||
|
||||
// The downloaded list counts the songs that actually have a download so the number under a
|
||||
// playlist matches the songs its detail screen lists.
|
||||
internal const val PLAYLISTS_COLUMNS_DOWNLOADED = """
|
||||
playlists.sourceId, playlists.id, playlists.name, playlists.comment, playlists.coverArt,
|
||||
(SELECT COUNT(DISTINCT ps.songId)
|
||||
FROM playlist_songs ps
|
||||
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
|
||||
) AS songCount,
|
||||
playlists.created, playlists.changed, playlists.duration, playlists.owner, playlists.shared
|
||||
"""
|
||||
|
||||
internal fun albumsPageQuery(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
sort: AlbumSort,
|
||||
descending: Boolean,
|
||||
): RoomRawQuery {
|
||||
val order = albumOrder(sort, descending)
|
||||
return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
||||
}
|
||||
}
|
||||
downloaded: Boolean,
|
||||
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||
): RoomRawQuery =
|
||||
rawQuery(
|
||||
sql = "SELECT * ${if (downloaded) ALBUMS_FILTER_DOWNLOADED else ALBUMS_FILTER} ORDER BY ${albumOrder(
|
||||
sort,
|
||||
descending,
|
||||
sortNameMode,
|
||||
)}",
|
||||
bindings =
|
||||
mapOf(
|
||||
"sourceId" to sourceId,
|
||||
"starredFilter" to starredFilter,
|
||||
"search" to search,
|
||||
),
|
||||
)
|
||||
|
||||
internal fun artistsPageQuery(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
sort: ArtistSort,
|
||||
descending: Boolean,
|
||||
downloaded: Boolean,
|
||||
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||
): RoomRawQuery =
|
||||
rawQuery(
|
||||
sql =
|
||||
"SELECT ${if (downloaded) ARTIST_COLUMNS_DOWNLOADED else ARTIST_COLUMNS} " +
|
||||
"${if (downloaded) ARTISTS_FILTER_DOWNLOADED else ARTISTS_FILTER} " +
|
||||
"ORDER BY ${artistOrder(sort, descending, sortNameMode)}",
|
||||
bindings =
|
||||
mapOf(
|
||||
"sourceId" to sourceId,
|
||||
"starredFilter" to starredFilter,
|
||||
"search" to search,
|
||||
),
|
||||
)
|
||||
|
||||
internal fun playlistsPageQuery(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
sort: PlaylistSort,
|
||||
descending: Boolean,
|
||||
downloaded: Boolean,
|
||||
): RoomRawQuery =
|
||||
rawQuery(
|
||||
sql =
|
||||
"SELECT ${if (downloaded) PLAYLISTS_COLUMNS_DOWNLOADED else "*"} " +
|
||||
"${if (downloaded) PLAYLISTS_FILTER_DOWNLOADED else PLAYLISTS_FILTER} " +
|
||||
"ORDER BY ${playlistOrder(sort, descending)}",
|
||||
bindings =
|
||||
mapOf(
|
||||
"sourceId" to sourceId,
|
||||
"search" to search,
|
||||
),
|
||||
)
|
||||
|
||||
internal fun rediscoverAlbumsQuery(
|
||||
sourceId: Long,
|
||||
@@ -51,90 +169,126 @@ internal fun rediscoverAlbumsQuery(
|
||||
search: String,
|
||||
cutoff: Long,
|
||||
): RoomRawQuery =
|
||||
RoomRawQuery(
|
||||
"SELECT * $ALBUMS_FILTER_BASE AND played IS NOT NULL AND played < ? " +
|
||||
"ORDER BY played ASC, name COLLATE NOCASE, id",
|
||||
) { stmt ->
|
||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
||||
stmt.bindLong(11, cutoff)
|
||||
}
|
||||
rawQuery(
|
||||
sql =
|
||||
"SELECT * $ALBUMS_FILTER AND played IS NOT NULL AND played < :cutoff " +
|
||||
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
|
||||
bindings =
|
||||
mapOf(
|
||||
"sourceId" to sourceId,
|
||||
"starredFilter" to starredFilter,
|
||||
"search" to search,
|
||||
"cutoff" to cutoff,
|
||||
),
|
||||
)
|
||||
|
||||
private fun SQLiteStatement.bindAlbumFilters(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
// RoomRawQuery takes a positional bind function, but SQLite gives a repeated named parameter one
|
||||
// index, so the SQL can keep its names and only the first-appearance order needs binding.
|
||||
internal fun rawQuery(
|
||||
sql: String,
|
||||
bindings: Map<String, Any?>,
|
||||
): RoomRawQuery {
|
||||
val values =
|
||||
Regex(":(\\w+)")
|
||||
.findAll(sql)
|
||||
.map { it.groupValues[1] }
|
||||
.distinct()
|
||||
.map(bindings::getValue)
|
||||
.toList()
|
||||
return RoomRawQuery(sql) { statement ->
|
||||
values.forEachIndexed { index, value -> statement.bind(index + 1, value) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun SQLiteStatement.bind(
|
||||
index: Int,
|
||||
value: Any?,
|
||||
) {
|
||||
bindLong(1, sourceId)
|
||||
bindInt(2, starredFilter)
|
||||
bindInt(3, starredFilter)
|
||||
bindInt(4, starredFilter)
|
||||
bindText(5, search)
|
||||
bindText(6, search)
|
||||
bindText(7, search)
|
||||
bindText(8, search)
|
||||
bindText(9, search)
|
||||
bindText(10, search)
|
||||
}
|
||||
|
||||
internal fun artistsDefaultQuery(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
sort: ArtistSort,
|
||||
descending: Boolean,
|
||||
): RoomRawQuery {
|
||||
val order = artistOrder(sort, descending)
|
||||
return RoomRawQuery("SELECT * $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||
stmt.bindLong(1, sourceId)
|
||||
stmt.bindInt(2, starredFilter)
|
||||
stmt.bindInt(3, starredFilter)
|
||||
stmt.bindInt(4, starredFilter)
|
||||
stmt.bindText(5, search)
|
||||
stmt.bindText(6, search)
|
||||
stmt.bindText(7, search)
|
||||
stmt.bindText(8, search)
|
||||
stmt.bindText(9, search)
|
||||
when (value) {
|
||||
null -> bindNull(index)
|
||||
is Int -> bindLong(index, value.toLong())
|
||||
is Long -> bindLong(index, value)
|
||||
is String -> bindText(index, value)
|
||||
else -> error("Unsupported SQL parameter: ${value::class.qualifiedName}")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun playlistsDefaultQuery(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
sort: PlaylistSort,
|
||||
descending: Boolean,
|
||||
): RoomRawQuery {
|
||||
val order = playlistOrder(sort, descending)
|
||||
return RoomRawQuery("SELECT * $PLAYLISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||
stmt.bindLong(1, sourceId)
|
||||
stmt.bindText(2, search)
|
||||
stmt.bindText(3, search)
|
||||
stmt.bindText(4, search)
|
||||
stmt.bindText(5, search)
|
||||
stmt.bindText(6, search)
|
||||
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(
|
||||
sort: AlbumSort,
|
||||
descending: Boolean,
|
||||
mode: SortNameMode,
|
||||
): String =
|
||||
when (sort) {
|
||||
AlbumSort.Name -> if (descending) ALBUM_ORDER_BY_NAME_REVERSED else ALBUM_ORDER_BY_NAME
|
||||
AlbumSort.Artist -> if (descending) ALBUM_ORDER_BY_ARTIST_REVERSED else ALBUM_ORDER_BY_ARTIST
|
||||
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
|
||||
AlbumSort.Name -> {
|
||||
val name = albumNameSortKey(mode)
|
||||
if (descending) "$name DESC, id DESC" else "$name, id"
|
||||
}
|
||||
|
||||
AlbumSort.Artist -> {
|
||||
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(
|
||||
sort: ArtistSort,
|
||||
descending: Boolean,
|
||||
mode: SortNameMode,
|
||||
): String =
|
||||
when (sort) {
|
||||
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 -> {
|
||||
|
||||
@@ -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.
|
||||
private suspend fun createSearchIndex(
|
||||
connection: SQLiteConnection,
|
||||
@@ -416,6 +695,80 @@ private suspend fun createSearchIndex(
|
||||
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> =
|
||||
arrayOf(
|
||||
MIGRATION_1_2,
|
||||
@@ -444,4 +797,15 @@ val MIGRATIONS: Array<Migration> =
|
||||
MIGRATION_24_25,
|
||||
MIGRATION_25_26,
|
||||
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.Insert
|
||||
import androidx.room3.OnConflictStrategy
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Upsert
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.PlaybackCursor
|
||||
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 ARTIST_SONG_IDS =
|
||||
"(SELECT songId FROM ($CREDITED_SONG_ROWS) WHERE artistId = :artistId)"
|
||||
|
||||
private const val ARTIST_SONGS_SELECT =
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)"
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS"
|
||||
|
||||
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 =
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
@@ -55,7 +58,7 @@ interface QueueDao {
|
||||
@Query("SELECT * FROM playback_cursor WHERE id = 1")
|
||||
suspend fun cursor(): PlaybackCursor?
|
||||
|
||||
@Upsert
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun setCursor(cursor: PlaybackCursor)
|
||||
|
||||
@Query(
|
||||
@@ -82,8 +85,8 @@ interface QueueDao {
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(*) FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)",
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS",
|
||||
)
|
||||
suspend fun artistLength(
|
||||
sourceId: Long,
|
||||
@@ -190,8 +193,8 @@ interface QueueDao {
|
||||
|
||||
@Query(
|
||||
"SELECT songs.id FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)" +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS" +
|
||||
ARTIST_SONGS_ORDER,
|
||||
)
|
||||
suspend fun artistSongIds(
|
||||
@@ -211,14 +214,34 @@ interface QueueDao {
|
||||
): List<String>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.id IN (:ids)",
|
||||
"SELECT CASE WHEN EXISTS (SELECT 1 FROM songs WHERE sourceId = :sourceId AND albumId = :albumId AND id = :songId) " +
|
||||
"THEN (SELECT COUNT(*) FROM songs ranked CROSS JOIN songs target " +
|
||||
"WHERE ranked.sourceId = :sourceId AND ranked.albumId = :albumId " +
|
||||
"AND target.sourceId = :sourceId AND target.albumId = :albumId AND target.id = :songId " +
|
||||
"AND ((ranked.disc IS NULL AND target.disc IS NOT NULL) OR ranked.disc < target.disc OR " +
|
||||
"(ranked.disc IS target.disc AND ((ranked.track IS NULL AND target.track IS NOT NULL) OR ranked.track < target.track OR " +
|
||||
"(ranked.track IS target.track AND ranked.id < target.id))))) END",
|
||||
)
|
||||
suspend fun songsByIds(
|
||||
suspend fun albumRank(
|
||||
sourceId: Long,
|
||||
ids: List<String>,
|
||||
): List<AlbumSongItem>
|
||||
albumId: String,
|
||||
songId: String,
|
||||
): Long?
|
||||
|
||||
@Query(
|
||||
"SELECT CASE WHEN firstPosition IS NULL THEN NULL ELSE " +
|
||||
"(SELECT COUNT(*) - 1 FROM playlist_songs " +
|
||||
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
||||
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId AND position <= firstPosition) END " +
|
||||
"FROM (SELECT MIN(position) AS firstPosition FROM playlist_songs " +
|
||||
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
||||
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId AND songId = :songId)",
|
||||
)
|
||||
suspend fun playlistRank(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
songId: String,
|
||||
): Long?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM song_genres WHERE sourceId = :sourceId AND genre = :genre")
|
||||
suspend fun genreLength(
|
||||
@@ -231,7 +254,7 @@ interface QueueDao {
|
||||
"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 " +
|
||||
"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(
|
||||
sourceId: Long,
|
||||
@@ -240,17 +263,6 @@ interface QueueDao {
|
||||
limit: Int,
|
||||
): List<AlbumSongItem>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.id FROM song_genres " +
|
||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
||||
"ORDER BY songs.title COLLATE NOCASE, songs.id",
|
||||
)
|
||||
suspend fun genreSongIds(
|
||||
sourceId: Long,
|
||||
genre: String,
|
||||
): List<String>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM song_downloads WHERE sourceId = :sourceId AND status = 'Completed'")
|
||||
suspend fun downloadedLength(sourceId: Long): Long
|
||||
|
||||
@@ -261,7 +273,7 @@ interface QueueDao {
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"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(
|
||||
sourceId: Long,
|
||||
@@ -269,30 +281,16 @@ interface QueueDao {
|
||||
limit: Int,
|
||||
): List<AlbumSongItem>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
|
||||
"ORDER BY starred DESC, title COLLATE NOCASE, id",
|
||||
)
|
||||
suspend fun starredSongIds(sourceId: Long): List<String>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM song_downloads d " +
|
||||
"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 " +
|
||||
"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(
|
||||
sourceId: Long,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
): List<AlbumSongItem>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.id FROM song_downloads d " +
|
||||
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id",
|
||||
)
|
||||
suspend fun downloadedSongIds(sourceId: Long): List<String>
|
||||
}
|
||||
|
||||
@@ -29,6 +29,22 @@ interface SourcesDao {
|
||||
@Query("SELECT id FROM sources WHERE isActive = 1 LIMIT 1")
|
||||
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
|
||||
suspend fun insertSource(source: Source): Long
|
||||
|
||||
|
||||
@@ -4,8 +4,11 @@ import androidx.room3.ColumnTypeConverters
|
||||
import androidx.room3.Database
|
||||
import androidx.room3.RoomDatabase
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumArtistRef
|
||||
import com.subtracks.data.model.AlbumSearch
|
||||
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.ArtworkSeed
|
||||
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.QueueKindConverter
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongArtistRef
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.SongGenre
|
||||
import com.subtracks.data.model.SongSearch
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.model.StringListConverter
|
||||
import com.subtracks.data.model.SubsonicSource
|
||||
import com.subtracks.data.model.UpNextEntry
|
||||
|
||||
@@ -32,15 +37,18 @@ import com.subtracks.data.model.UpNextEntry
|
||||
Source::class,
|
||||
SubsonicSource::class,
|
||||
Artist::class,
|
||||
ArtistBio::class,
|
||||
ArtistSearch::class,
|
||||
Album::class,
|
||||
AlbumSearch::class,
|
||||
Disc::class,
|
||||
AlbumArtistRef::class,
|
||||
Playlist::class,
|
||||
PlaylistSearch::class,
|
||||
PlaylistSong::class,
|
||||
Song::class,
|
||||
SongGenre::class,
|
||||
SongArtistRef::class,
|
||||
SongSearch::class,
|
||||
QueueEntry::class,
|
||||
UpNextEntry::class,
|
||||
@@ -49,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry
|
||||
SongDownload::class,
|
||||
PendingAction::class,
|
||||
],
|
||||
version = 27,
|
||||
version = 38,
|
||||
exportSchema = true,
|
||||
)
|
||||
@ColumnTypeConverters(
|
||||
@@ -57,6 +65,8 @@ import com.subtracks.data.model.UpNextEntry
|
||||
DownloadStatusConverter::class,
|
||||
DownloadErrorConverter::class,
|
||||
PendingActionKindConverter::class,
|
||||
ArtistRefsConverter::class,
|
||||
StringListConverter::class,
|
||||
)
|
||||
abstract class SubtracksDatabase : RoomDatabase() {
|
||||
abstract fun libraryDao(): LibraryDao
|
||||
|
||||
@@ -2,8 +2,9 @@ package com.subtracks.data.download
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.core.net.toUri
|
||||
import com.subtracks.UserAgent
|
||||
import com.subtracks.data.model.DownloadError
|
||||
|
||||
class SystemDownloadEngine(
|
||||
@@ -12,10 +13,11 @@ class SystemDownloadEngine(
|
||||
override fun enqueue(request: EngineRequest): Long {
|
||||
val download =
|
||||
DownloadManager
|
||||
.Request(Uri.parse(request.uri))
|
||||
.Request(request.uri.toUri())
|
||||
.setTitle(request.title)
|
||||
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path)
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
|
||||
.addRequestHeader("User-Agent", UserAgent.value)
|
||||
// Wi-Fi only unless the preference allows a metered network, and roaming counts as
|
||||
// one of those; the platform otherwise permits it by default.
|
||||
.setAllowedOverMetered(request.allowMetered)
|
||||
|
||||
@@ -10,3 +10,8 @@ fun coverArtKey(
|
||||
coverArt: String,
|
||||
thumbnail: Boolean,
|
||||
): 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,
|
||||
) {
|
||||
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 BulkDownloadAction { Download, Cancel, Delete }
|
||||
@@ -94,19 +99,24 @@ data class DownloadedSong(
|
||||
val size: Long = 0,
|
||||
val bytes: Long = 0,
|
||||
val total: Long = 0,
|
||||
val artistSort: String = artistName.orEmpty(),
|
||||
val albumSort: String = albumName.orEmpty(),
|
||||
val songSort: String = title,
|
||||
) {
|
||||
val progress: Float?
|
||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||
get() = fractionOf(bytes, total)
|
||||
}
|
||||
|
||||
data class DownloadArtwork(
|
||||
val sourceId: Long,
|
||||
val albumId: String?,
|
||||
val artistId: String?,
|
||||
val albumArtistId: String?,
|
||||
val albumRow: String?,
|
||||
val artistRow: String?,
|
||||
val albumCoverArt: String?,
|
||||
val artistCoverArt: String?,
|
||||
val albumArtistCoverArt: String?,
|
||||
) {
|
||||
val hasMissingLibraryRow: Boolean
|
||||
get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
|
||||
|
||||
@@ -18,6 +18,7 @@ data class Source(
|
||||
val address: String,
|
||||
val isActive: Boolean = false,
|
||||
val createdAt: Long,
|
||||
@ColumnInfo(defaultValue = "1") val creditsStale: Boolean = true,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
@@ -39,6 +40,11 @@ data class SubsonicSource(
|
||||
val useTokenAuth: Boolean = true,
|
||||
)
|
||||
|
||||
data class ArtistRef(
|
||||
val id: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "artists",
|
||||
primaryKeys = ["sourceId", "id"],
|
||||
@@ -52,25 +58,26 @@ data class SubsonicSource(
|
||||
],
|
||||
indices = [
|
||||
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(
|
||||
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],
|
||||
),
|
||||
Index(
|
||||
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],
|
||||
),
|
||||
Index(
|
||||
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],
|
||||
),
|
||||
Index(
|
||||
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],
|
||||
),
|
||||
],
|
||||
@@ -82,8 +89,32 @@ data class Artist(
|
||||
val albumCount: Long,
|
||||
val starred: Long?,
|
||||
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,
|
||||
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(
|
||||
@@ -100,31 +131,33 @@ data class Artist(
|
||||
indices = [
|
||||
Index("sourceId"),
|
||||
Index("sourceId", "artistId"),
|
||||
Index(name = "index_albums_name", value = ["sourceId", "name", "id"]),
|
||||
Index(name = "index_albums_artist", value = ["sourceId", "albumArtist", "year", "name", "id"]),
|
||||
Index(name = "index_albums_name", value = ["sourceId", "sortName", "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(
|
||||
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],
|
||||
),
|
||||
Index(
|
||||
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],
|
||||
),
|
||||
Index(
|
||||
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],
|
||||
),
|
||||
Index(
|
||||
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],
|
||||
),
|
||||
Index(
|
||||
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],
|
||||
),
|
||||
],
|
||||
@@ -144,7 +177,26 @@ data class Album(
|
||||
@Ignore val discTitles: Map<Long, String> = emptyMap(),
|
||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||
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(
|
||||
tableName = "discs",
|
||||
@@ -202,6 +254,8 @@ data class Playlist(
|
||||
val created: Long,
|
||||
@ColumnInfo(defaultValue = "0") val changed: Long = 0,
|
||||
@ColumnInfo(defaultValue = "0") val duration: Long = 0,
|
||||
val owner: String? = null,
|
||||
@ColumnInfo(defaultValue = "0") val shared: Boolean = false,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
@@ -243,8 +297,8 @@ data class PlaylistSong(
|
||||
),
|
||||
Index(
|
||||
name = "index_songs_starred",
|
||||
value = ["sourceId", "starred", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC],
|
||||
value = ["sourceId", "starred", "sortName", "id"],
|
||||
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 playCount: Long = 0,
|
||||
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(),
|
||||
)
|
||||
@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(
|
||||
tableName = "song_genres",
|
||||
@@ -287,6 +357,48 @@ data class SongGenre(
|
||||
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 }
|
||||
|
||||
@Entity(
|
||||
@@ -434,6 +546,8 @@ data class AlbumSearch(
|
||||
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
||||
val name: String,
|
||||
val albumArtist: String?,
|
||||
val sortName: String,
|
||||
val albumArtistSort: String,
|
||||
)
|
||||
|
||||
@Entity(tableName = "artist_search")
|
||||
@@ -441,6 +555,7 @@ data class AlbumSearch(
|
||||
data class ArtistSearch(
|
||||
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
||||
val name: String,
|
||||
val sortName: String,
|
||||
)
|
||||
|
||||
@Entity(tableName = "playlist_search")
|
||||
@@ -456,6 +571,8 @@ data class SongSearch(
|
||||
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
||||
val title: String,
|
||||
val artist: String?,
|
||||
val sortName: String,
|
||||
val artistSort: String,
|
||||
)
|
||||
|
||||
class QueueKindConverter {
|
||||
@@ -474,3 +591,53 @@ class PendingActionKindConverter {
|
||||
fun toPendingActionKind(value: String): PendingActionKind =
|
||||
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.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
@@ -14,6 +16,8 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
enum class SortNameMode { SortName, Name, NonLatin }
|
||||
|
||||
enum class AlbumSort { Name, Artist, Year, Added, Starred, Frequent, Recent }
|
||||
|
||||
enum class ArtistSort { Name, AlbumCount, Starred, Frequent, Recent }
|
||||
@@ -48,6 +52,17 @@ data class StreamQuality(
|
||||
|
||||
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 SongTapBehavior { InOrder, KeepShuffle, Shuffle }
|
||||
|
||||
enum class ReplayGainMode { Off, Track, Album, Auto }
|
||||
|
||||
data class ArtworkSeedValue(
|
||||
val cacheKey: String,
|
||||
val primary: Int,
|
||||
@@ -105,6 +120,47 @@ class UserPreferences(
|
||||
|
||||
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 songTapBehavior(): Flow<SongTapBehavior> =
|
||||
store.data.map { prefs ->
|
||||
prefs[songTapBehaviorKey]?.let { stored ->
|
||||
SongTapBehavior.entries.firstOrNull { it.name == stored }
|
||||
} ?: SongTapBehavior.InOrder
|
||||
}
|
||||
|
||||
suspend fun setSongTapBehavior(behavior: SongTapBehavior) {
|
||||
store.edit { prefs -> prefs[songTapBehaviorKey] = behavior.name }
|
||||
}
|
||||
|
||||
private val songTapBehaviorKey = stringPreferencesKey("song_tap_behavior")
|
||||
|
||||
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 }
|
||||
|
||||
suspend fun setScrobbling(enabled: Boolean) {
|
||||
@@ -113,6 +169,47 @@ class UserPreferences(
|
||||
|
||||
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 }
|
||||
|
||||
suspend fun setOfflineMode(enabled: Boolean) {
|
||||
@@ -121,6 +218,14 @@ class UserPreferences(
|
||||
|
||||
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? {
|
||||
val prefs = store.data.first()
|
||||
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.EngineStatus
|
||||
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.DownloadError
|
||||
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.ListDownloadStatus
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.artistImageKey
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -42,6 +46,7 @@ import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class DownloadRepository(
|
||||
private val db: SubtracksDatabase,
|
||||
@@ -62,6 +67,8 @@ class DownloadRepository(
|
||||
private var started = false
|
||||
|
||||
private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap())
|
||||
private val attemptedBios = ConcurrentHashMap.newKeySet<Pair<Long, String>>()
|
||||
private val failedArtistImages = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
fun start() {
|
||||
if (started) return
|
||||
@@ -79,7 +86,10 @@ class DownloadRepository(
|
||||
}
|
||||
scope.launch {
|
||||
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()
|
||||
file(sourceId, songId).delete()
|
||||
db.downloadDao().upsert(SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued))
|
||||
Log.i("download", "queued song=$songId (source $sourceId)")
|
||||
ensurePolling()
|
||||
return true
|
||||
}
|
||||
@@ -336,11 +347,15 @@ class DownloadRepository(
|
||||
|
||||
private suspend fun removeRows(rows: List<SongDownload>) {
|
||||
if (rows.isEmpty()) return
|
||||
Log.i("download", "removed ${rows.size} download(s)")
|
||||
val engineIds = rows.mapNotNull { it.engineId }
|
||||
if (engineIds.isNotEmpty()) engine.cancel(engineIds)
|
||||
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
|
||||
group.forEach { file(sourceId, it.songId).delete() }
|
||||
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())
|
||||
}
|
||||
@@ -363,6 +378,7 @@ class DownloadRepository(
|
||||
.mapNotNull { it.engineId }
|
||||
.let(engine::cancel)
|
||||
db.downloadDao().deleteSource(sourceId)
|
||||
db.libraryDao().clearDownloadedArtistArt(sourceId)
|
||||
dir(sourceId).deleteRecursively()
|
||||
}
|
||||
}
|
||||
@@ -400,14 +416,18 @@ class DownloadRepository(
|
||||
private suspend fun fetchArtwork(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
artistIds: MutableSet<String>,
|
||||
) {
|
||||
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 =
|
||||
(
|
||||
listOfNotNull(
|
||||
song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it)?.coverArt },
|
||||
song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it)?.coverArt },
|
||||
) + db.downloadDao().playlistCoversForSong(sourceId, songId)
|
||||
listOfNotNull(album?.coverArt) +
|
||||
db.downloadDao().playlistCoversForSong(sourceId, songId)
|
||||
).distinct()
|
||||
coverArt.forEach { cover ->
|
||||
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(
|
||||
sourceId: Long,
|
||||
coverArt: String,
|
||||
@@ -449,11 +530,15 @@ class DownloadRepository(
|
||||
.mapValues { (sourceId, rows) ->
|
||||
if (rows.any { it.hasMissingLibraryRow }) return@mapValues null
|
||||
val covers =
|
||||
rows.flatMap { row -> listOfNotNull(row.albumCoverArt, row.artistCoverArt) } +
|
||||
playlistCovers[sourceId].orEmpty()
|
||||
covers
|
||||
.flatMap { cover -> listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) } }
|
||||
.toSet()
|
||||
rows.flatMap { row ->
|
||||
listOfNotNull(row.albumCoverArt, row.artistCoverArt, row.albumArtistCoverArt)
|
||||
} + playlistCovers[sourceId].orEmpty()
|
||||
val artistImages = rows.flatMap { row -> listOfNotNull(row.artistId, row.albumArtistId) }
|
||||
(
|
||||
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)) }
|
||||
file(sourceId, songId).delete()
|
||||
db.downloadDao().delete(sourceId, songId)
|
||||
if (row.status == DownloadStatus.Completed) refreshDownloadedArtistArt(sourceId, listOf(songId))
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -531,11 +617,12 @@ class DownloadRepository(
|
||||
sourceId: Long,
|
||||
songIds: List<String>,
|
||||
) {
|
||||
val artistIds = mutableSetOf<String>()
|
||||
try {
|
||||
withTimeoutOrNull(ART_TIMEOUT_MS) {
|
||||
songIds.forEach { songId ->
|
||||
// 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) {
|
||||
@@ -543,6 +630,7 @@ class DownloadRepository(
|
||||
} catch (_: Exception) {
|
||||
// Artwork is best effort and must never disturb a download that is already running.
|
||||
}
|
||||
fetchArtistBiosSafely(sourceId, artistIds)
|
||||
}
|
||||
|
||||
private suspend fun apply(
|
||||
@@ -581,6 +669,19 @@ class DownloadRepository(
|
||||
},
|
||||
)
|
||||
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(
|
||||
@@ -590,6 +691,20 @@ class DownloadRepository(
|
||||
row.engineId?.let { engine.cancel(listOf(it)) }
|
||||
file(row.sourceId, row.songId).delete()
|
||||
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() {
|
||||
@@ -658,5 +773,6 @@ class DownloadRepository(
|
||||
const val IN_FLIGHT_LIMIT = 8
|
||||
const val DELETE_CHUNK = 900
|
||||
const val ART_TIMEOUT_MS = 60_000L
|
||||
const val BIO_TIMEOUT_MS = 30_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,15 @@ import androidx.paging.PagingSource
|
||||
import com.subtracks.R
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.albumsDefaultQuery
|
||||
import com.subtracks.data.db.artistsDefaultQuery
|
||||
import com.subtracks.data.db.playlistsDefaultQuery
|
||||
import com.subtracks.data.db.albumsPageQuery
|
||||
import com.subtracks.data.db.artistsPageQuery
|
||||
import com.subtracks.data.db.playlistsPageQuery
|
||||
import com.subtracks.data.db.rediscoverAlbumsQuery
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
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.Playlist
|
||||
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.PlaylistSort
|
||||
import com.subtracks.data.prefs.StarredFilter
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class LibraryRepository(
|
||||
private val db: SubtracksDatabase,
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val serverActions: ServerActionSink,
|
||||
private val userPreferences: UserPreferences,
|
||||
private val showMessage: (UiMessage) -> Unit = {},
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
) {
|
||||
@@ -106,12 +116,18 @@ class LibraryRepository(
|
||||
fun searchAlbums(
|
||||
sourceId: Long,
|
||||
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(
|
||||
sourceId: Long,
|
||||
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(
|
||||
sourceId: Long,
|
||||
@@ -121,7 +137,10 @@ class LibraryRepository(
|
||||
fun searchSongs(
|
||||
sourceId: Long,
|
||||
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) }
|
||||
|
||||
@@ -135,9 +154,9 @@ class LibraryRepository(
|
||||
suspend fun genreSongOrdinal(
|
||||
sourceId: Long,
|
||||
genre: String,
|
||||
title: String,
|
||||
sortName: String,
|
||||
id: String,
|
||||
): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, title, id)
|
||||
): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, sortName, id)
|
||||
|
||||
suspend fun downloadedSongOrdinal(
|
||||
sourceId: Long,
|
||||
@@ -173,69 +192,20 @@ class LibraryRepository(
|
||||
search: String,
|
||||
downloaded: Boolean,
|
||||
): Flow<PagingData<Album>> =
|
||||
pager(40) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
||||
} else {
|
||||
val downloadedFilter = 1
|
||||
when (sort) {
|
||||
AlbumSort.Name -> {
|
||||
if (descending) {
|
||||
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Artist -> {
|
||||
if (descending) {
|
||||
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Year -> {
|
||||
if (descending) {
|
||||
dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Added -> {
|
||||
if (descending) {
|
||||
dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Starred -> {
|
||||
if (descending) {
|
||||
dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Frequent -> {
|
||||
if (descending) {
|
||||
dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Recent -> {
|
||||
if (descending) {
|
||||
dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
pager(40, enablePlaceholders = true) {
|
||||
val dao = db.libraryDao()
|
||||
val query =
|
||||
albumsPageQuery(
|
||||
sourceId = sourceId,
|
||||
starredFilter = starred.ordinal,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = downloaded,
|
||||
sortNameMode = mode,
|
||||
)
|
||||
if (downloaded) dao.downloadedAlbumsPaging(query) else dao.albumsPaging(query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,53 +217,20 @@ class LibraryRepository(
|
||||
search: String,
|
||||
downloaded: Boolean,
|
||||
): Flow<PagingData<Artist>> =
|
||||
pager(60) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
||||
} else {
|
||||
val downloadedFilter = 1
|
||||
when (sort) {
|
||||
ArtistSort.Name -> {
|
||||
if (descending) {
|
||||
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.AlbumCount -> {
|
||||
if (descending) {
|
||||
dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Starred -> {
|
||||
if (descending) {
|
||||
dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Frequent -> {
|
||||
if (descending) {
|
||||
dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Recent -> {
|
||||
if (descending) {
|
||||
dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
pager(60) {
|
||||
val dao = db.libraryDao()
|
||||
val query =
|
||||
artistsPageQuery(
|
||||
sourceId = sourceId,
|
||||
starredFilter = starred.ordinal,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = downloaded,
|
||||
sortNameMode = mode,
|
||||
)
|
||||
if (downloaded) dao.downloadedArtistsPaging(query) else dao.artistsPaging(query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,36 +243,15 @@ class LibraryRepository(
|
||||
): Flow<PagingData<Playlist>> =
|
||||
pager(40) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.playlistsPaging(playlistsDefaultQuery(sourceId, search, sort, descending))
|
||||
} else {
|
||||
val downloadedFilter = 1
|
||||
when (sort) {
|
||||
PlaylistSort.Name -> {
|
||||
if (descending) {
|
||||
dao.playlistsByNameReversed(sourceId, search, downloadedFilter)
|
||||
} else {
|
||||
dao.playlistsByName(sourceId, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
PlaylistSort.Added -> {
|
||||
if (descending) {
|
||||
dao.playlistsByAddedReversed(sourceId, search, downloadedFilter)
|
||||
} else {
|
||||
dao.playlistsByAdded(sourceId, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
PlaylistSort.Updated -> {
|
||||
if (descending) {
|
||||
dao.playlistsByUpdatedReversed(sourceId, search, downloadedFilter)
|
||||
} else {
|
||||
dao.playlistsByUpdated(sourceId, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val query =
|
||||
playlistsPageQuery(
|
||||
sourceId = sourceId,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = downloaded,
|
||||
)
|
||||
if (downloaded) dao.downloadedPlaylistsPaging(query) else dao.playlistsPaging(query)
|
||||
}
|
||||
|
||||
fun album(
|
||||
@@ -353,6 +269,36 @@ class LibraryRepository(
|
||||
artistId: String,
|
||||
): 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(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
@@ -375,12 +321,24 @@ class LibraryRepository(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
downloaded: Boolean = false,
|
||||
): Flow<List<Album>> =
|
||||
if (downloaded) {
|
||||
db.libraryDao().albumsForArtistDownloaded(sourceId, artistId)
|
||||
} else {
|
||||
db.libraryDao().albumsForArtist(sourceId, artistId)
|
||||
): Flow<ArtistAlbums> {
|
||||
val albums =
|
||||
if (downloaded) {
|
||||
db.libraryDao().albumsForArtistDownloaded(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(
|
||||
sourceId: Long,
|
||||
@@ -402,10 +360,7 @@ class LibraryRepository(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
songId: String,
|
||||
): Long {
|
||||
val index = db.queueDao().albumSongIds(sourceId, albumId).indexOf(songId)
|
||||
return if (index < 0) 0L else index.toLong()
|
||||
}
|
||||
): Long = db.queueDao().albumRank(sourceId, albumId, songId) ?: 0L
|
||||
|
||||
fun playlist(
|
||||
sourceId: Long,
|
||||
@@ -464,14 +419,17 @@ class LibraryRepository(
|
||||
}
|
||||
val previous = starredValue(sourceId, type, id)
|
||||
updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null)
|
||||
val action = if (starred) "star" else "unstar"
|
||||
return try {
|
||||
serverActions.setStar(type, id, starred)
|
||||
Log.i("star", "$action ${type.name.lowercase()} $id")
|
||||
Result.success(Unit)
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (failure: Exception) {
|
||||
withContext(NonCancellable) { updateStarred(sourceId, type, id, previous) }
|
||||
showMessage(UiMessage(R.string.star_update_failed))
|
||||
Log.w("star", "failed to $action ${type.name.lowercase()} $id", failure)
|
||||
Result.failure(failure)
|
||||
}
|
||||
}
|
||||
@@ -503,10 +461,11 @@ class LibraryRepository(
|
||||
|
||||
private fun <T : Any> pager(
|
||||
pageSize: Int,
|
||||
enablePlaceholders: Boolean = false,
|
||||
source: () -> PagingSource<Int, T>,
|
||||
): Flow<PagingData<T>> =
|
||||
Pager(
|
||||
config = PagingConfig(pageSize = pageSize, enablePlaceholders = false),
|
||||
config = PagingConfig(pageSize = pageSize, enablePlaceholders = enablePlaceholders),
|
||||
pagingSourceFactory = source,
|
||||
).flow
|
||||
}
|
||||
@@ -515,4 +474,12 @@ const val REDISCOVER_WINDOW_MS = 60L * 24 * 60 * 60 * 1000
|
||||
|
||||
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
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.subtracks.data.model.PendingActionKind
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.data.source.subsonic.SubsonicException
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -29,8 +30,13 @@ class PendingServerActionSink(
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
var lastReady: Boolean? = null
|
||||
combine(offlinePreferences, networkAvailable) { isOffline, online -> !isOffline && online }
|
||||
.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
|
||||
// flag's default at cold start, so wait for that flag to agree before replaying.
|
||||
if (ready) {
|
||||
@@ -96,12 +102,14 @@ class PendingServerActionSink(
|
||||
for (action in dao.pending(sourceId)) {
|
||||
try {
|
||||
deliver(action)
|
||||
Log.i("net", "replayed ${action.kind.name.lowercase()} ${action.targetId}")
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (failure: Exception) {
|
||||
// Still unreachable: keep this and the remaining actions for the next attempt.
|
||||
if (isRetryable(failure)) return
|
||||
// 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)
|
||||
}
|
||||
@@ -139,6 +147,7 @@ class PendingServerActionSink(
|
||||
db.pendingActionDao().replace(
|
||||
PendingAction(sourceId = sourceId, kind = kind, targetId = targetId, starType = starType, time = time),
|
||||
)
|
||||
Log.i("net", "queued ${kind.name.lowercase()} $targetId")
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -164,6 +164,8 @@ data class QueueSnapshot(
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -207,6 +209,202 @@ data class Reanchored(
|
||||
val changed: Boolean,
|
||||
)
|
||||
|
||||
private interface QueueKindResolver {
|
||||
fun supportsKeyset(key: OrderKey): Boolean = false
|
||||
|
||||
suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
): Long
|
||||
|
||||
suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
): List<SongItem>
|
||||
|
||||
suspend fun rowsFrom(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
): List<SongItem>? = null
|
||||
|
||||
suspend fun rowsBefore(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
): List<SongItem>? = null
|
||||
|
||||
suspend fun rank(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
songId: String,
|
||||
): Long? = null
|
||||
}
|
||||
|
||||
private val queueKindResolvers =
|
||||
mapOf<QueueKind, QueueKindResolver>(
|
||||
QueueKind.Song to
|
||||
object : QueueKindResolver {
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.songLength(entry.sourceId, entry.refId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.song(entry.sourceId, entry.refId, offset, limit)
|
||||
|
||||
override suspend fun rank(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
songId: String,
|
||||
) = if (entry.refId == songId && length(dao, entry) > 0) 0L else null
|
||||
},
|
||||
QueueKind.Playlist to
|
||||
object : QueueKindResolver {
|
||||
override fun supportsKeyset(key: OrderKey) = key.position != null
|
||||
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.playlistLength(entry.sourceId, entry.refId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
|
||||
override suspend fun rowsFrom(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
) = dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||
|
||||
override suspend fun rowsBefore(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
) = dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||
|
||||
override suspend fun rank(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
songId: String,
|
||||
) = dao.playlistRank(entry.sourceId, entry.refId, songId)
|
||||
},
|
||||
QueueKind.Album to
|
||||
object : QueueKindResolver {
|
||||
override fun supportsKeyset(key: OrderKey) = key.disc != null && key.track != null
|
||||
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.albumLength(entry.sourceId, entry.refId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
|
||||
override suspend fun rowsFrom(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
) = dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||
|
||||
override suspend fun rowsBefore(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
) = dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||
|
||||
override suspend fun rank(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
songId: String,
|
||||
) = dao.albumRank(entry.sourceId, entry.refId, songId)
|
||||
},
|
||||
QueueKind.Artist to
|
||||
object : QueueKindResolver {
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.artistLength(entry.sourceId, entry.refId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.artistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
},
|
||||
QueueKind.Genre to
|
||||
object : QueueKindResolver {
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.genreLength(entry.sourceId, entry.refId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.genreSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
},
|
||||
QueueKind.Downloaded to
|
||||
object : QueueKindResolver {
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.downloadedLength(entry.sourceId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.downloadedSongs(entry.sourceId, offset, limit)
|
||||
},
|
||||
QueueKind.Starred to
|
||||
object : QueueKindResolver {
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.starredLength(entry.sourceId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.starredSongs(entry.sourceId, offset, limit)
|
||||
},
|
||||
)
|
||||
|
||||
private fun resolverFor(kind: QueueKind): QueueKindResolver = queueKindResolvers.getValue(kind)
|
||||
|
||||
class QueueRepository(
|
||||
private val db: SubtracksDatabase,
|
||||
) {
|
||||
@@ -214,12 +412,6 @@ class QueueRepository(
|
||||
|
||||
private val queueVersion = AtomicLong()
|
||||
|
||||
@Volatile
|
||||
private var cachedFlatIds: List<String>? = null
|
||||
|
||||
@Volatile
|
||||
private var cachedFlatVersion = -1L
|
||||
|
||||
@Volatile
|
||||
private var shuffleRemoved: ShuffleState? = null
|
||||
|
||||
@@ -233,6 +425,9 @@ class QueueRepository(
|
||||
internal var offsetSeeks = 0L
|
||||
private set
|
||||
|
||||
internal var resolvedRowCount = 0L
|
||||
private set
|
||||
|
||||
private val cursorMutex = Mutex()
|
||||
|
||||
fun playlistEntry(
|
||||
@@ -295,8 +490,8 @@ class QueueRepository(
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
// An explicit song entry in the middle of a queue is a reordered context track, so
|
||||
// label it by its album rather than losing the context name.
|
||||
// A song entry can be a single or a track reordered out of a playlist, artist or
|
||||
// genre, so label it by its album rather than losing the context name.
|
||||
QueueKind.Song -> {
|
||||
library
|
||||
.song(sourceId, refId)
|
||||
@@ -386,7 +581,6 @@ class QueueRepository(
|
||||
}
|
||||
|
||||
fun invalidateLibraryCache() {
|
||||
cachedFlatIds = null
|
||||
lengthCache.clear()
|
||||
keyCache.clear()
|
||||
shuffleRemoved = null
|
||||
@@ -407,8 +601,8 @@ class QueueRepository(
|
||||
snapshot: QueueSnapshot,
|
||||
songId: String,
|
||||
): Long? {
|
||||
val upIndex = idsFor(snapshot.upNext).indexOf(songId)
|
||||
if (upIndex >= 0) return snapshot.anchorPlay + 1 + upIndex
|
||||
val upIndex = flatIndexOf(snapshot.upNext, songId)
|
||||
if (upIndex != null) return snapshot.anchorPlay + 1 + upIndex
|
||||
return combinedContextIndexOf(snapshot, songId)
|
||||
}
|
||||
|
||||
@@ -416,9 +610,8 @@ class QueueRepository(
|
||||
snapshot: QueueSnapshot,
|
||||
songId: String,
|
||||
): Long? {
|
||||
val flat = flatIds(snapshot).indexOf(songId)
|
||||
if (flat < 0) return null
|
||||
return snapshot.playContext(flat.toLong())?.let { snapshot.combined(it) }
|
||||
val flat = flatIndexOf(snapshot.entries, songId) ?: return null
|
||||
return snapshot.playContext(flat)?.let { snapshot.combined(it) }
|
||||
}
|
||||
|
||||
suspend fun range(
|
||||
@@ -581,9 +774,9 @@ class QueueRepository(
|
||||
val toUpNext = snapshot.isUpNext(to)
|
||||
if (fromUpNext && toUpNext) {
|
||||
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 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 })
|
||||
return true
|
||||
}
|
||||
@@ -597,7 +790,7 @@ class QueueRepository(
|
||||
val located = snapshot.locateContext(playFrom) ?: return false
|
||||
val song = itemAt(snapshot, from) ?: return false
|
||||
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 })
|
||||
write(compact(insertEntry(removed, insertAt, moved)).map { it.entry })
|
||||
adjustAnchorOnMove(playFrom, insertAt)
|
||||
@@ -631,7 +824,6 @@ class QueueRepository(
|
||||
}
|
||||
}
|
||||
queueVersion.incrementAndGet()
|
||||
cachedFlatIds = null
|
||||
}
|
||||
|
||||
private suspend fun writeUpNext(entries: List<QueueEntry>) {
|
||||
@@ -648,34 +840,40 @@ class QueueRepository(
|
||||
queueVersion.incrementAndGet()
|
||||
}
|
||||
|
||||
private suspend fun flatIds(snapshot: QueueSnapshot): List<String> {
|
||||
val cached = cachedFlatIds
|
||||
if (cached != null && cachedFlatVersion == snapshot.version && cached.size.toLong() == snapshot.contextSize) return cached
|
||||
val ids = idsFor(snapshot.entries)
|
||||
cachedFlatIds = ids
|
||||
cachedFlatVersion = snapshot.version
|
||||
return ids
|
||||
private suspend fun flatIndexOf(
|
||||
entries: List<ResolvedQueueEntry>,
|
||||
songId: String,
|
||||
): Long? {
|
||||
var start = 0L
|
||||
for (resolved in entries) {
|
||||
val entry = resolved.entry
|
||||
val rank = resolverFor(entry.kind).rank(dao, entry, songId)
|
||||
if (rank != null && rank >= entry.offset && rank < entry.offset + resolved.length) {
|
||||
return start + rank - entry.offset
|
||||
}
|
||||
val fallback = fallbackRank(entry, resolved.length, songId)
|
||||
if (fallback != null) return start + fallback - entry.offset
|
||||
start += resolved.length
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun idsFor(resolved: List<ResolvedQueueEntry>): List<String> {
|
||||
val ids = ArrayList<String>()
|
||||
for (entry in resolved) {
|
||||
val ref = entry.entry
|
||||
val entryIds =
|
||||
when (ref.kind) {
|
||||
QueueKind.Song -> listOf(ref.refId)
|
||||
QueueKind.Album -> dao.albumSongIds(ref.sourceId, ref.refId)
|
||||
QueueKind.Playlist -> dao.playlistSongIds(ref.sourceId, ref.refId)
|
||||
QueueKind.Artist -> dao.artistSongIds(ref.sourceId, ref.refId)
|
||||
QueueKind.Genre -> dao.genreSongIds(ref.sourceId, ref.refId)
|
||||
QueueKind.Downloaded -> dao.downloadedSongIds(ref.sourceId)
|
||||
QueueKind.Starred -> dao.starredSongIds(ref.sourceId)
|
||||
}
|
||||
val from = ref.offset.toInt().coerceIn(0, entryIds.size)
|
||||
val to = (ref.offset + entry.length).toInt().coerceIn(from, entryIds.size)
|
||||
ids.addAll(entryIds.subList(from, to))
|
||||
private suspend fun fallbackRank(
|
||||
entry: QueueEntry,
|
||||
length: Long,
|
||||
songId: String,
|
||||
): Long? {
|
||||
var offset = entry.offset
|
||||
val end = entry.offset + length
|
||||
while (offset < end) {
|
||||
val limit = minOf(QUEUE_CHUNK.toLong(), end - offset).toInt()
|
||||
val batch = rows(entry, offset, limit)
|
||||
val index = batch.indexOfFirst { it.song.id == songId }
|
||||
if (index >= 0) return offset + index
|
||||
if (batch.size < limit) return null
|
||||
offset += batch.size
|
||||
}
|
||||
return ids
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun contextPlayRange(
|
||||
@@ -686,21 +884,11 @@ class QueueRepository(
|
||||
if (first > last) return emptyList()
|
||||
val items = mutableListOf<QueueWindowItem>()
|
||||
if (snapshot.shuffled) {
|
||||
val sourceId =
|
||||
snapshot.entries
|
||||
.firstOrNull()
|
||||
?.entry
|
||||
?.sourceId ?: return emptyList()
|
||||
val ids = flatIds(snapshot)
|
||||
val requested = mutableListOf<Pair<Long, String>>()
|
||||
for (play in first..last) {
|
||||
val flat = snapshot.flatContext(play) ?: continue
|
||||
val id = ids.getOrNull(flat.toInt()) ?: continue
|
||||
requested += play to id
|
||||
val located = snapshot.locateContext(play) ?: continue
|
||||
val item = rows(located.first, located.first.offset + located.second, 1).firstOrNull() ?: continue
|
||||
items += QueueWindowItem(snapshot.combined(play), item, playlistPosition = item.playlistPosition)
|
||||
}
|
||||
if (requested.isEmpty()) return emptyList()
|
||||
val byId = dao.songsByIds(sourceId, requested.map { it.second }.distinct()).associateBy { it.song.id }
|
||||
requested.forEach { (play, id) -> byId[id]?.let { items += QueueWindowItem(snapshot.combined(play), it) } }
|
||||
return items
|
||||
}
|
||||
var start = 0L
|
||||
@@ -755,27 +943,20 @@ class QueueRepository(
|
||||
): List<SongItem> {
|
||||
if (entry.kind == QueueKind.Song) {
|
||||
offsetSeeks++
|
||||
return dao.song(entry.sourceId, entry.refId, offset, limit)
|
||||
return resolverFor(entry.kind).rows(dao, entry, offset, limit).also { resolvedRowCount += it.size }
|
||||
}
|
||||
val ref = QueueRef(entry.kind, entry.sourceId, entry.refId)
|
||||
val anchored = keyCache[ref]?.let { keyedRows(entry, offset, limit, it) }
|
||||
if (anchored != null) {
|
||||
keysetSeeks++
|
||||
if (anchored.isNotEmpty()) remember(ref, offset, anchored)
|
||||
resolvedRowCount += anchored.size
|
||||
return anchored
|
||||
}
|
||||
offsetSeeks++
|
||||
val fresh =
|
||||
when (entry.kind) {
|
||||
QueueKind.Playlist -> dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
QueueKind.Album -> dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
QueueKind.Artist -> dao.artistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
QueueKind.Genre -> dao.genreSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
QueueKind.Downloaded -> dao.downloadedSongs(entry.sourceId, offset, limit)
|
||||
QueueKind.Starred -> dao.starredSongs(entry.sourceId, offset, limit)
|
||||
QueueKind.Song -> emptyList()
|
||||
}
|
||||
val fresh = resolverFor(entry.kind).rows(dao, entry, offset, limit)
|
||||
if (fresh.isNotEmpty()) remember(ref, offset, fresh)
|
||||
resolvedRowCount += fresh.size
|
||||
return fresh
|
||||
}
|
||||
|
||||
@@ -808,7 +989,7 @@ class QueueRepository(
|
||||
limit: Int,
|
||||
cursor: KeyCursor,
|
||||
): List<SongItem>? {
|
||||
if (!cursor.key.seekable(entry.kind)) return null
|
||||
if (!resolverFor(entry.kind).supportsKeyset(cursor.key)) return null
|
||||
val from = cursor.ordinal
|
||||
// A backward seek from a far cursor is not cheaper than the offset query: the album
|
||||
// predicate is not sargable (it has to include NULL disc/track), so it scans the partition
|
||||
@@ -834,43 +1015,14 @@ class QueueRepository(
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
): List<SongItem> =
|
||||
when (entry.kind) {
|
||||
QueueKind.Playlist -> dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||
QueueKind.Album -> dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||
QueueKind.Artist -> emptyList()
|
||||
QueueKind.Genre -> emptyList()
|
||||
QueueKind.Downloaded -> emptyList()
|
||||
QueueKind.Starred -> emptyList()
|
||||
QueueKind.Song -> emptyList()
|
||||
}
|
||||
): List<SongItem> = resolverFor(entry.kind).rowsFrom(dao, entry, key, skip, limit) ?: emptyList()
|
||||
|
||||
private suspend fun rowsBefore(
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
): List<SongItem> =
|
||||
when (entry.kind) {
|
||||
QueueKind.Playlist -> dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||
QueueKind.Album -> dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||
QueueKind.Artist -> emptyList()
|
||||
QueueKind.Genre -> emptyList()
|
||||
QueueKind.Downloaded -> emptyList()
|
||||
QueueKind.Starred -> emptyList()
|
||||
QueueKind.Song -> emptyList()
|
||||
}
|
||||
|
||||
private fun OrderKey.seekable(kind: QueueKind): Boolean =
|
||||
when (kind) {
|
||||
QueueKind.Playlist -> position != null
|
||||
QueueKind.Album -> disc != null && track != null
|
||||
QueueKind.Artist -> false
|
||||
QueueKind.Genre -> false
|
||||
QueueKind.Downloaded -> false
|
||||
QueueKind.Starred -> false
|
||||
QueueKind.Song -> false
|
||||
}
|
||||
): List<SongItem> = resolverFor(entry.kind).rowsBefore(dao, entry, key, skip, limit) ?: emptyList()
|
||||
|
||||
private suspend fun lengthOf(
|
||||
entry: QueueEntry,
|
||||
@@ -895,40 +1047,23 @@ class QueueRepository(
|
||||
)
|
||||
|
||||
private suspend fun QueueEntry.resolvedLength(): Long {
|
||||
val total =
|
||||
when (kind) {
|
||||
QueueKind.Song -> {
|
||||
dao.songLength(sourceId, refId)
|
||||
}
|
||||
|
||||
QueueKind.Playlist -> {
|
||||
dao.playlistLength(sourceId, refId)
|
||||
}
|
||||
|
||||
QueueKind.Album -> {
|
||||
dao.albumLength(sourceId, refId)
|
||||
}
|
||||
|
||||
QueueKind.Artist -> {
|
||||
dao.artistLength(sourceId, refId)
|
||||
}
|
||||
|
||||
QueueKind.Genre -> {
|
||||
dao.genreLength(sourceId, refId)
|
||||
}
|
||||
|
||||
QueueKind.Downloaded -> {
|
||||
dao.downloadedLength(sourceId)
|
||||
}
|
||||
|
||||
QueueKind.Starred -> {
|
||||
dao.starredLength(sourceId)
|
||||
}
|
||||
}
|
||||
val total = resolverFor(kind).length(dao, this)
|
||||
val available = (total - offset).coerceAtLeast(0)
|
||||
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(
|
||||
start: Long,
|
||||
end: Long,
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.model.SubsonicConfig
|
||||
import com.subtracks.data.model.SubsonicSource
|
||||
import com.subtracks.data.model.artistImageKey
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
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.streamLengthSuffix
|
||||
import com.subtracks.data.source.subsonic.SubsonicClient
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -46,6 +48,13 @@ class SourceRepository(
|
||||
val source: SubsonicMusicSource,
|
||||
)
|
||||
|
||||
private data class ResolvedSource(
|
||||
val config: SubsonicConfig?,
|
||||
val quality: StreamQuality,
|
||||
val mode: NetworkMode,
|
||||
val concurrency: Int,
|
||||
)
|
||||
|
||||
@Volatile
|
||||
private var active: ActiveSource? = null
|
||||
|
||||
@@ -61,9 +70,13 @@ class SourceRepository(
|
||||
private val _offline = MutableStateFlow(false)
|
||||
val offline: StateFlow<Boolean> = _offline
|
||||
|
||||
private val _activeSource = MutableStateFlow<Long?>(null)
|
||||
val activeSource: StateFlow<Long?> = _activeSource
|
||||
|
||||
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
||||
|
||||
fun setOfflineMode(enabled: Boolean) {
|
||||
Log.i("net", "offline mode -> $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
|
||||
// cold start with offline on never touches the network.
|
||||
_offline.value = prefs.offlineMode().first()
|
||||
var lastMode: NetworkMode? = null
|
||||
combine(
|
||||
db.sourcesDao().activeSubsonicConfig(),
|
||||
networkMode,
|
||||
@@ -88,13 +102,24 @@ class SourceRepository(
|
||||
prefs.streamQuality(NetworkMode.Mobile),
|
||||
prefs.syncConcurrency(),
|
||||
) { config, mode, wifi, mobile, concurrency ->
|
||||
Triple(config, if (mode == NetworkMode.Wifi) wifi else mobile, concurrency)
|
||||
}.collect { (config, quality, concurrency) ->
|
||||
ResolvedSource(config, if (mode == NetworkMode.Wifi) wifi else mobile, mode, concurrency)
|
||||
}.collect { (config, quality, mode, concurrency) ->
|
||||
if (mode != lastMode) {
|
||||
Log.i("net", "network mode -> ${mode.key}")
|
||||
lastMode = mode
|
||||
}
|
||||
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)) }
|
||||
_activeSource.value = config?.id
|
||||
_quality.value = quality
|
||||
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)
|
||||
}
|
||||
|
||||
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(
|
||||
sourceId: Long,
|
||||
coverArt: String,
|
||||
@@ -200,8 +237,8 @@ class SourceRepository(
|
||||
useTokenAuth: Boolean,
|
||||
) {
|
||||
val dao = db.sourcesDao()
|
||||
val source = dao.sourceOnce(id) ?: return
|
||||
dao.upsertSource(source.copy(name = name.trim(), address = normalizeAddress(address)))
|
||||
if (dao.sourceOnce(id) == null) return
|
||||
dao.updateSourceDetails(id, name.trim(), normalizeAddress(address))
|
||||
dao.upsertSubsonicSource(
|
||||
SubsonicSource(
|
||||
sourceId = id,
|
||||
@@ -241,6 +278,15 @@ class SourceRepository(
|
||||
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 =
|
||||
client(
|
||||
address = address,
|
||||
|
||||
@@ -26,6 +26,8 @@ interface MusicSource {
|
||||
time: Long? = null,
|
||||
)
|
||||
|
||||
suspend fun artistBiography(artistId: String): String? = null
|
||||
|
||||
fun artists(): Flow<List<Artist>>
|
||||
|
||||
fun albums(): Flow<List<Album>>
|
||||
|
||||
@@ -2,11 +2,13 @@ package com.subtracks.data.source.subsonic
|
||||
|
||||
import com.subtracks.R
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.log.Log
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.InputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
open class SubsonicException(
|
||||
val code: Int,
|
||||
@@ -20,7 +22,6 @@ class SubsonicClient(
|
||||
private val password: String,
|
||||
useTokenAuth: Boolean,
|
||||
private val http: OkHttpClient,
|
||||
private val userAgent: String = "subtracks/android",
|
||||
private val onTokenAuthUnsupported: () -> Unit = {},
|
||||
) {
|
||||
@Volatile
|
||||
@@ -55,30 +56,37 @@ class SubsonicClient(
|
||||
fun <T> stream(
|
||||
method: String,
|
||||
params: Map<String, String> = emptyMap(),
|
||||
timeoutMs: Long? = null,
|
||||
body: (InputStream) -> T,
|
||||
): T {
|
||||
val usedTokenAuth = useTokenAuth
|
||||
return try {
|
||||
execute(method, params, body)
|
||||
execute(method, params, timeoutMs, body)
|
||||
} 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()
|
||||
execute(method, params, body)
|
||||
execute(method, params, timeoutMs, body)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> execute(
|
||||
method: String,
|
||||
params: Map<String, String>,
|
||||
timeoutMs: Long?,
|
||||
body: (InputStream) -> T,
|
||||
): T {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(uri(method, params))
|
||||
.header("User-Agent", userAgent)
|
||||
.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) {
|
||||
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.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
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>> =
|
||||
entityBatches(
|
||||
method = "getArtists",
|
||||
@@ -101,7 +110,7 @@ class SubsonicSource(
|
||||
params = page("newest", offset),
|
||||
tag = "album",
|
||||
create = { SubsonicXml.albumDraft(id, it) },
|
||||
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
|
||||
onChild = SubsonicXml::albumChild,
|
||||
).collect { batch ->
|
||||
raw += batch.size
|
||||
val accepted = batch.filter { it.id.isNotEmpty() }
|
||||
@@ -205,7 +214,7 @@ class SubsonicSource(
|
||||
),
|
||||
tag = "song",
|
||||
create = { SubsonicXml.songDraft(id, it) },
|
||||
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) },
|
||||
onChild = SubsonicXml::songChild,
|
||||
).collect { batch ->
|
||||
raw += batch.size
|
||||
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
||||
@@ -224,6 +233,23 @@ class SubsonicSource(
|
||||
private fun albumSongs(): Flow<List<Song>> =
|
||||
channelFlow {
|
||||
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 pages = 0
|
||||
while (true) {
|
||||
@@ -248,10 +274,10 @@ class SubsonicSource(
|
||||
params = mapOf("id" to albumId),
|
||||
tag = "song",
|
||||
create = { SubsonicXml.songDraft(id, it) },
|
||||
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) },
|
||||
onChild = SubsonicXml::songChild,
|
||||
).collect { batch ->
|
||||
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()
|
||||
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 {
|
||||
emptyQuerySearchSupported?.let { return it }
|
||||
@@ -297,7 +330,7 @@ class SubsonicSource(
|
||||
params: Map<String, String> = emptyMap(),
|
||||
tag: String,
|
||||
create: (Attributes) -> T,
|
||||
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
|
||||
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
|
||||
): Flow<List<T>> =
|
||||
channelFlow {
|
||||
val buffer = ArrayList<T>(PAGE_SIZE)
|
||||
@@ -339,6 +372,7 @@ class SubsonicSource(
|
||||
const val PAGE_SIZE = 500
|
||||
const val MAX_PAGES = 20_000
|
||||
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.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.Song
|
||||
@@ -31,6 +32,8 @@ object SubsonicXml {
|
||||
albumCount = attrs.longAttr("albumCount") ?: 0L,
|
||||
starred = attrs.dateAttr("starred"),
|
||||
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(
|
||||
@@ -43,6 +46,8 @@ object SubsonicXml {
|
||||
artistId = attrs.attr("artistId").ifEmpty { null },
|
||||
name = attrs.attr("name"),
|
||||
albumArtist = attrs.attr("artist").ifEmpty { null },
|
||||
displayArtist = attrs.attr("displayArtist").ifEmpty { null },
|
||||
sortName = attrs.attr("sortName").ifEmpty { null },
|
||||
created = attrs.dateAttr("created") ?: 0L,
|
||||
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
||||
genre = attrs.attr("genre").ifEmpty { null },
|
||||
@@ -68,6 +73,8 @@ object SubsonicXml {
|
||||
title = attrs.attr("title"),
|
||||
album = attrs.attr("album").ifEmpty { null },
|
||||
artist = attrs.attr("artist").ifEmpty { null },
|
||||
displayArtist = attrs.attr("displayArtist").ifEmpty { null },
|
||||
sortName = attrs.attr("sortName").ifEmpty { null },
|
||||
duration = attrs.longAttr("duration"),
|
||||
track = attrs.longAttr("track"),
|
||||
disc = attrs.longAttr("discNumber"),
|
||||
@@ -78,6 +85,36 @@ object SubsonicXml {
|
||||
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(
|
||||
sourceId: Long,
|
||||
attrs: Attributes,
|
||||
@@ -92,6 +129,8 @@ object SubsonicXml {
|
||||
created = attrs.dateAttr("created") ?: 0L,
|
||||
changed = attrs.dateAttr("changed") ?: 0L,
|
||||
duration = attrs.longAttr("duration") ?: 0L,
|
||||
owner = attrs.attr("owner").ifEmpty { null },
|
||||
shared = attrs.boolAttr("public"),
|
||||
)
|
||||
|
||||
internal fun playlistSong(
|
||||
@@ -115,7 +154,7 @@ object SubsonicXml {
|
||||
input: InputStream,
|
||||
entityTag: String,
|
||||
create: (Attributes) -> T,
|
||||
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
|
||||
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
|
||||
onEnd: (T) -> Unit,
|
||||
) {
|
||||
parse(input, EntityHandler(entityTag, create, onChild, onEnd, stopAfterFirst = false))
|
||||
@@ -125,10 +164,16 @@ object SubsonicXml {
|
||||
input: InputStream,
|
||||
entityTag: String,
|
||||
): Boolean {
|
||||
val handler = EntityHandler(entityTag, { Unit }, { _, _, _ -> }, { }, stopAfterFirst = true)
|
||||
val handler = EntityHandler(entityTag, { Unit }, { _, _, _, _ -> }, { }, stopAfterFirst = true)
|
||||
parse(input, handler)
|
||||
return handler.found
|
||||
}
|
||||
|
||||
internal fun artistBiography(input: InputStream): String? {
|
||||
val handler = ArtistBiographyHandler()
|
||||
parse(input, handler)
|
||||
return handler.biography
|
||||
}
|
||||
}
|
||||
|
||||
internal class AlbumDraft(
|
||||
@@ -137,6 +182,8 @@ internal class AlbumDraft(
|
||||
var artistId: String? = null,
|
||||
var name: String = "",
|
||||
var albumArtist: String? = null,
|
||||
var displayArtist: String? = null,
|
||||
var sortName: String? = null,
|
||||
var created: Long = 0L,
|
||||
var coverArt: String? = null,
|
||||
var genre: String? = null,
|
||||
@@ -145,19 +192,41 @@ internal class AlbumDraft(
|
||||
var songCount: Long = 0L,
|
||||
val discTitles: MutableMap<Long, String> = mutableMapOf(),
|
||||
) {
|
||||
private val artists = mutableListOf<ArtistRef>()
|
||||
private var artistContainer = false
|
||||
|
||||
fun addDiscTitle(attrs: Attributes) {
|
||||
val disc = attrs.longAttr("disc") ?: return
|
||||
val title = attrs.attr("title").takeIf { it.isNotBlank() } ?: return
|
||||
discTitles[disc] = title
|
||||
}
|
||||
|
||||
fun toAlbum(): Album =
|
||||
Album(
|
||||
fun addArtists(attrs: Attributes) {
|
||||
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,
|
||||
id = id,
|
||||
artistId = artistId,
|
||||
name = name,
|
||||
albumArtist = albumArtist,
|
||||
albumArtist = displayName(albumArtist, displayArtist, refs),
|
||||
created = created,
|
||||
coverArt = coverArt,
|
||||
genre = genre,
|
||||
@@ -165,7 +234,10 @@ internal class AlbumDraft(
|
||||
starred = starred,
|
||||
songCount = songCount,
|
||||
discTitles = discTitles.toMap(),
|
||||
artists = refs,
|
||||
sortName = sortName?.takeIf { it.isNotBlank() } ?: name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal class SongDraft(
|
||||
@@ -176,6 +248,8 @@ internal class SongDraft(
|
||||
private val title: String = "",
|
||||
private val album: String? = null,
|
||||
private val artist: String? = null,
|
||||
private val displayArtist: String? = null,
|
||||
private val sortName: String? = null,
|
||||
private val duration: Long? = null,
|
||||
private val track: Long? = null,
|
||||
private val disc: Long? = null,
|
||||
@@ -184,8 +258,15 @@ internal class SongDraft(
|
||||
private val created: Long = 0L,
|
||||
private val playCount: Long = 0L,
|
||||
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 artists = mutableListOf<ArtistRef>()
|
||||
private var nestedArtists: MutableList<ArtistRef>? = null
|
||||
|
||||
fun addGenre(attrs: Attributes) {
|
||||
attrs
|
||||
.attr("name")
|
||||
@@ -194,15 +275,41 @@ internal class SongDraft(
|
||||
?.let { genres += it }
|
||||
}
|
||||
|
||||
fun toSong(): Song =
|
||||
Song(
|
||||
fun addReplayGain(attrs: Attributes) {
|
||||
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,
|
||||
id = id,
|
||||
albumId = albumId,
|
||||
artistId = artistId,
|
||||
title = title,
|
||||
album = album,
|
||||
artist = artist,
|
||||
artist = displayName(artist, displayArtist, refs),
|
||||
duration = duration,
|
||||
track = track,
|
||||
disc = disc,
|
||||
@@ -211,14 +318,59 @@ internal class SongDraft(
|
||||
created = created,
|
||||
playCount = playCount,
|
||||
played = played,
|
||||
trackGain = trackGain,
|
||||
albumGain = albumGain,
|
||||
trackPeak = trackPeak,
|
||||
albumPeak = albumPeak,
|
||||
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.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)
|
||||
|
||||
private const val ROOT_TAG = "subsonic-response"
|
||||
@@ -280,10 +432,44 @@ private open class SubsonicResponseHandler : DefaultHandler() {
|
||||
) = 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 val entityTag: String,
|
||||
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 stopAfterFirst: Boolean,
|
||||
) : SubsonicResponseHandler() {
|
||||
@@ -304,8 +490,8 @@ private class EntityHandler<T>(
|
||||
current = create(attributes)
|
||||
entityDepth = depth
|
||||
}
|
||||
} else if (depth == entityDepth + 1) {
|
||||
onChild(active, name, attributes)
|
||||
} else if (depth > entityDepth) {
|
||||
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
|
||||
|
||||
import android.util.Log
|
||||
import com.subtracks.R
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.data.source.subsonic.SubsonicException
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface SyncStatus {
|
||||
data object Idle : SyncStatus
|
||||
|
||||
data object Running : SyncStatus
|
||||
data class Running(
|
||||
val silent: Boolean,
|
||||
) : SyncStatus
|
||||
|
||||
data object Success : SyncStatus
|
||||
|
||||
@@ -34,41 +39,65 @@ class SyncManager(
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val queueRepository: QueueRepository,
|
||||
private val serverActions: ServerActionSink,
|
||||
private val preferences: UserPreferences,
|
||||
private val showMessage: (UiMessage) -> Unit = {},
|
||||
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)
|
||||
val status: StateFlow<SyncStatus> = _status
|
||||
|
||||
init {
|
||||
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() {
|
||||
requests.trySend(Unit)
|
||||
fun requestSync(silent: Boolean = false) {
|
||||
requests.trySend(silent)
|
||||
}
|
||||
|
||||
private suspend fun runSync() {
|
||||
private suspend fun runSync(silent: Boolean) {
|
||||
if (sourceRepository.offline.value) {
|
||||
val offline = UiMessage(R.string.sync_offline)
|
||||
showMessage(offline)
|
||||
_status.value = SyncStatus.Failed(offline)
|
||||
if (!silent) {
|
||||
val offline = UiMessage(R.string.sync_offline)
|
||||
showMessage(offline)
|
||||
_status.value = SyncStatus.Failed(offline)
|
||||
}
|
||||
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 =
|
||||
try {
|
||||
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
|
||||
Log.i("sync", "started (source ${source.id})")
|
||||
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
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (failure: Exception) {
|
||||
Log.w(TAG, "Sync failed", failure)
|
||||
Log.w("sync", "failed after ${elapsedMs(startedAt)}ms", failure)
|
||||
val message =
|
||||
when (failure) {
|
||||
is NoServerException -> {
|
||||
@@ -80,16 +109,14 @@ class SyncManager(
|
||||
?: UiMessage(R.string.sync_failed_detail, listOf(failure.message ?: ""))
|
||||
}
|
||||
}
|
||||
showMessage(message)
|
||||
if (!silent) showMessage(message)
|
||||
SyncStatus.Failed(message)
|
||||
}
|
||||
queueRepository.invalidateLibraryCache()
|
||||
_status.value = result
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "SubtracksSync"
|
||||
}
|
||||
private fun elapsedMs(startNanos: Long): Long = (System.nanoTime() - startNanos) / 1_000_000
|
||||
}
|
||||
|
||||
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 com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumArtistRef
|
||||
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.DiscKey
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongArtistRef
|
||||
import com.subtracks.data.model.SongGenre
|
||||
import com.subtracks.data.source.MusicSource
|
||||
import java.util.Arrays
|
||||
@@ -20,66 +24,120 @@ class SyncService(
|
||||
private val db: SubtracksDatabase,
|
||||
private val source: MusicSource,
|
||||
) {
|
||||
suspend fun sync() {
|
||||
syncArtists()
|
||||
syncAlbums()
|
||||
syncSongs()
|
||||
aggregatePlayData()
|
||||
private var artistsSynced = 0
|
||||
private var albumsSynced = 0
|
||||
private var songsSynced = 0
|
||||
private var playlistsSynced = 0
|
||||
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()
|
||||
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 seen = HashedIds()
|
||||
var changed = false
|
||||
source.artists().collect { batch ->
|
||||
if (batch.isEmpty()) return@collect
|
||||
write("artists", artistColumns, primaryKey, batch) { it.values() }
|
||||
changed =
|
||||
write("artists", primaryKey, batch, preserved = setOf("albumCount"), markStale = true) { it.columns() } ||
|
||||
changed
|
||||
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 seenAlbums = HashedIds()
|
||||
val seenDiscs = HashedIds()
|
||||
var changed = false
|
||||
source.albums().collect { batch ->
|
||||
if (batch.isEmpty()) return@collect
|
||||
val discs =
|
||||
batch.flatMap { album ->
|
||||
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)) }
|
||||
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) })
|
||||
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
||||
deleteStaleDiscs(it)
|
||||
}
|
||||
val prunedBefore = pruned
|
||||
pruned +=
|
||||
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 seen = HashedIds()
|
||||
var changed = false
|
||||
source.songs().collect { batch ->
|
||||
if (batch.isEmpty()) return@collect
|
||||
writeSongs(batch)
|
||||
changed = writeSongs(batch) || changed
|
||||
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 genres =
|
||||
batch.flatMap { song ->
|
||||
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.upsertChanged("songs", songColumns, primaryKey, batch) { it.values() }
|
||||
val before = connection.totalChanges()
|
||||
val resolved = connection.resolveSongSortNames(batch)
|
||||
connection.upsertChanged("songs", primaryKey, resolved) { it.columns() }
|
||||
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||
batch.forEach { song ->
|
||||
statement.bind(1, song.sourceId)
|
||||
@@ -90,13 +148,107 @@ class SyncService(
|
||||
statement.clearBindings()
|
||||
}
|
||||
}
|
||||
connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() }
|
||||
connection.upsertChanged("song_genres", songGenreKey, genres) { it.columns() }
|
||||
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", songArtistKey, artists) { it.columns() }
|
||||
(connection.totalChanges() != before).also { changed ->
|
||||
if (changed) connection.markCreditsStale(batch.first().sourceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun aggregatePlayData() {
|
||||
db.libraryDao().recomputePlayData(source.id)
|
||||
private suspend fun recomputeAggregates() {
|
||||
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() {
|
||||
@@ -104,10 +256,11 @@ class SyncService(
|
||||
val seen = HashedIds()
|
||||
source.playlists().collect { batch ->
|
||||
if (batch.isEmpty()) return@collect
|
||||
write("playlists", playlistColumns, primaryKey, batch) { it.values() }
|
||||
write("playlists", primaryKey, batch) { it.columns() }
|
||||
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() {
|
||||
@@ -116,8 +269,9 @@ class SyncService(
|
||||
val counts = HashMap<String, Long>()
|
||||
source.playlistSongs(playlistIds).collect { batch ->
|
||||
if (batch.isEmpty()) return@collect
|
||||
write("playlist_songs", playlistSongColumns, playlistSongKey, batch) { it.values() }
|
||||
write("playlist_songs", playlistSongKey, batch) { it.columns() }
|
||||
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
|
||||
playlistSongsSynced += batch.size
|
||||
}
|
||||
playlistIds.forEach { library.deletePlaylistSongsFrom(source.id, it, counts[it] ?: 0L) }
|
||||
library.deleteOrphanPlaylistSongs(source.id)
|
||||
@@ -126,26 +280,50 @@ class SyncService(
|
||||
// One commit per batch, so Room notifies paged observers once per batch rather than per row.
|
||||
private suspend fun <T> write(
|
||||
table: String,
|
||||
columns: List<String>,
|
||||
key: List<String>,
|
||||
rows: List<T>,
|
||||
values: (T) -> List<Any?>,
|
||||
) {
|
||||
preserved: Set<String> = emptySet(),
|
||||
markStale: Boolean = false,
|
||||
columns: (T) -> List<Pair<String, Any?>>,
|
||||
): Boolean =
|
||||
db.useWriterConnection { connection ->
|
||||
connection.immediateTransaction {
|
||||
connection.upsertChanged(table, columns, key, rows, values)
|
||||
val before = connection.totalChanges()
|
||||
connection.upsertChanged(table, key, rows, preserved, columns)
|
||||
(connection.totalChanges() != before).also { changed ->
|
||||
if (changed && markStale) connection.markCreditsStale(source.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun writeAlbumsAndDiscs(
|
||||
albums: List<Album>,
|
||||
discs: List<Disc>,
|
||||
) {
|
||||
db.useWriterConnection { connection ->
|
||||
): Boolean {
|
||||
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.upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() }
|
||||
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
|
||||
val before = connection.totalChanges()
|
||||
val resolved = connection.resolveAlbumSortNames(albums)
|
||||
connection.upsertChanged("albums", primaryKey, resolved) { it.columns() }
|
||||
connection.upsertChanged("discs", discKey, discs) { it.columns() }
|
||||
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", albumArtistKey, artists) { it.columns() }
|
||||
(connection.totalChanges() != before).also { changed ->
|
||||
if (changed) connection.markCreditsStale(albums.first().sourceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,15 +351,19 @@ class SyncService(
|
||||
seen: HashedIds,
|
||||
pageOf: suspend (afterId: String) -> List<String>,
|
||||
delete: suspend (Collection<String>) -> Unit,
|
||||
) {
|
||||
markStale: (suspend () -> Unit)? = null,
|
||||
): Int {
|
||||
seen.freeze()
|
||||
var afterId = ""
|
||||
var pruned = 0
|
||||
while (true) {
|
||||
val ids = pageOf(afterId)
|
||||
if (ids.isEmpty()) return
|
||||
if (ids.isEmpty()) return pruned
|
||||
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
||||
if (stale.isNotEmpty()) markStale?.invoke()
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -190,23 +372,35 @@ class SyncService(
|
||||
seen: HashedIds,
|
||||
pageOf: suspend (afterAlbumId: String, afterDisc: Long) -> List<DiscKey>,
|
||||
delete: suspend (Collection<DiscKey>) -> Unit,
|
||||
) {
|
||||
): Int {
|
||||
seen.freeze()
|
||||
var afterAlbumId = ""
|
||||
var afterDisc = 0L
|
||||
var pruned = 0
|
||||
while (true) {
|
||||
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)) }
|
||||
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
|
||||
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 SQL_VARIABLES = 500
|
||||
private const val PRUNE_PAGE = 500
|
||||
private const val INITIAL_HASH_CAPACITY = 256
|
||||
private const val FNV_OFFSET = -3750763034362895579L
|
||||
@@ -243,47 +437,8 @@ private fun discHash(
|
||||
|
||||
private val primaryKey = listOf("sourceId", "id")
|
||||
|
||||
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt")
|
||||
|
||||
private val albumColumns =
|
||||
listOf(
|
||||
"sourceId",
|
||||
"id",
|
||||
"artistId",
|
||||
"name",
|
||||
"albumArtist",
|
||||
"created",
|
||||
"coverArt",
|
||||
"genre",
|
||||
"year",
|
||||
"starred",
|
||||
"songCount",
|
||||
)
|
||||
|
||||
private val discKey = listOf("sourceId", "albumId", "disc")
|
||||
|
||||
private val discColumns = listOf("sourceId", "albumId", "disc", "title")
|
||||
|
||||
private val songColumns =
|
||||
listOf(
|
||||
"sourceId",
|
||||
"id",
|
||||
"albumId",
|
||||
"artistId",
|
||||
"title",
|
||||
"album",
|
||||
"artist",
|
||||
"duration",
|
||||
"track",
|
||||
"disc",
|
||||
"starred",
|
||||
"created",
|
||||
"playCount",
|
||||
"played",
|
||||
)
|
||||
|
||||
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
|
||||
|
||||
private fun Song.genreNames(): List<String> =
|
||||
genres
|
||||
.flatMap { it.genreParts() }
|
||||
@@ -294,62 +449,154 @@ private fun Song.genreNames(): List<String> =
|
||||
// Navidrome's default genre tag split (resources/mappings.yaml): semicolon, slash, comma.
|
||||
private fun String.genreParts(): List<String> = split(';', '/', ',').map { it.trim() }.filter { it.isNotEmpty() }
|
||||
|
||||
private val songGenreColumns = listOf("sourceId", "songId", "position", "genre")
|
||||
|
||||
private val songGenreKey = listOf("sourceId", "songId", "position")
|
||||
|
||||
private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
|
||||
private val albumArtistKey = listOf("sourceId", "albumId", "position")
|
||||
|
||||
private val songArtistKey = listOf("sourceId", "songId", "position")
|
||||
|
||||
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
||||
|
||||
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt)
|
||||
|
||||
private fun Album.values() = listOf<Any?>(sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount)
|
||||
|
||||
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
|
||||
|
||||
private fun Song.values() =
|
||||
listOf<Any?>(
|
||||
sourceId,
|
||||
id,
|
||||
albumId,
|
||||
artistId,
|
||||
title,
|
||||
album,
|
||||
artist,
|
||||
duration,
|
||||
track,
|
||||
disc,
|
||||
starred,
|
||||
created,
|
||||
playCount,
|
||||
played,
|
||||
private fun Artist.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"id" to id,
|
||||
"name" to name,
|
||||
"albumCount" to albumCount,
|
||||
"starred" to starred,
|
||||
"coverArt" to coverArt,
|
||||
"artistImageUrl" to artistImageUrl,
|
||||
"sortName" to sortName,
|
||||
)
|
||||
|
||||
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
|
||||
private fun Album.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"id" to id,
|
||||
"artistId" to artistId,
|
||||
"name" to name,
|
||||
"albumArtist" to albumArtist,
|
||||
"created" to created,
|
||||
"coverArt" to coverArt,
|
||||
"genre" to genre,
|
||||
"year" to year,
|
||||
"starred" to starred,
|
||||
"songCount" to songCount,
|
||||
"artists" to ArtistRefsCodec.encode(artistRefs),
|
||||
"sortName" to sortName,
|
||||
"albumArtistSort" to albumArtistSort,
|
||||
)
|
||||
|
||||
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
|
||||
private fun AlbumArtistRef.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"albumId" to albumId,
|
||||
"position" to position,
|
||||
"artistId" to artistId,
|
||||
"name" to name,
|
||||
)
|
||||
|
||||
private fun PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position)
|
||||
private fun SongArtistRef.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"songId" to songId,
|
||||
"position" to position,
|
||||
"artistId" to artistId,
|
||||
"name" to name,
|
||||
)
|
||||
|
||||
private fun Disc.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"albumId" to albumId,
|
||||
"disc" to disc,
|
||||
"title" to title,
|
||||
)
|
||||
|
||||
private fun Song.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"id" to id,
|
||||
"albumId" to albumId,
|
||||
"artistId" to artistId,
|
||||
"title" to title,
|
||||
"album" to album,
|
||||
"artist" to artist,
|
||||
"duration" to duration,
|
||||
"track" to track,
|
||||
"disc" to disc,
|
||||
"starred" to starred,
|
||||
"created" to created,
|
||||
"playCount" to playCount,
|
||||
"played" to played,
|
||||
"trackGain" to trackGain,
|
||||
"albumGain" to albumGain,
|
||||
"trackPeak" to trackPeak,
|
||||
"albumPeak" to albumPeak,
|
||||
"artists" to ArtistRefsCodec.encode(artistRefs),
|
||||
"sortName" to sortName,
|
||||
"artistSort" to 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.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"songId" to songId,
|
||||
"position" to position,
|
||||
"genre" to genre,
|
||||
)
|
||||
|
||||
private fun Playlist.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"id" to id,
|
||||
"name" to name,
|
||||
"comment" to comment,
|
||||
"coverArt" to coverArt,
|
||||
"songCount" to songCount,
|
||||
"created" to created,
|
||||
"changed" to changed,
|
||||
"duration" to duration,
|
||||
"owner" to owner,
|
||||
"shared" to shared,
|
||||
)
|
||||
|
||||
private fun PlaylistSong.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"playlistId" to playlistId,
|
||||
"songId" to songId,
|
||||
"position" to position,
|
||||
)
|
||||
|
||||
private suspend fun <T> PooledConnection.upsertChanged(
|
||||
table: String,
|
||||
columns: List<String>,
|
||||
key: List<String>,
|
||||
rows: List<T>,
|
||||
values: (T) -> List<Any?>,
|
||||
preserved: Set<String> = emptySet(),
|
||||
columns: (T) -> List<Pair<String, Any?>>,
|
||||
) {
|
||||
if (rows.isEmpty()) return
|
||||
val updates = columns.filterNot { it in key }
|
||||
val columnNames = columns(rows.first()).map { it.first }
|
||||
val updates = columnNames.filterNot { it in key || it in preserved }
|
||||
val sql =
|
||||
"INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " +
|
||||
"INSERT INTO $table (${columnNames.joinToString()}) VALUES (${columnNames.joinToString { "?" }}) " +
|
||||
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
|
||||
updates.joinToString { "$it = excluded.$it" } +
|
||||
" WHERE " +
|
||||
updates.joinToString(" OR ") { "$table.$it COLLATE BINARY IS NOT excluded.$it" }
|
||||
usePrepared(sql) { statement ->
|
||||
rows.forEach { row ->
|
||||
values(row).forEachIndexed { index, value -> statement.bind(index + 1, value) }
|
||||
columns(row).forEachIndexed { index, (_, value) -> statement.bind(index + 1, value) }
|
||||
statement.step()
|
||||
statement.reset()
|
||||
statement.clearBindings()
|
||||
@@ -367,6 +614,7 @@ private fun SQLiteStatement.bind(
|
||||
is Int -> bindLong(index, value.toLong())
|
||||
is Boolean -> bindLong(index, if (value) 1L else 0L)
|
||||
is Double -> bindDouble(index, value)
|
||||
is Float -> bindDouble(index, value.toDouble())
|
||||
is String -> bindText(index, value)
|
||||
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.SourceRepository
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.data.sync.AutoSync
|
||||
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.PlaybackController
|
||||
import com.subtracks.playback.PlayerConnection
|
||||
@@ -48,6 +51,8 @@ import com.subtracks.ui.settings.SettingsViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.core.qualifier.named
|
||||
@@ -68,6 +73,7 @@ fun appModule(
|
||||
single { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
|
||||
single(named("io")) { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||
single(named("playback")) { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) }
|
||||
single(createdAtStart = true) { get<UserPreferences>().verboseLogging().onEach(Log::setVerbose).launchIn(get()) }
|
||||
single {
|
||||
SourceRepository(
|
||||
get(),
|
||||
@@ -90,9 +96,18 @@ fun appModule(
|
||||
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 { 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 { ArtworkStore(downloadsRoot(context)) }
|
||||
single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) }
|
||||
@@ -111,8 +126,9 @@ fun appModule(
|
||||
).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 { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
|
||||
single { PlaybackController(get(), get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
|
||||
single(createdAtStart = true) {
|
||||
val playback = get<PlaybackController>()
|
||||
Scrobbler(
|
||||
|
||||
@@ -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
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MediaItem
|
||||
@@ -13,9 +12,12 @@ import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.MediaController
|
||||
import com.subtracks.R
|
||||
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.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.log.Log
|
||||
|
||||
class Media3PlayerHandle(
|
||||
private val controller: MediaController,
|
||||
@@ -112,14 +114,19 @@ class Media3PlayerHandle(
|
||||
) = controller.replaceMediaItem(index, toMediaItem(item))
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
override fun seekTo(positionMs: Long) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"seekTo(${positionMs}ms) from=${controller.currentPosition} playerDuration=${controller.duration} " +
|
||||
"playback",
|
||||
"seekTo(${positionMs}ms) from=${controller.currentPosition} duration=${duration(controller.duration)} " +
|
||||
"itemDuration=${currentItem?.durationMs} state=${controller.playbackState}",
|
||||
)
|
||||
controller.seekTo(positionMs)
|
||||
@@ -136,7 +143,7 @@ class Media3PlayerHandle(
|
||||
mediaItem: MediaItem?,
|
||||
reason: Int,
|
||||
) {
|
||||
Log.d(TAG, "item=${mediaItem?.mediaId}")
|
||||
Log.i("playback", "${describe(mediaItem)} transition=${transitionReason(reason)}")
|
||||
listener.onTransition()
|
||||
}
|
||||
|
||||
@@ -147,8 +154,9 @@ class Media3PlayerHandle(
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Playback error ${error.errorCodeName} at ${controller.currentPosition}/${controller.duration}",
|
||||
"playback",
|
||||
"error ${error.errorCodeName} ${describe(controller.currentMediaItem)} " +
|
||||
"position=${controller.currentPosition} duration=${duration(controller.duration)}",
|
||||
error,
|
||||
)
|
||||
listener.onError(playbackErrorMessage(error))
|
||||
@@ -156,15 +164,54 @@ class Media3PlayerHandle(
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"playback",
|
||||
"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 {
|
||||
val quality = sourceRepository.quality.value
|
||||
return queueMediaItem(
|
||||
@@ -184,20 +231,51 @@ class Media3PlayerHandle(
|
||||
.orEmpty(),
|
||||
artist = mediaItem.mediaMetadata.artist?.toString(),
|
||||
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
||||
artists = mediaItem.artistRefs(),
|
||||
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
||||
durationMs =
|
||||
mediaItem.mediaMetadata.extras
|
||||
?.takeIf { it.containsKey(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_ARTISTS = "artists"
|
||||
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(
|
||||
item: QueueItem,
|
||||
@@ -218,7 +296,13 @@ internal fun queueMediaItem(
|
||||
.setExtras(
|
||||
Bundle().apply {
|
||||
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
||||
putString(EXTRA_ARTISTS, ArtistRefsCodec.encode(item.artists))
|
||||
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()
|
||||
val builder =
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.AudioEncoding
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.QueueEntry
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.prefs.SongTapBehavior
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.QueueSnapshot
|
||||
import com.subtracks.data.repo.QueueWindowItem
|
||||
import com.subtracks.data.repo.Shuffle
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -20,6 +24,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -33,6 +38,9 @@ data class QueueItem(
|
||||
val album: String?,
|
||||
val coverArtId: String?,
|
||||
val durationMs: Long? = null,
|
||||
val replayGain: ReplayGain = ReplayGain(),
|
||||
val albumContext: Boolean = false,
|
||||
val artists: List<ArtistRef> = emptyList(),
|
||||
)
|
||||
|
||||
data class QueueContext(
|
||||
@@ -99,9 +107,9 @@ private data class ReloadUndo(
|
||||
}
|
||||
|
||||
private data class PendingPlay(
|
||||
val entries: List<QueueEntry>,
|
||||
val entry: QueueEntry,
|
||||
val position: Long,
|
||||
val disableShuffle: Boolean,
|
||||
val behavior: SongTapBehavior?,
|
||||
)
|
||||
|
||||
class PlaybackController(
|
||||
@@ -109,6 +117,7 @@ class PlaybackController(
|
||||
private val queueRepository: QueueRepository,
|
||||
private val connection: PlayerConnection,
|
||||
private val downloads: DownloadRepository,
|
||||
private val preferences: UserPreferences,
|
||||
private val showMessage: (UiMessage) -> Unit = {},
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
|
||||
) {
|
||||
@@ -123,11 +132,13 @@ class PlaybackController(
|
||||
private var player: PlayerHandle? = null
|
||||
private var connecting = false
|
||||
private var pendingPlay: PendingPlay? = null
|
||||
private var pendingShuffle: List<QueueEntry>? = null
|
||||
private var pendingShuffle: QueueEntry? = null
|
||||
private var positionJob: Job? = null
|
||||
private var windowJob: Job? = null
|
||||
private var bufferingJob: Job? = null
|
||||
private var stallJob: Job? = null
|
||||
private var showBuffering = false
|
||||
private var stalled = false
|
||||
private val startLock = Mutex()
|
||||
|
||||
private var snapshot: QueueSnapshot? = null
|
||||
@@ -184,7 +195,7 @@ class PlaybackController(
|
||||
val shuffle = pendingShuffle
|
||||
pendingShuffle = null
|
||||
when {
|
||||
pending != null -> start(pending.entries, pending.position, pending.disableShuffle)
|
||||
pending != null -> startPending(pending)
|
||||
shuffle != null -> startShuffled(shuffle)
|
||||
else -> restore()
|
||||
}
|
||||
@@ -204,91 +215,85 @@ class PlaybackController(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
startOrdinal: Long,
|
||||
) = playContext(queueRepository.albumEntry(sourceId, albumId), startOrdinal)
|
||||
) = play(queueRepository.albumEntry(sourceId, albumId), startOrdinal)
|
||||
|
||||
fun playSong(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
) = playContext(queueRepository.songEntry(sourceId, songId), 0)
|
||||
) = play(queueRepository.songEntry(sourceId, songId), 0)
|
||||
|
||||
fun playPlaylist(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
startPosition: Long,
|
||||
) = playContext(queueRepository.playlistEntry(sourceId, playlistId), startPosition)
|
||||
) = play(queueRepository.playlistEntry(sourceId, playlistId), startPosition)
|
||||
|
||||
fun playGenre(
|
||||
sourceId: Long,
|
||||
genre: String,
|
||||
startOrdinal: Long,
|
||||
) = playContext(queueRepository.genreEntry(sourceId, genre), startOrdinal)
|
||||
) = play(queueRepository.genreEntry(sourceId, genre), startOrdinal)
|
||||
|
||||
fun playDownloaded(
|
||||
sourceId: Long,
|
||||
startOrdinal: Long,
|
||||
) = playContext(queueRepository.downloadedEntry(sourceId), startOrdinal)
|
||||
) = play(queueRepository.downloadedEntry(sourceId), startOrdinal)
|
||||
|
||||
fun playStarred(
|
||||
sourceId: Long,
|
||||
startOrdinal: Long,
|
||||
) = playContext(queueRepository.starredEntry(sourceId), startOrdinal)
|
||||
) = play(queueRepository.starredEntry(sourceId), startOrdinal)
|
||||
|
||||
fun playAlbumInOrder(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
) = playContext(queueRepository.albumEntry(sourceId, albumId), 0, disableShuffle = true)
|
||||
) = play(queueRepository.albumEntry(sourceId, albumId), 0, SongTapBehavior.InOrder)
|
||||
|
||||
fun playPlaylistInOrder(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
) = playContext(queueRepository.playlistEntry(sourceId, playlistId), 0, disableShuffle = true)
|
||||
) = play(queueRepository.playlistEntry(sourceId, playlistId), 0, SongTapBehavior.InOrder)
|
||||
|
||||
fun shuffleAlbum(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
) = shuffleContext(queueRepository.albumEntry(sourceId, albumId))
|
||||
) = shufflePlay(queueRepository.albumEntry(sourceId, albumId))
|
||||
|
||||
fun shufflePlaylist(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
) = shuffleContext(queueRepository.playlistEntry(sourceId, playlistId))
|
||||
) = shufflePlay(queueRepository.playlistEntry(sourceId, playlistId))
|
||||
|
||||
fun shuffleArtist(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
) = shuffleContext(queueRepository.artistEntry(sourceId, artistId))
|
||||
) = shufflePlay(queueRepository.artistEntry(sourceId, artistId))
|
||||
|
||||
private fun playContext(
|
||||
entry: QueueEntry,
|
||||
start: Long,
|
||||
disableShuffle: Boolean = false,
|
||||
) = play(listOf(entry), start, disableShuffle)
|
||||
fun shuffleGenre(
|
||||
sourceId: Long,
|
||||
genre: String,
|
||||
) = shufflePlay(queueRepository.genreEntry(sourceId, genre))
|
||||
|
||||
private fun shuffleContext(entry: QueueEntry) = shufflePlay(listOf(entry))
|
||||
fun shuffleStarred(sourceId: Long) = shufflePlay(queueRepository.starredEntry(sourceId))
|
||||
|
||||
private fun shufflePlay(entries: List<QueueEntry>) {
|
||||
fun shuffleDownloaded(sourceId: Long) = shufflePlay(queueRepository.downloadedEntry(sourceId))
|
||||
|
||||
private fun shufflePlay(entry: QueueEntry) {
|
||||
if (player == null) {
|
||||
pendingShuffle = entries
|
||||
pendingShuffle = entry
|
||||
connect()
|
||||
return
|
||||
}
|
||||
scope.launch { startShuffled(entries) }
|
||||
scope.launch { startShuffled(entry) }
|
||||
}
|
||||
|
||||
private suspend fun startShuffled(entries: List<QueueEntry>) =
|
||||
private suspend fun startShuffled(entry: QueueEntry) =
|
||||
startLock.withLock {
|
||||
val entry = entries.first()
|
||||
windowJob?.cancel()
|
||||
windowStart = 0
|
||||
windowEnd = -1
|
||||
lastEdit = null
|
||||
endedHandled = false
|
||||
val previous = snapshot
|
||||
val previousEntry = previous?.entries?.firstOrNull()?.entry
|
||||
val same = previousEntry != null && previousEntry.kind == entry.kind && previousEntry.refId == entry.refId
|
||||
val avoid = if (same) currentPosition()?.let { previous.flatContext(previous.anchorContextPlay(it)) } else null
|
||||
queueRepository.replace(entries)
|
||||
var snap = queueRepository.snapshot()
|
||||
var snap = resetQueue(entry)
|
||||
if (snap.contextSize == 0L) {
|
||||
stopLocked()
|
||||
return@withLock
|
||||
@@ -303,6 +308,18 @@ class PlaybackController(
|
||||
loadWindow(0, autoplay = true)
|
||||
}
|
||||
|
||||
private suspend fun resetQueue(entry: QueueEntry): QueueSnapshot {
|
||||
windowJob?.cancel()
|
||||
windowStart = 0
|
||||
windowEnd = -1
|
||||
lastEdit = null
|
||||
endedHandled = false
|
||||
queueRepository.replace(listOf(entry))
|
||||
val snapshot = queueRepository.snapshot()
|
||||
this.snapshot = snapshot
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private fun randomIndex(
|
||||
size: Long,
|
||||
avoid: Long?,
|
||||
@@ -380,7 +397,7 @@ class PlaybackController(
|
||||
val current = currentPosition() ?: return@withLock
|
||||
val playing = player.playWhenReady
|
||||
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)
|
||||
var updated = readSnapshot()
|
||||
this.snapshot = updated
|
||||
@@ -532,13 +549,13 @@ class PlaybackController(
|
||||
return@withLock
|
||||
}
|
||||
when {
|
||||
fromInWindow && toInWindow -> {
|
||||
fromInWindow && toInWindow && (snapshot.upNextSize == 0L || snapshot.isUpNext(from)) -> {
|
||||
updating = true
|
||||
player.move((from - windowStart).toInt(), (to - windowStart).toInt())
|
||||
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 window's positions shift, and a move that spans it shifts them by one.
|
||||
when {
|
||||
@@ -680,12 +697,29 @@ class PlaybackController(
|
||||
}
|
||||
return
|
||||
}
|
||||
when {
|
||||
current < snapshot.size - 1 -> jumpTo(current + 1)
|
||||
repeatMode != RepeatMode.Off -> jumpTo(0)
|
||||
}
|
||||
nextPosition(snapshot, current)?.let { jumpTo(it) }
|
||||
}
|
||||
|
||||
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) =
|
||||
startLock.withLock {
|
||||
val snapshot = snapshot ?: return@withLock
|
||||
@@ -771,19 +805,12 @@ class PlaybackController(
|
||||
}
|
||||
return
|
||||
}
|
||||
when {
|
||||
current > 0 -> {
|
||||
jumpTo(current - 1)
|
||||
}
|
||||
|
||||
repeatMode == RepeatMode.All -> {
|
||||
jumpTo(snapshot.size - 1)
|
||||
}
|
||||
|
||||
else -> {
|
||||
player.seekTo(0)
|
||||
refresh()
|
||||
}
|
||||
val target = previousPosition(snapshot, current)
|
||||
if (target != null) {
|
||||
jumpTo(target)
|
||||
} else {
|
||||
player.seekTo(0)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -801,10 +828,7 @@ class PlaybackController(
|
||||
}
|
||||
return
|
||||
}
|
||||
when {
|
||||
current > 0 -> jumpTo(current - 1)
|
||||
repeatMode == RepeatMode.All -> jumpTo(snapshot.size - 1)
|
||||
}
|
||||
previousPosition(snapshot, current)?.let { jumpTo(it) }
|
||||
}
|
||||
|
||||
fun addToQueue(
|
||||
@@ -921,12 +945,16 @@ class PlaybackController(
|
||||
}
|
||||
val snapshot = current ?: 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()
|
||||
val target =
|
||||
downloadedAtOrAfter(snapshot, at + 1)
|
||||
?: 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? {
|
||||
@@ -940,52 +968,66 @@ class PlaybackController(
|
||||
}
|
||||
val snapshot = current ?: 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()
|
||||
val target =
|
||||
downloadedAtOrBefore(snapshot, at - 1)
|
||||
?: 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(
|
||||
entries: List<QueueEntry>,
|
||||
entry: QueueEntry,
|
||||
startPosition: Long,
|
||||
disableShuffle: Boolean = false,
|
||||
behavior: SongTapBehavior? = null,
|
||||
) {
|
||||
if (player == null) {
|
||||
pendingPlay = PendingPlay(entries, startPosition, disableShuffle)
|
||||
pendingPlay = PendingPlay(entry, startPosition, behavior)
|
||||
connect()
|
||||
return
|
||||
}
|
||||
scope.launch { start(entries, startPosition, disableShuffle) }
|
||||
scope.launch {
|
||||
startLock.withLock {
|
||||
startLocked(entry, startPosition, behavior)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun start(
|
||||
entries: List<QueueEntry>,
|
||||
private suspend fun startPending(pending: PendingPlay) {
|
||||
startLock.withLock {
|
||||
startLocked(pending.entry, pending.position, pending.behavior)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startLocked(
|
||||
entry: QueueEntry,
|
||||
startPosition: Long,
|
||||
disableShuffle: Boolean = false,
|
||||
) = startLock.withLock {
|
||||
windowJob?.cancel()
|
||||
windowStart = 0
|
||||
windowEnd = -1
|
||||
lastEdit = null
|
||||
endedHandled = false
|
||||
behavior: SongTapBehavior?,
|
||||
) {
|
||||
val resolved = behavior ?: preferences.songTapBehavior().first()
|
||||
val modes = queueRepository.modes()
|
||||
shuffleEnabled = !disableShuffle && modes.shuffle
|
||||
val shuffle =
|
||||
when (resolved) {
|
||||
SongTapBehavior.InOrder -> false
|
||||
SongTapBehavior.KeepShuffle -> modes.shuffle
|
||||
SongTapBehavior.Shuffle -> true
|
||||
}
|
||||
val snapshot = resetQueue(entry)
|
||||
shuffleEnabled = shuffle
|
||||
repeatMode = modes.repeat.toRepeatMode()
|
||||
player?.setRepeatOne(repeatMode == RepeatMode.One)
|
||||
queueRepository.replace(entries)
|
||||
val snapshot = queueRepository.snapshot()
|
||||
this.snapshot = snapshot
|
||||
queueSourceId = entries.firstOrNull()?.sourceId
|
||||
queueSourceId = entry.sourceId
|
||||
if (snapshot.size == 0L) {
|
||||
stopLocked()
|
||||
return@withLock
|
||||
return
|
||||
}
|
||||
val start = startPosition.coerceIn(0, snapshot.contextSize - 1)
|
||||
val position =
|
||||
if (shuffleEnabled) {
|
||||
if (shuffle) {
|
||||
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, start))
|
||||
0L
|
||||
} else {
|
||||
@@ -1047,6 +1089,9 @@ class PlaybackController(
|
||||
windowJob?.cancel()
|
||||
bufferingJob?.cancel()
|
||||
bufferingJob = null
|
||||
stallJob?.cancel()
|
||||
stallJob = null
|
||||
stalled = false
|
||||
showBuffering = false
|
||||
snapshot = null
|
||||
queueSourceId = null
|
||||
@@ -1101,7 +1146,7 @@ class PlaybackController(
|
||||
lastPosition = actual
|
||||
if (actual != requested) queueRepository.setCursor(actual)
|
||||
updating = true
|
||||
player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs)
|
||||
player.setWindow(window.map { it.playerItem(snapshot) }, startIndex, startPositionMs)
|
||||
updating = false
|
||||
if (autoplay) {
|
||||
player.prepare()
|
||||
@@ -1141,7 +1186,7 @@ class PlaybackController(
|
||||
window.forEachIndexed { index, entry ->
|
||||
val local = downloads.localUri(entry.item.song.id) != null
|
||||
if (local != (uris[index]?.startsWith(FILE_SCHEME) == true)) {
|
||||
player.replaceItem(index, entry.item.toQueueItem())
|
||||
player.replaceItem(index, entry.playerItem(snapshot))
|
||||
replaced = true
|
||||
}
|
||||
}
|
||||
@@ -1256,8 +1301,8 @@ class PlaybackController(
|
||||
updating = true
|
||||
try {
|
||||
while (windowStart > desiredStart) {
|
||||
val item = queueRepository.itemAt(snapshot, windowStart - 1) ?: break
|
||||
player.addFirst(item.toQueueItem())
|
||||
val item = queueItemAt(snapshot, windowStart - 1) ?: break
|
||||
player.addFirst(item)
|
||||
windowStart--
|
||||
}
|
||||
while (windowStart < desiredStart) {
|
||||
@@ -1265,8 +1310,8 @@ class PlaybackController(
|
||||
windowStart++
|
||||
}
|
||||
while (windowEnd < desiredEnd) {
|
||||
val item = queueRepository.itemAt(snapshot, windowEnd + 1) ?: break
|
||||
player.addLast(item.toQueueItem())
|
||||
val item = queueItemAt(snapshot, windowEnd + 1) ?: break
|
||||
player.addLast(item)
|
||||
windowEnd++
|
||||
}
|
||||
while (windowEnd > desiredEnd) {
|
||||
@@ -1284,6 +1329,7 @@ class PlaybackController(
|
||||
position: Long,
|
||||
) {
|
||||
val player = player ?: return
|
||||
val snapshot = this.snapshot ?: return
|
||||
downloads.awaitLoaded()
|
||||
val ordered =
|
||||
if (offline) window.filter { downloads.localUri(it.item.song.id) != null } else window
|
||||
@@ -1297,7 +1343,7 @@ class PlaybackController(
|
||||
val current = ordered[index]
|
||||
val wantsLocal = downloads.localUri(current.item.song.id) != null
|
||||
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 }
|
||||
windowFiltered = offline
|
||||
windowStart = ordered.first().position
|
||||
@@ -1307,8 +1353,8 @@ class PlaybackController(
|
||||
try {
|
||||
repeat(player.currentIndex) { player.removeFirst() }
|
||||
while (player.itemCount > 1) player.removeLast()
|
||||
for (i in index - 1 downTo 0) player.addFirst(ordered[i].item.toQueueItem())
|
||||
for (i in index + 1 until ordered.size) player.addLast(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].playerItem(snapshot))
|
||||
} finally {
|
||||
updating = false
|
||||
}
|
||||
@@ -1450,6 +1496,17 @@ class PlaybackController(
|
||||
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? {
|
||||
val snapshot = snapshot ?: return null
|
||||
val play = position?.let { snapshot.anchorContextPlay(it) } ?: return null
|
||||
@@ -1460,21 +1517,42 @@ class PlaybackController(
|
||||
suspend fun sourceTitle(context: QueueContext?): String? = context?.let { queueRepository.sourceName(it.kind, it.sourceId, it.refId) }
|
||||
|
||||
private fun updateBuffering() {
|
||||
val player = player
|
||||
if (player?.isBuffering != true) {
|
||||
bufferingJob?.cancel()
|
||||
bufferingJob = null
|
||||
stallJob?.cancel()
|
||||
stallJob = null
|
||||
stalled = false
|
||||
showBuffering = false
|
||||
return
|
||||
}
|
||||
if (showBuffering || bufferingJob?.isActive == true) return
|
||||
bufferingJob =
|
||||
scope.launch {
|
||||
delay(BUFFERING_INDICATOR_DELAY_MS)
|
||||
if (player?.isBuffering == true) {
|
||||
showBuffering = true
|
||||
refresh()
|
||||
if (!showBuffering && bufferingJob?.isActive != true) {
|
||||
bufferingJob =
|
||||
scope.launch {
|
||||
delay(BUFFERING_INDICATOR_DELAY_MS)
|
||||
if (player.isBuffering) {
|
||||
showBuffering = true
|
||||
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()) {
|
||||
@@ -1541,6 +1619,7 @@ class PlaybackController(
|
||||
const val POSITION_TICK_MS = 500L
|
||||
const val POSITION_SAVE_INTERVAL_MS = 15_000L
|
||||
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
|
||||
const val STALL_WATCHDOG_MS = 15_000L
|
||||
const val QUEUE_WINDOW_RADIUS = 25L
|
||||
const val WINDOW_SHIFT_DELAY_MS = 400L
|
||||
const val RESTART_THRESHOLD_MS = 3_000L
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
@@ -13,8 +12,13 @@ import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.ExperimentalApi
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.DataSource
|
||||
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.exoplayer.ExoPlayer
|
||||
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.subtracks.MainActivity
|
||||
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.SourceRepository
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.core.context.GlobalContext
|
||||
import java.io.File
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class PlaybackService : MediaSessionService() {
|
||||
@@ -53,6 +62,9 @@ class PlaybackService : MediaSessionService() {
|
||||
private var starred = false
|
||||
private var starJob: Job? = null
|
||||
|
||||
private val gainProcessor: ReplayGainProcessor = ReplayGainProcessor { replayGain.applyItem(it) }
|
||||
private val replayGain: ReplayGainController = ReplayGainController { gainProcessor.setLinearGain(it) }
|
||||
|
||||
private val starCallback =
|
||||
object : MediaSession.Callback {
|
||||
override fun onConnect(
|
||||
@@ -90,9 +102,18 @@ class PlaybackService : MediaSessionService() {
|
||||
val sources = GlobalContext.get().get<SourceRepository>()
|
||||
sourceRepository = sources
|
||||
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 =
|
||||
ExoPlayer
|
||||
.Builder(this)
|
||||
.setRenderersFactory(ReplayGainRenderersFactory(this, gainProcessor))
|
||||
.setAudioAttributes(
|
||||
AudioAttributes
|
||||
.Builder()
|
||||
@@ -104,7 +125,13 @@ class PlaybackService : MediaSessionService() {
|
||||
.setMediaSourceFactory(
|
||||
DefaultMediaSourceFactory(this)
|
||||
.setEnableClippingInMediaPeriod(true)
|
||||
.setDataSourceFactory(mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()))),
|
||||
.setDataSourceFactory(
|
||||
mediaDataSourceFactory(
|
||||
this,
|
||||
cache,
|
||||
OkHttpDataSource.Factory(streamingClient()).setUserAgent(UserAgent.value),
|
||||
),
|
||||
),
|
||||
).build()
|
||||
exoPlayer.addListener(
|
||||
object : Player.Listener {
|
||||
@@ -116,6 +143,7 @@ class PlaybackService : MediaSessionService() {
|
||||
}
|
||||
},
|
||||
)
|
||||
val player = KnownDurationPlayer(exoPlayer)
|
||||
val loader =
|
||||
CoverArtBitmapLoader(
|
||||
applicationContext,
|
||||
@@ -125,7 +153,7 @@ class PlaybackService : MediaSessionService() {
|
||||
artworkLoader = loader
|
||||
session =
|
||||
MediaSession
|
||||
.Builder(this, exoPlayer)
|
||||
.Builder(this, player)
|
||||
.setSessionActivity(
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
@@ -183,12 +211,13 @@ class PlaybackService : MediaSessionService() {
|
||||
private fun streamingClient(): OkHttpClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.addInterceptor(Log.interceptor())
|
||||
.addInterceptor { chain ->
|
||||
val started = SystemClock.elapsedRealtime()
|
||||
val response = chain.proceed(chain.request())
|
||||
Log.d(
|
||||
STREAM_TAG,
|
||||
"stream range=${chain.request().header("Range")} code=${response.code} " +
|
||||
"stream",
|
||||
"range=${chain.request().header("Range")} code=${response.code} " +
|
||||
"contentRange=${response.header("Content-Range")} " +
|
||||
"length=${response.body.contentLength()} in ${SystemClock.elapsedRealtime() - started}ms",
|
||||
)
|
||||
@@ -208,13 +237,50 @@ class PlaybackService : MediaSessionService() {
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STREAM_TAG = "SubtracksPlayback"
|
||||
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)
|
||||
internal fun mediaDataSourceFactory(
|
||||
context: Context,
|
||||
cache: Cache?,
|
||||
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
|
||||
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.SongItem
|
||||
|
||||
fun SongItem.toQueueItem() =
|
||||
fun SongItem.toQueueItem(albumContext: Boolean = false) =
|
||||
QueueItem(
|
||||
id = song.id,
|
||||
title = song.title,
|
||||
artist = song.artist,
|
||||
artists = song.artistRefs,
|
||||
album = song.album,
|
||||
coverArtId = coverArt,
|
||||
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
|
||||
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -58,6 +59,7 @@ class ScrobblePolicy(
|
||||
item?.id != songId ||
|
||||
(item != null && item.id == lastItemId && positionMs < previousPosition && positionMs <= RESTART_POSITION_MS)
|
||||
if (restarted) {
|
||||
val alreadyAnnounced = nowPlayingSent && item?.id == songId
|
||||
songId = item?.id
|
||||
lastItemId = null
|
||||
playedMs = 0
|
||||
@@ -67,7 +69,7 @@ class ScrobblePolicy(
|
||||
if (item == null || !isPlaying) return null
|
||||
nowPlayingSent = true
|
||||
startedAt = now()
|
||||
return Scrobble.NowPlaying(item.id)
|
||||
return if (alreadyAnnounced) null else Scrobble.NowPlaying(item.id)
|
||||
}
|
||||
lastPositionMs = positionMs
|
||||
lastItemId = item?.id
|
||||
@@ -128,22 +130,28 @@ class Scrobbler(
|
||||
private suspend fun submit(event: Scrobble) {
|
||||
when (event) {
|
||||
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 -> {
|
||||
attempt { recordPlay(event.songId, event.time / 1000) }
|
||||
attempt { sink.scrobble(event.songId, event.time) }
|
||||
Log.i("scrobble", "submitting ${event.songId}")
|
||||
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 {
|
||||
block()
|
||||
} catch (cancellation: CancellationException) {
|
||||
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.navArgument
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
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.ItemContextMenu
|
||||
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.statusBarScrim
|
||||
import com.subtracks.ui.downloads.DownloadsRoute
|
||||
@@ -230,7 +236,8 @@ private fun MainNavigation() {
|
||||
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
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)
|
||||
var previousSourceId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||
@@ -312,7 +319,8 @@ private fun MainNavigation() {
|
||||
nowPlayingLauncher.openNowPlaying.collect {
|
||||
focusManager.clearFocus()
|
||||
contextMenuHost.dismissTransients()
|
||||
infoSong = null
|
||||
infoTarget = null
|
||||
artistChooser = null
|
||||
showingQueue = false
|
||||
if (navController.currentDestination?.route in DETAIL_ROUTES) {
|
||||
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))
|
||||
},
|
||||
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 ->
|
||||
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))
|
||||
},
|
||||
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 ->
|
||||
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))
|
||||
},
|
||||
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)) },
|
||||
onViewArtist = { artistId ->
|
||||
@@ -538,7 +570,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
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)) },
|
||||
onViewArtist = { artistId ->
|
||||
@@ -562,7 +602,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
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)) },
|
||||
onViewArtist = { artistId ->
|
||||
@@ -588,7 +636,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
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 ->
|
||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||
@@ -725,23 +781,69 @@ private fun MainNavigation() {
|
||||
target = target,
|
||||
actions = contextMenuHost.actions,
|
||||
onDismiss = { contextMenuHost.dismiss() },
|
||||
onInfo = { infoSong = it },
|
||||
onInfo = { infoTarget = it },
|
||||
onChooseArtists = { artistChooser = it },
|
||||
)
|
||||
}
|
||||
|
||||
infoSong?.let { 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 = { infoSong = null },
|
||||
genres = genres,
|
||||
artistChooser?.let { artists ->
|
||||
ArtistChooserSheet(
|
||||
artists = artists,
|
||||
onArtistClick = { artist ->
|
||||
artistChooser = null
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(artist.id),
|
||||
)
|
||||
},
|
||||
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,
|
||||
elevation: Dp = 0.dp,
|
||||
colorFilter: ColorFilter? = null,
|
||||
onError: (() -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalPlatformContext.current
|
||||
var failed by remember(ref, thumbnailRef) { mutableStateOf(false) }
|
||||
var thumbnailLoaded by remember(ref, thumbnailRef) { mutableStateOf(false) }
|
||||
var thumbnailRatio by remember(ref, thumbnailRef) { mutableStateOf(thumbnailRef?.cacheKey?.let(ArtworkRatioCache::get)) }
|
||||
val refKey = ref?.cacheKey
|
||||
val thumbnailKey = thumbnailRef?.cacheKey
|
||||
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 =
|
||||
if (showPlaceholder || (failed && !thumbnailLoaded)) {
|
||||
remember(name) { artworkColorsFromSeed(placeholderSeed(name)).scheme }
|
||||
@@ -102,7 +107,7 @@ fun CoverArt(
|
||||
.background(letterScheme?.primaryContainer ?: MaterialTheme.colorScheme.surfaceVariant)
|
||||
|
||||
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)) {
|
||||
CoverArtContent(
|
||||
ref = ref,
|
||||
@@ -113,6 +118,10 @@ fun CoverArt(
|
||||
thumbnailLoaded = thumbnailLoaded,
|
||||
onThumbnailLoaded = { thumbnailLoaded = true },
|
||||
onThumbnailRatio = { thumbnailRatio = it },
|
||||
onThumbnailError = {
|
||||
thumbnailFailed = true
|
||||
if (failed) onError?.invoke()
|
||||
},
|
||||
contentScale = ContentScale.Crop,
|
||||
colorFilter = colorFilter,
|
||||
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
||||
@@ -129,7 +138,10 @@ fun CoverArt(
|
||||
.ratioOrNull()
|
||||
?.let { ArtworkRatioCache.put(ref.cacheKey, it) }
|
||||
},
|
||||
onError = { failed = true },
|
||||
onError = {
|
||||
failed = true
|
||||
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
@@ -147,7 +159,7 @@ fun CoverArt(
|
||||
val painter =
|
||||
rememberAsyncImagePainter(
|
||||
model =
|
||||
remember(ref, thumbnailRef, target) {
|
||||
remember(refKey, thumbnailKey, target) {
|
||||
ref?.let {
|
||||
imageRequest(
|
||||
context,
|
||||
@@ -158,7 +170,10 @@ fun CoverArt(
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = { failed = true },
|
||||
onError = {
|
||||
failed = true
|
||||
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
|
||||
},
|
||||
contentScale = ContentScale.Fit,
|
||||
filterQuality = FilterQuality.High,
|
||||
)
|
||||
@@ -177,6 +192,10 @@ fun CoverArt(
|
||||
thumbnailLoaded = thumbnailLoaded,
|
||||
onThumbnailLoaded = { thumbnailLoaded = true },
|
||||
onThumbnailRatio = { thumbnailRatio = it },
|
||||
onThumbnailError = {
|
||||
thumbnailFailed = true
|
||||
if (failed) onError?.invoke()
|
||||
},
|
||||
contentScale = ContentScale.Fit,
|
||||
colorFilter = colorFilter,
|
||||
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
||||
@@ -206,6 +225,7 @@ private fun BoxScope.CoverArtContent(
|
||||
thumbnailLoaded: Boolean,
|
||||
onThumbnailLoaded: () -> Unit,
|
||||
onThumbnailRatio: (Float) -> Unit,
|
||||
onThumbnailError: () -> Unit,
|
||||
contentScale: ContentScale,
|
||||
colorFilter: ColorFilter?,
|
||||
placeholderContentColor: Color?,
|
||||
@@ -231,7 +251,7 @@ private fun BoxScope.CoverArtContent(
|
||||
}
|
||||
if (thumbnailRef != null && thumbnailRef != ref) {
|
||||
AsyncImage(
|
||||
model = remember(thumbnailRef) { imageRequest(context, thumbnailRef, crossfade = false) },
|
||||
model = remember(thumbnailRef.cacheKey) { imageRequest(context, thumbnailRef, crossfade = false) },
|
||||
contentDescription = null,
|
||||
contentScale = contentScale,
|
||||
colorFilter = colorFilter,
|
||||
@@ -244,6 +264,7 @@ private fun BoxScope.CoverArtContent(
|
||||
ArtworkRatioCache.put(thumbnailRef.cacheKey, it)
|
||||
}
|
||||
},
|
||||
onError = { onThumbnailError() },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,21 @@ import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.DownloadList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class PendingDownloadDelete(
|
||||
val name: String,
|
||||
@@ -18,6 +29,70 @@ data class PendingDownloadDelete(
|
||||
val bytes: Long,
|
||||
)
|
||||
|
||||
class PendingDownloadDeleteController internal constructor(
|
||||
private val scope: CoroutineScope,
|
||||
private val downloadedBytes: State<suspend (DownloadList, String) -> Long>,
|
||||
) {
|
||||
var pending by mutableStateOf<PendingDownloadDelete?>(null)
|
||||
private set
|
||||
|
||||
fun onAction(
|
||||
target: MenuTarget,
|
||||
action: BulkDownloadAction,
|
||||
onAction: (DownloadList, String, BulkDownloadAction) -> Unit,
|
||||
) {
|
||||
val (list, refId) = target.bulkRef() ?: return
|
||||
onAction(target.title, list, refId, action, onAction)
|
||||
}
|
||||
|
||||
fun onAction(
|
||||
name: String,
|
||||
list: DownloadList,
|
||||
refId: String,
|
||||
action: BulkDownloadAction,
|
||||
onAction: (DownloadList, String, BulkDownloadAction) -> Unit,
|
||||
) {
|
||||
if (action == BulkDownloadAction.Delete) {
|
||||
scope.launch {
|
||||
pending = PendingDownloadDelete(name, list, refId, downloadedBytes.value(list, refId))
|
||||
}
|
||||
} else {
|
||||
onAction(list, refId, action)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
pending = null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberPendingDownloadDelete(
|
||||
dismissals: Flow<Unit>,
|
||||
downloadedBytes: suspend (DownloadList, String) -> Long,
|
||||
): PendingDownloadDeleteController {
|
||||
val scope = rememberCoroutineScope()
|
||||
val currentDownloadedBytes = rememberUpdatedState(downloadedBytes)
|
||||
val controller = remember(scope) { PendingDownloadDeleteController(scope, currentDownloadedBytes) }
|
||||
DismissOnRequest(dismissals, controller::dismiss)
|
||||
return controller
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PendingDownloadDeleteConfirmation(
|
||||
controller: PendingDownloadDeleteController,
|
||||
onConfirm: (PendingDownloadDelete) -> Unit,
|
||||
) {
|
||||
controller.pending?.let { pending ->
|
||||
DeleteDownloadsDialog(
|
||||
name = pending.name,
|
||||
bytes = pending.bytes,
|
||||
onConfirm = { onConfirm(pending) },
|
||||
onDismiss = controller::dismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteDownloadsDialog(
|
||||
name: String,
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -47,11 +48,17 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.baseArtworkColors
|
||||
import com.subtracks.ui.theme.heroBarColor
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val FADE_DISTANCE_DP = 64
|
||||
|
||||
@@ -96,12 +104,14 @@ fun HeroDetailScaffold(
|
||||
header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit,
|
||||
content: LazyListScope.(rowModifier: Modifier) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentReady: Boolean = true,
|
||||
) {
|
||||
val effectiveArtwork = artwork ?: baseArtworkColors()
|
||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||
ArtworkTheme(effectiveArtwork) {
|
||||
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
val listState = rememberLazyListState()
|
||||
val listState = rememberRestorableLazyListState(contentReady)
|
||||
val scope = rememberCoroutineScope()
|
||||
val fill = rememberViewportFill(listState)
|
||||
val density = LocalDensity.current
|
||||
val screenHeightPx = with(density) { maxHeight.toPx() }
|
||||
@@ -111,9 +121,9 @@ fun HeroDetailScaffold(
|
||||
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
||||
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
|
||||
|
||||
var headerHeightPx by remember { mutableFloatStateOf(0f) }
|
||||
var rowHeightPx by remember { mutableFloatStateOf(0f) }
|
||||
var controlsTopPx by remember { mutableFloatStateOf(0f) }
|
||||
var headerHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
var rowHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
var controlsTopPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
val scrollPx by remember {
|
||||
derivedStateOf {
|
||||
val index = listState.firstVisibleItemIndex
|
||||
@@ -195,13 +205,39 @@ fun HeroDetailScaffold(
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
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
|
||||
private fun HeroSubtitle(
|
||||
text: String,
|
||||
@@ -248,11 +284,12 @@ fun HeroHeader(
|
||||
onDownloadAction: (BulkDownloadAction) -> Unit,
|
||||
onMore: () -> Unit,
|
||||
topInset: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
controlsModifier: Modifier = Modifier,
|
||||
thumbnailRef: CoverArtRef? = null,
|
||||
comment: String? = null,
|
||||
onSubtitleClick: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
nameMaxLines: Int = 2,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
@@ -274,7 +311,7 @@ fun HeroHeader(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
maxLines = nameMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitleIsTarget = onSubtitleClick != null && subtitle.isNotEmpty() && comment.isNullOrBlank()
|
||||
@@ -286,7 +323,11 @@ fun HeroHeader(
|
||||
modifier =
|
||||
Modifier
|
||||
.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,
|
||||
) {
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.rounded.Album
|
||||
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.unit.dp
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.DownloadList
|
||||
@@ -77,7 +82,7 @@ sealed interface MenuTarget {
|
||||
) : MenuTarget {
|
||||
override val title: String get() = song.title
|
||||
|
||||
override val subtitle: String? get() = song.artist
|
||||
override val subtitle: String? get() = song.artistSubtitle()
|
||||
}
|
||||
|
||||
data class Album(
|
||||
@@ -194,7 +199,8 @@ fun ItemContextMenu(
|
||||
target: MenuTarget,
|
||||
actions: ItemActions,
|
||||
onDismiss: () -> Unit,
|
||||
onInfo: ((SongModel) -> Unit)? = null,
|
||||
onInfo: ((MenuTarget) -> Unit)? = null,
|
||||
onChooseArtists: ((List<ArtistRef>) -> Unit)? = null,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -207,6 +213,11 @@ fun ItemContextMenu(
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceWith(after: () -> Unit) {
|
||||
onDismiss()
|
||||
after()
|
||||
}
|
||||
|
||||
val queueRef = target.queueRef()
|
||||
|
||||
@Composable
|
||||
@@ -226,76 +237,75 @@ fun ItemContextMenu(
|
||||
dragHandle = null,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
MenuHeader(target)
|
||||
when (target) {
|
||||
is MenuTarget.Song -> {
|
||||
queueItems()
|
||||
DownloadItem(target.download, target.song, actions, ::dismiss)
|
||||
StarItem(
|
||||
current = target.song.starred,
|
||||
onSet = { starring -> actions.setStar(StarType.Song, target.song.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
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) }
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = MENU_MAX_HEIGHT)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
MenuHeader(target)
|
||||
when (target) {
|
||||
is MenuTarget.Song -> {
|
||||
queueItems()
|
||||
DownloadItem(target.download, target.song, actions, ::dismiss)
|
||||
StarItem(
|
||||
current = target.song.starred,
|
||||
onSet = { starring -> actions.setStar(StarType.Song, target.song.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
target.song.artistId?.let { artistId ->
|
||||
actions.viewArtist?.let { view ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
target.song.artist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artistId) }
|
||||
}
|
||||
ArtistItems(target.song.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||
}
|
||||
}
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target.song) } }
|
||||
}
|
||||
}
|
||||
|
||||
is MenuTarget.Album -> {
|
||||
queueItems()
|
||||
BulkDownloadItem(target, actions, ::dismiss)
|
||||
StarItem(
|
||||
current = target.album.starred,
|
||||
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
target.album.artistId?.let { artistId ->
|
||||
actions.viewArtist?.let { view ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
target.album.albumArtist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artistId) }
|
||||
}
|
||||
is MenuTarget.Album -> {
|
||||
queueItems()
|
||||
BulkDownloadItem(target, actions, ::dismiss)
|
||||
StarItem(
|
||||
current = target.album.starred,
|
||||
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
ArtistItems(target.album.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||
}
|
||||
}
|
||||
|
||||
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) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
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
|
||||
private fun DownloadItem(
|
||||
download: SongDownload?,
|
||||
@@ -428,11 +487,19 @@ private fun MenuItem(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
tint: Color? = null,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(label) },
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
leadingContent = { Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) },
|
||||
trailingContent = trailing,
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
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)
|
||||
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
@@ -28,6 +32,8 @@ import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
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.unit.dp
|
||||
import com.subtracks.R
|
||||
@@ -43,6 +49,12 @@ fun SearchField(
|
||||
) {
|
||||
val hint = stringResource(R.string.search_input_placeholder)
|
||||
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() }
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
@@ -62,8 +74,11 @@ fun SearchField(
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
value = textFieldValue,
|
||||
onValueChange = {
|
||||
textFieldValue = it
|
||||
onValueChange(it.text)
|
||||
},
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
@@ -73,7 +88,7 @@ fun SearchField(
|
||||
.focusRequester(focusRequester)
|
||||
.semantics { contentDescription = hint },
|
||||
decorationBox = { inner ->
|
||||
if (value.isEmpty()) {
|
||||
if (textFieldValue.text.isEmpty()) {
|
||||
Text(
|
||||
text = hint,
|
||||
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 androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.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
|
||||
@@ -18,8 +13,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -53,8 +46,20 @@ fun SongInfoDialog(
|
||||
title = { Text(stringResource(R.string.info)) },
|
||||
text = {
|
||||
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_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.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
|
||||
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
|
||||
private fun downloadStatus(status: DownloadStatus): String =
|
||||
when (status) {
|
||||
|
||||
@@ -88,3 +88,34 @@ fun rememberViewportFill(state: LazyGridState): Dp {
|
||||
}
|
||||
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.material.icons.Icons
|
||||
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.Delete
|
||||
import androidx.compose.material.icons.rounded.ExpandLess
|
||||
import androidx.compose.material.icons.rounded.ExpandMore
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -274,7 +274,7 @@ private fun NodeRow(
|
||||
leadingContent = {
|
||||
IconButton(onClick = onToggle) {
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ data class DownloadArtistNode(
|
||||
val name: String,
|
||||
val bytes: Long,
|
||||
val albums: List<DownloadAlbumNode>,
|
||||
val sortName: String = name,
|
||||
)
|
||||
|
||||
data class DownloadAlbumNode(
|
||||
@@ -38,6 +39,7 @@ data class DownloadAlbumNode(
|
||||
val name: String,
|
||||
val bytes: Long,
|
||||
val songs: List<DownloadedSong>,
|
||||
val sortName: String = name,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -105,6 +107,7 @@ internal fun buildTree(
|
||||
id = artistKey.first,
|
||||
name = artistKey.second.ifBlank { unknownArtist },
|
||||
bytes = artistSongs.sumOf { it.size },
|
||||
sortName = artistSongs.first().artistSort.ifBlank { artistKey.second },
|
||||
albums =
|
||||
artistSongs
|
||||
.groupBy { it.albumId.orEmpty() to it.albumName.orEmpty() }
|
||||
@@ -113,9 +116,10 @@ internal fun buildTree(
|
||||
id = albumKey.first,
|
||||
name = albumKey.second.ifBlank { unknownAlbum },
|
||||
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.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -31,11 +33,12 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -54,13 +57,17 @@ import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.source.StarType
|
||||
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.CoverArt
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.ShuffleFab
|
||||
import com.subtracks.ui.library.PlaylistsContent
|
||||
import com.subtracks.ui.library.SongRow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
@@ -103,6 +110,7 @@ fun HomeListRoute(
|
||||
genres = genres,
|
||||
decades = decades,
|
||||
coverArt = viewModel::coverArt,
|
||||
artistArt = { viewModel.artistArt(it) },
|
||||
playingSongId = playback.item?.id,
|
||||
onBack = onBack,
|
||||
onAlbumClick = onAlbumClick,
|
||||
@@ -110,6 +118,7 @@ fun HomeListRoute(
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
onSongClick = viewModel::play,
|
||||
onLongClick = { contextMenuHost?.show(it, actions) },
|
||||
onShuffle = if (viewModel.canShuffle) viewModel::shuffle else null,
|
||||
onGenreClick = onGenreClick,
|
||||
onDecadeClick = onDecadeClick,
|
||||
)
|
||||
@@ -123,9 +132,11 @@ fun HomeListScreen(
|
||||
artists: LazyPagingItems<Artist>,
|
||||
songs: LazyPagingItems<AlbumSongItem>,
|
||||
playlists: LazyPagingItems<Playlist>,
|
||||
modifier: Modifier = Modifier,
|
||||
genres: List<String> = emptyList(),
|
||||
decades: List<Long> = emptyList(),
|
||||
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
playingSongId: String? = null,
|
||||
onBack: () -> Unit = {},
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
@@ -133,12 +144,14 @@ fun HomeListScreen(
|
||||
onPlaylistClick: (Playlist) -> Unit = {},
|
||||
onSongClick: (Song) -> Unit = {},
|
||||
onLongClick: (MenuTarget) -> Unit = {},
|
||||
onShuffle: (() -> Unit)? = null,
|
||||
onGenreClick: (String) -> Unit = {},
|
||||
onDecadeClick: (Long) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val now = remember { System.currentTimeMillis() }
|
||||
val resources = LocalContext.current.resources
|
||||
val resources = LocalResources.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val songListState = rememberLazyListState()
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
@@ -149,8 +162,21 @@ fun HomeListScreen(
|
||||
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 ->
|
||||
val downloaded = request.downloaded
|
||||
if (downloaded != null) {
|
||||
@@ -169,7 +195,7 @@ fun HomeListScreen(
|
||||
OfflineListKind.Artists -> {
|
||||
ArtistsList(
|
||||
artists = artists,
|
||||
coverArt = coverArt,
|
||||
artistArt = artistArt,
|
||||
info = { artistListInfo(request, it, now, resources) },
|
||||
onArtistClick = onArtistClick,
|
||||
onLongClick = onLongClick,
|
||||
@@ -184,6 +210,7 @@ fun HomeListScreen(
|
||||
playingSongId = playingSongId,
|
||||
onSongClick = onSongClick,
|
||||
onLongClick = onLongClick,
|
||||
listState = songListState,
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
}
|
||||
@@ -222,7 +249,7 @@ fun HomeListScreen(
|
||||
HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> {
|
||||
ArtistsList(
|
||||
artists = artists,
|
||||
coverArt = coverArt,
|
||||
artistArt = artistArt,
|
||||
info = { artistListInfo(request, it, now, resources) },
|
||||
onArtistClick = onArtistClick,
|
||||
onLongClick = onLongClick,
|
||||
@@ -237,6 +264,7 @@ fun HomeListScreen(
|
||||
playingSongId = playingSongId,
|
||||
onSongClick = onSongClick,
|
||||
onLongClick = onLongClick,
|
||||
listState = songListState,
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
}
|
||||
@@ -254,6 +282,7 @@ fun HomeListScreen(
|
||||
playingSongId = playingSongId,
|
||||
onSongClick = onSongClick,
|
||||
onLongClick = onLongClick,
|
||||
listState = songListState,
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
} else {
|
||||
@@ -297,6 +326,7 @@ private fun SongsList(
|
||||
playingSongId: String?,
|
||||
onSongClick: (Song) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
listState: LazyListState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (songs.itemCount == 0 && songs.loadState.refresh is LoadState.Loading) {
|
||||
@@ -304,8 +334,9 @@ private fun SongsList(
|
||||
return
|
||||
}
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
contentPadding = PaddingValues(bottom = 88.dp),
|
||||
) {
|
||||
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
|
||||
val item = songs[index]
|
||||
@@ -416,7 +447,7 @@ internal fun AlbumListRow(
|
||||
@Composable
|
||||
private fun ArtistsList(
|
||||
artists: LazyPagingItems<Artist>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistArt: (Artist) -> ArtistArt,
|
||||
info: (Artist) -> String?,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
@@ -433,12 +464,13 @@ private fun ArtistsList(
|
||||
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
||||
val artist = artists[index]
|
||||
if (artist != null) {
|
||||
val art = artistArt(artist)
|
||||
ArtistListRow(
|
||||
artist = artist,
|
||||
coverArt = coverArt,
|
||||
art = art,
|
||||
info = info(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
|
||||
internal fun ArtistListRow(
|
||||
artist: Artist,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
art: ArtistArt,
|
||||
info: String?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
@@ -461,9 +493,9 @@ internal fun ArtistListRow(
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CoverArt(
|
||||
ref = coverArt(artist.coverArt, true),
|
||||
name = artist.name,
|
||||
ArtistIcon(
|
||||
artist = artist,
|
||||
art = art,
|
||||
modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape),
|
||||
)
|
||||
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.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class HomeListRequest(
|
||||
val title: String,
|
||||
@@ -51,6 +55,11 @@ class HomeListViewModel(
|
||||
) : ViewModel() {
|
||||
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>> =
|
||||
sourceId
|
||||
.flatMapLatest { id ->
|
||||
@@ -149,6 +158,11 @@ class HomeListViewModel(
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
fun artistArt(
|
||||
artist: Artist,
|
||||
forIcon: Boolean = true,
|
||||
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||
|
||||
fun play(song: Song) {
|
||||
val genre = request.genre
|
||||
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.source.StarType
|
||||
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.CoverArt
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
@@ -137,6 +139,7 @@ fun HomeRoute(
|
||||
playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(),
|
||||
songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value,
|
||||
coverArt = viewModel::coverArt,
|
||||
artistArt = { viewModel.artistArt(it) },
|
||||
playingSongId = playback.item?.id,
|
||||
topInset = topInset,
|
||||
bottomInset = bottomInset,
|
||||
@@ -152,6 +155,7 @@ fun HomeRoute(
|
||||
HomeScreen(
|
||||
feed = feed,
|
||||
coverArt = viewModel::coverArt,
|
||||
artistArt = { viewModel.artistArt(it) },
|
||||
playingSongId = playback.item?.id,
|
||||
topInset = topInset,
|
||||
bottomInset = bottomInset,
|
||||
@@ -172,6 +176,8 @@ fun HomeScreen(
|
||||
feed: HomeFeed,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
playingSongId: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
topInset: Dp = 0.dp,
|
||||
bottomInset: Dp = 0.dp,
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
@@ -182,7 +188,6 @@ fun HomeScreen(
|
||||
onGenreClick: (String) -> Unit = {},
|
||||
onDecadeClick: (Long) -> Unit = {},
|
||||
onSync: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||
if (feed.loading) {
|
||||
@@ -215,6 +220,7 @@ fun HomeScreen(
|
||||
HomeSection.RecentlyPlayedArtists,
|
||||
feed.recentlyPlayedArtists,
|
||||
coverArt,
|
||||
artistArt,
|
||||
onArtistClick,
|
||||
onLongClick,
|
||||
onMore,
|
||||
@@ -249,6 +255,7 @@ fun HomeScreen(
|
||||
HomeSection.MostPlayedArtists,
|
||||
feed.mostPlayedArtists,
|
||||
coverArt,
|
||||
artistArt,
|
||||
onArtistClick,
|
||||
onLongClick,
|
||||
onMore,
|
||||
@@ -295,6 +302,8 @@ fun OfflineHomeScreen(
|
||||
songs: List<AlbumSongItem>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
playingSongId: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
topInset: Dp = 0.dp,
|
||||
bottomInset: Dp = 0.dp,
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
@@ -303,7 +312,6 @@ fun OfflineHomeScreen(
|
||||
onSongPlay: (Song) -> Unit = {},
|
||||
onLongClick: (MenuTarget) -> Unit = {},
|
||||
onMore: (OfflineListKind) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||
LazyColumn(
|
||||
@@ -312,7 +320,7 @@ fun OfflineHomeScreen(
|
||||
) {
|
||||
homeTitle()
|
||||
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) }
|
||||
songList(
|
||||
key = "downloaded-songs",
|
||||
@@ -364,7 +372,7 @@ private fun LazyListScope.downloadedAlbumRow(
|
||||
|
||||
private fun LazyListScope.downloadedArtistRow(
|
||||
artists: LazyPagingItems<Artist>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistArt: (Artist) -> ArtistArt,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
onMore: () -> Unit,
|
||||
@@ -382,12 +390,12 @@ private fun LazyListScope.downloadedArtistRow(
|
||||
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
||||
val artist = artists[index]
|
||||
if (artist != null) {
|
||||
val ref = coverArt(artist.coverArt, true)
|
||||
val art = artistArt(artist)
|
||||
ArtistTile(
|
||||
artist = artist,
|
||||
ref = ref,
|
||||
art = art,
|
||||
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,
|
||||
artists: List<Artist>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistArt: (Artist) -> ArtistArt,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
onMore: (HomeSection) -> Unit,
|
||||
@@ -625,12 +634,12 @@ private fun LazyListScope.artistRow(
|
||||
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
|
||||
) {
|
||||
items(artists, key = { it.id }) { artist ->
|
||||
val ref = coverArt(artist.coverArt, true)
|
||||
val art = artistArt(artist)
|
||||
ArtistTile(
|
||||
artist = artist,
|
||||
ref = ref,
|
||||
art = art,
|
||||
onClick = { onArtistClick(artist) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -779,7 +788,7 @@ private fun AlbumTile(
|
||||
@Composable
|
||||
private fun ArtistTile(
|
||||
artist: Artist,
|
||||
ref: CoverArtRef?,
|
||||
art: ArtistArt,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
@@ -787,9 +796,9 @@ private fun ArtistTile(
|
||||
modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
CoverArt(
|
||||
ref = ref,
|
||||
name = artist.name,
|
||||
ArtistIcon(
|
||||
artist = artist,
|
||||
art = art,
|
||||
modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape),
|
||||
)
|
||||
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.SyncStatus
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -25,7 +27,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -53,11 +54,6 @@ class HomeViewModel(
|
||||
private val syncManager: SyncManager,
|
||||
private val playbackController: PlaybackController,
|
||||
) : ViewModel() {
|
||||
val syncing: StateFlow<Boolean> =
|
||||
syncManager.status
|
||||
.map { it == SyncStatus.Running }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
val offline: StateFlow<Boolean> =
|
||||
sourceRepository.offline.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
@@ -133,6 +129,11 @@ class HomeViewModel(
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
fun artistArt(
|
||||
artist: Artist,
|
||||
forIcon: Boolean = true,
|
||||
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||
|
||||
fun playStarred(song: Song) {
|
||||
viewModelScope.playStarredList(libraryRepository, playbackController, song)
|
||||
}
|
||||
@@ -141,7 +142,9 @@ class HomeViewModel(
|
||||
viewModelScope.playDownloadedList(libraryRepository, playbackController, song)
|
||||
}
|
||||
|
||||
fun sync() = syncManager.requestSync()
|
||||
fun sync() {
|
||||
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
||||
}
|
||||
|
||||
private data class PlayRows(
|
||||
val recentAlbums: List<Album>,
|
||||
@@ -217,7 +220,7 @@ internal fun CoroutineScope.playGenreList(
|
||||
song: Song,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -30,24 +29,26 @@ import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Disc
|
||||
import com.subtracks.data.model.DownloadList
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistChooserSheet
|
||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||
import com.subtracks.ui.components.DismissOnRequest
|
||||
import com.subtracks.ui.components.HeroDetailScaffold
|
||||
import com.subtracks.ui.components.HeroHeader
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
|
||||
import com.subtracks.ui.components.rememberPendingDownloadDelete
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.rememberArtworkColors
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
@@ -66,24 +67,23 @@ fun AlbumDetailRoute(
|
||||
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
|
||||
playbackController: PlaybackController = koinInject(),
|
||||
) {
|
||||
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
|
||||
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val album by viewModel.album.collectAsStateWithLifecycle()
|
||||
val songs by viewModel.songs.collectAsStateWithLifecycle()
|
||||
val discs by viewModel.discs.collectAsStateWithLifecycle()
|
||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||
val scope = rememberCoroutineScope()
|
||||
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
||||
val dismissals = contextMenuHost?.dismissals ?: emptyFlow()
|
||||
val pendingDelete = rememberPendingDownloadDelete(dismissals) { _, _ -> viewModel.downloadedBytes() }
|
||||
var showArtistChooser by remember { mutableStateOf(false) }
|
||||
DismissOnRequest(dismissals) { showArtistChooser = false }
|
||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||
val context = playback.context
|
||||
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
|
||||
if (action == BulkDownloadAction.Delete) {
|
||||
scope.launch { pendingDelete = viewModel.downloadedBytes() }
|
||||
} else {
|
||||
viewModel.onDownloadAction(action)
|
||||
pendingDelete.onAction(album?.name.orEmpty(), DownloadList.Album, albumId, action) { _, _, requested ->
|
||||
viewModel.onDownloadAction(requested)
|
||||
}
|
||||
}
|
||||
val actions =
|
||||
@@ -126,18 +126,28 @@ fun AlbumDetailRoute(
|
||||
},
|
||||
onArtistClick =
|
||||
album
|
||||
?.takeIf { !it.albumArtist.isNullOrBlank() }
|
||||
?.let { it.artistId }
|
||||
?.let { id -> { onViewArtist(id) } },
|
||||
?.takeIf { it.artistRefs.isNotEmpty() }
|
||||
?.let {
|
||||
{
|
||||
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 },
|
||||
)
|
||||
|
||||
pendingDelete?.let { bytes ->
|
||||
DeleteDownloadsDialog(
|
||||
name = album?.name.orEmpty(),
|
||||
bytes = bytes,
|
||||
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
|
||||
onDismiss = { pendingDelete = null },
|
||||
PendingDownloadDeleteConfirmation(pendingDelete) {
|
||||
viewModel.onDownloadAction(BulkDownloadAction.Delete)
|
||||
}
|
||||
|
||||
if (showArtistChooser) {
|
||||
ArtistChooserSheet(
|
||||
artists = album?.artistRefs.orEmpty(),
|
||||
onArtistClick = { artist ->
|
||||
showArtistChooser = false
|
||||
onViewArtist(artist.id)
|
||||
},
|
||||
onDismiss = { showArtistChooser = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -146,6 +156,7 @@ fun AlbumDetailRoute(
|
||||
fun AlbumDetailScreen(
|
||||
album: Album?,
|
||||
songs: List<Song>,
|
||||
modifier: Modifier = Modifier,
|
||||
discs: List<Disc> = emptyList(),
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artwork: ArtworkColors?,
|
||||
@@ -160,7 +171,6 @@ fun AlbumDetailScreen(
|
||||
onMore: () -> Unit = {},
|
||||
onArtistClick: (() -> Unit)? = null,
|
||||
playingSongId: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val multiDisc = songs.map { it.disc ?: 1L }.distinct().size > 1
|
||||
val resources = LocalResources.current
|
||||
@@ -195,6 +205,7 @@ fun AlbumDetailScreen(
|
||||
artwork = artwork,
|
||||
title = album?.name.orEmpty(),
|
||||
onBack = onBack,
|
||||
contentReady = songs.isNotEmpty(),
|
||||
header = { controlsModifier, topInset ->
|
||||
val artistName = album?.albumArtist?.takeIf { it.isNotBlank() }
|
||||
HeroHeader(
|
||||
@@ -215,6 +226,7 @@ fun AlbumDetailScreen(
|
||||
onMore = onMore,
|
||||
topInset = topInset,
|
||||
controlsModifier = controlsModifier,
|
||||
nameMaxLines = Int.MAX_VALUE,
|
||||
)
|
||||
},
|
||||
content = { rowModifier ->
|
||||
|
||||
@@ -15,7 +15,6 @@ import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -35,13 +34,20 @@ class AlbumDetailViewModel(
|
||||
) : ViewModel() {
|
||||
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 }
|
||||
.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()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.rememberLazyGridState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -37,17 +39,21 @@ import com.subtracks.ui.components.ListDownloadIndicator
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.ResetScrollOnChange
|
||||
import com.subtracks.ui.components.ScrollToTopOnRequest
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
||||
|
||||
const val ALBUM_COVER_TAG = "album-cover"
|
||||
|
||||
const val ALBUM_PLACEHOLDER_TAG = "album-placeholder"
|
||||
|
||||
@Composable
|
||||
fun AlbumsContent(
|
||||
items: LazyPagingItems<Album>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
bottomInset: Dp,
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onLongClick: (MenuTarget) -> Unit = {},
|
||||
filtered: Boolean = false,
|
||||
onClearFilters: () -> Unit = {},
|
||||
@@ -55,7 +61,7 @@ fun AlbumsContent(
|
||||
topInset: Dp = 0.dp,
|
||||
onSync: () -> Unit = {},
|
||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||
modifier: Modifier = Modifier,
|
||||
scrollToTopKey: Any? = null,
|
||||
) {
|
||||
when {
|
||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||
@@ -78,6 +84,7 @@ fun AlbumsContent(
|
||||
else -> {
|
||||
val gridState = rememberLazyGridState()
|
||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { gridState.scrollToItem(0) }
|
||||
ScrollToTopOnRequest(scrollToTopKey, gridState)
|
||||
val fill = rememberViewportFill(gridState)
|
||||
LazyVerticalGrid(
|
||||
state = gridState,
|
||||
@@ -114,6 +121,16 @@ fun AlbumsContent(
|
||||
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) }) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,9 @@ package com.subtracks.ui.library
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.subtracks.data.model.Album
|
||||
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.CoverArtRef
|
||||
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.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -36,21 +38,26 @@ class ArtistDetailViewModel(
|
||||
) : ViewModel() {
|
||||
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?> =
|
||||
sourceId.flatMapLatest { id ->
|
||||
combine(
|
||||
libraryRepository.artist(id, artistId),
|
||||
libraryRepository.artistName(id, artistId),
|
||||
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
|
||||
}
|
||||
val artistName: StateFlow<String?> =
|
||||
sourceId
|
||||
.flatMapLatest { id ->
|
||||
combine(
|
||||
libraryRepository.artist(id, artistId),
|
||||
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 albums: Flow<List<Album>> =
|
||||
val albums: StateFlow<ArtistAlbums?> =
|
||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val downloadStatus: StateFlow<ListDownloadStatus> =
|
||||
sourceId
|
||||
@@ -62,6 +69,55 @@ class ArtistDetailViewModel(
|
||||
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
|
||||
.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(
|
||||
list: DownloadList,
|
||||
refId: String,
|
||||
@@ -84,30 +140,36 @@ class ArtistDetailViewModel(
|
||||
private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null)
|
||||
val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail
|
||||
|
||||
private val _heroArt = MutableStateFlow<ArtistArt?>(null)
|
||||
val heroArt: StateFlow<ArtistArt?> = _heroArt
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
artist
|
||||
sourceRepository.activeSource
|
||||
.filterNotNull()
|
||||
.collectLatest { loaded ->
|
||||
val (full, thumbnail) = resolveArt(loaded)
|
||||
_art.value = full
|
||||
_artThumbnail.value = thumbnail
|
||||
.flatMapLatest { sourceId ->
|
||||
combine(
|
||||
libraryRepository.artist(sourceId, artistId),
|
||||
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(
|
||||
coverArt: String?,
|
||||
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.CoverArtRef
|
||||
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.FilteredEmptyState
|
||||
import com.subtracks.ui.components.ListDownloadIndicator
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.ResetScrollOnChange
|
||||
import com.subtracks.ui.components.ScrollToTopOnRequest
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
||||
|
||||
@@ -45,6 +47,7 @@ fun ArtistsContent(
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
bottomInset: Dp,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onLongClick: (MenuTarget) -> Unit = {},
|
||||
filtered: Boolean = false,
|
||||
onClearFilters: () -> Unit = {},
|
||||
@@ -52,7 +55,8 @@ fun ArtistsContent(
|
||||
topInset: Dp = 0.dp,
|
||||
onSync: () -> Unit = {},
|
||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||
modifier: Modifier = Modifier,
|
||||
scrollToTopKey: Any? = null,
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
) {
|
||||
when {
|
||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||
@@ -70,6 +74,7 @@ fun ArtistsContent(
|
||||
else -> {
|
||||
val listState = rememberLazyListState()
|
||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
||||
ScrollToTopOnRequest(scrollToTopKey, listState)
|
||||
val fill = rememberViewportFill(listState)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
@@ -79,7 +84,8 @@ fun ArtistsContent(
|
||||
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||
val artist = items[index]
|
||||
if (artist != null) {
|
||||
PrefetchArtworkSeeds(coverArt(artist.coverArt, true))
|
||||
val art = artistArt(artist)
|
||||
PrefetchArtworkSeeds(art.primary)
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
@@ -101,9 +107,9 @@ fun ArtistsContent(
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
CoverArt(
|
||||
ref = coverArt(artist.coverArt, true),
|
||||
name = artist.name,
|
||||
ArtistIcon(
|
||||
artist = artist,
|
||||
art = art,
|
||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||
)
|
||||
},
|
||||
@@ -114,7 +120,7 @@ fun ArtistsContent(
|
||||
Modifier.combinedClickable(
|
||||
onClick = { onArtistClick(artist) },
|
||||
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),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.Search
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
@@ -63,13 +68,11 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
@@ -91,12 +94,15 @@ import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
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.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.paging.PagingData
|
||||
@@ -117,14 +123,14 @@ import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.StarredFilter
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||
import com.subtracks.ui.components.DismissOnRequest
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.PendingDownloadDelete
|
||||
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
|
||||
import com.subtracks.ui.components.SearchField
|
||||
import com.subtracks.ui.components.bulkRef
|
||||
import com.subtracks.ui.components.rememberPendingDownloadDelete
|
||||
import com.subtracks.ui.components.statusBarScrim
|
||||
import com.subtracks.ui.home.HomeRoute
|
||||
import com.subtracks.ui.home.HomeSection
|
||||
@@ -135,7 +141,6 @@ import com.subtracks.ui.theme.playerSurfaceColor
|
||||
import com.subtracks.ui.theme.rememberArtworkColors
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import kotlin.math.abs
|
||||
@@ -243,6 +248,7 @@ fun LibraryRoute(
|
||||
var previousTabName by remember { mutableStateOf(selectedTab.name) }
|
||||
val previousTab = LibraryTab.entries.firstOrNull { it.name == previousTabName } ?: LibraryTab.Albums
|
||||
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
||||
val refreshing by viewModel.refreshing.collectAsStateWithLifecycle()
|
||||
val listTab = selectedTab.listTab()
|
||||
LaunchedEffect(selectedTab) {
|
||||
if (previousTab != selectedTab) {
|
||||
@@ -269,25 +275,13 @@ fun LibraryRoute(
|
||||
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true), fallbackName = playback.item?.title)
|
||||
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
|
||||
val downloads = downloadsState?.value ?: emptyMap()
|
||||
val scope = rememberCoroutineScope()
|
||||
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
|
||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow(), viewModel::downloadedBytes)
|
||||
val itemActions =
|
||||
ItemActions(
|
||||
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
||||
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
||||
bulkDownload = { target, action ->
|
||||
target.bulkRef()?.let { (list, refId) ->
|
||||
if (action == BulkDownloadAction.Delete) {
|
||||
scope.launch {
|
||||
pendingDelete =
|
||||
PendingDownloadDelete(target.title, list, refId, viewModel.downloadedBytes(list, refId))
|
||||
}
|
||||
} else {
|
||||
viewModel.onDownloadAction(list, refId, action)
|
||||
}
|
||||
}
|
||||
},
|
||||
bulkDownload = { target, action -> pendingDelete.onAction(target, action, viewModel::onDownloadAction) },
|
||||
setStar = setStar,
|
||||
viewAlbum = onViewAlbum,
|
||||
viewArtist = onViewArtist,
|
||||
@@ -296,19 +290,24 @@ fun LibraryRoute(
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = { tab -> if (viewModel.selectedTab.value != tab) viewModel.selectTab(tab) },
|
||||
syncing = syncing,
|
||||
refreshing = refreshing,
|
||||
bottomInset = bottomInset,
|
||||
artwork = artwork,
|
||||
albums = viewModel.albums,
|
||||
artists = viewModel.artists,
|
||||
playlists = viewModel.playlists,
|
||||
coverArt = viewModel::coverArt,
|
||||
artistArt = { viewModel.artistArt(it) },
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
onSync = viewModel::sync,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onItemLongClick = { contextMenuHost?.show(it, itemActions) },
|
||||
onItemLongClick = {
|
||||
focusManager.clearFocus()
|
||||
contextMenuHost?.show(it, itemActions)
|
||||
},
|
||||
listQuery = displayQuery,
|
||||
resetKeys = resetKeys,
|
||||
offline = offline,
|
||||
@@ -362,13 +361,8 @@ fun LibraryRoute(
|
||||
},
|
||||
)
|
||||
|
||||
pendingDelete?.let { pending ->
|
||||
DeleteDownloadsDialog(
|
||||
name = pending.name,
|
||||
bytes = pending.bytes,
|
||||
onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) },
|
||||
onDismiss = { pendingDelete = null },
|
||||
)
|
||||
PendingDownloadDeleteConfirmation(pendingDelete) { pending ->
|
||||
viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,11 +380,14 @@ fun LibraryScreen(
|
||||
onPlaylistClick: (Playlist) -> Unit,
|
||||
onSync: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
onOpenSearch: () -> Unit = {},
|
||||
offline: Boolean = false,
|
||||
onExitOffline: () -> Unit = {},
|
||||
onItemLongClick: (MenuTarget) -> Unit = {},
|
||||
syncing: Boolean = false,
|
||||
refreshing: Boolean = false,
|
||||
bottomInset: Dp = 0.dp,
|
||||
listQuery: ListQuery = ListQuery(""),
|
||||
resetKeys: Map<LibraryTab, Any?> = emptyMap(),
|
||||
@@ -409,7 +406,6 @@ fun LibraryScreen(
|
||||
playlistDownloads: Map<String, ListDownloadStatus> = emptyMap(),
|
||||
dismissals: Flow<Unit> = emptyFlow(),
|
||||
homeContent: @Composable (topInset: Dp, bottomInset: Dp) -> Unit = { _, _ -> },
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val pagerState =
|
||||
rememberPagerState(
|
||||
@@ -418,10 +414,16 @@ fun LibraryScreen(
|
||||
)
|
||||
var showOptions by rememberSaveable { mutableStateOf(false) }
|
||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||
var topRequest by remember { mutableIntStateOf(0) }
|
||||
val searchFocus = remember { FocusRequester() }
|
||||
val closeSearch = {
|
||||
searchActive = false
|
||||
onSearchChange("")
|
||||
}
|
||||
|
||||
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
|
||||
DismissOnRequest(dismissals) { showOptions = false }
|
||||
BackHandler(enabled = searchActive, onBack = closeSearch)
|
||||
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.settledPage }.collect { page ->
|
||||
@@ -440,14 +442,8 @@ fun LibraryScreen(
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissSearch() {
|
||||
if (searchActive) {
|
||||
searchActive = false
|
||||
onSearchChange("")
|
||||
}
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||
val filtersActive = listQuery.starred != StarredFilter.Any || listQuery.downloaded || search.isNotEmpty()
|
||||
var tabBarHeightPx by remember { mutableFloatStateOf(with(density) { (TAB_BAR_CONTENT_HEIGHT + bottomInset).toPx() }) }
|
||||
@@ -470,15 +466,16 @@ fun LibraryScreen(
|
||||
) { page ->
|
||||
val pageTab = LibraryTab.entries[page]
|
||||
val resetKey = resetKeys[pageTab]
|
||||
val scrollToTopKey = if (pageTab == selectedTab) topRequest else null
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
PullToRefreshBox(
|
||||
isRefreshing = syncing,
|
||||
isRefreshing = refreshing,
|
||||
onRefresh = onSync,
|
||||
state = pullToRefreshState,
|
||||
indicator = {
|
||||
PullToRefreshDefaults.Indicator(
|
||||
state = pullToRefreshState,
|
||||
isRefreshing = syncing,
|
||||
isRefreshing = refreshing,
|
||||
modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding(),
|
||||
containerColor = headerColor,
|
||||
color = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.primary,
|
||||
@@ -497,7 +494,7 @@ fun LibraryScreen(
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
{
|
||||
dismissSearch()
|
||||
focusManager.clearFocus()
|
||||
onAlbumClick(it)
|
||||
},
|
||||
onLongClick = onItemLongClick,
|
||||
@@ -507,6 +504,7 @@ fun LibraryScreen(
|
||||
topInset = listTopInset,
|
||||
onSync = onSync,
|
||||
downloadStatuses = albumDownloads,
|
||||
scrollToTopKey = scrollToTopKey,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -516,7 +514,7 @@ fun LibraryScreen(
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
{
|
||||
dismissSearch()
|
||||
focusManager.clearFocus()
|
||||
onArtistClick(it)
|
||||
},
|
||||
onLongClick = onItemLongClick,
|
||||
@@ -526,6 +524,8 @@ fun LibraryScreen(
|
||||
topInset = listTopInset,
|
||||
onSync = onSync,
|
||||
downloadStatuses = artistDownloads,
|
||||
scrollToTopKey = scrollToTopKey,
|
||||
artistArt = artistArt,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -535,7 +535,7 @@ fun LibraryScreen(
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
{
|
||||
dismissSearch()
|
||||
focusManager.clearFocus()
|
||||
onPlaylistClick(it)
|
||||
},
|
||||
onLongClick = onItemLongClick,
|
||||
@@ -545,6 +545,7 @@ fun LibraryScreen(
|
||||
topInset = listTopInset,
|
||||
onSync = onSync,
|
||||
downloadStatuses = playlistDownloads,
|
||||
scrollToTopKey = scrollToTopKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -570,11 +571,12 @@ fun LibraryScreen(
|
||||
) {
|
||||
LibraryTabs(
|
||||
pagerState = pagerState,
|
||||
onTabSelected = onTabSelected,
|
||||
onTabSelected = { tab ->
|
||||
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
|
||||
},
|
||||
onOpenSettings = onOpenSettings,
|
||||
offline = offline,
|
||||
onExitOffline = onExitOffline,
|
||||
artwork = artwork,
|
||||
syncing = syncing,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -583,10 +585,7 @@ fun LibraryScreen(
|
||||
SearchField(
|
||||
value = search,
|
||||
onValueChange = onSearchChange,
|
||||
onClose = {
|
||||
searchActive = false
|
||||
onSearchChange("")
|
||||
},
|
||||
onClose = closeSearch,
|
||||
focusRequester = searchFocus,
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -597,11 +596,29 @@ fun LibraryScreen(
|
||||
}
|
||||
|
||||
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
|
||||
FloatingActionButton(
|
||||
onClick = { if (searchFab) onOpenSearch() else showOptions = true },
|
||||
containerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground,
|
||||
containerColor = fabContainerColor,
|
||||
contentColor = fabContentColor,
|
||||
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp),
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp),
|
||||
) {
|
||||
@@ -620,11 +637,18 @@ fun LibraryScreen(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 4.dp, y = (-4).dp)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
.offset(x = 1.dp, y = 2.5.dp)
|
||||
.size(11.dp)
|
||||
.background(fabContainerColor, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.background(fabContentColor, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -684,7 +708,7 @@ private fun ListOptionsSheet(
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
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)
|
||||
Text(stringResource(R.string.search_this_list), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
@@ -806,6 +830,7 @@ private fun ListOptionsSheet(
|
||||
|
||||
private val BOTTOM_CLEARANCE = 80.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_VERTICAL_PADDING = 6.dp
|
||||
private val TOUCH_TARGET = 48.dp
|
||||
@@ -831,18 +856,19 @@ private fun ListOptionsGlyph(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("FrequentlyChangingValue")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun LibraryTabs(
|
||||
pagerState: PagerState,
|
||||
onTabSelected: (LibraryTab) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
offline: Boolean = false,
|
||||
onExitOffline: () -> Unit = {},
|
||||
artwork: ArtworkColors?,
|
||||
syncing: Boolean = false,
|
||||
) {
|
||||
val indicatorStretch = 18.dp
|
||||
val density = LocalDensity.current
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val bounds = remember { mutableStateMapOf<Int, Rect>() }
|
||||
val page = pagerState.currentPage
|
||||
val fraction = pagerState.currentPageOffsetFraction
|
||||
@@ -874,68 +900,111 @@ internal fun LibraryTabs(
|
||||
val indicatorColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.onBackground
|
||||
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 =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(headerColor)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
.padding(top = 2.dp, end = 8.dp, bottom = 2.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.drawBehind {
|
||||
if (current != null && indicatorLeft != null && indicatorRight != null && 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()),
|
||||
)
|
||||
}
|
||||
},
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.onSizeChanged { viewportWidth = it.width }
|
||||
.horizontalScroll(scrollState)
|
||||
.onSizeChanged { contentWidth = it.width },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
LibraryTab.entries.forEachIndexed { index, tab ->
|
||||
TabButton(
|
||||
tab = tab,
|
||||
artwork = artwork,
|
||||
progress =
|
||||
when (index) {
|
||||
page -> 1f - transition
|
||||
page + (if (fraction >= 0f) 1 else -1) -> transition
|
||||
else -> 0f
|
||||
Spacer(Modifier.width(TAB_STRIP_LEADING_GAP))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.drawBehind {
|
||||
if (current != null &&
|
||||
indicatorLeft != null &&
|
||||
indicatorRight != null &&
|
||||
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,
|
||||
onClick = { onTabSelected(tab) },
|
||||
modifier =
|
||||
Modifier.onGloballyPositioned { coordinates ->
|
||||
bounds[index] = coordinates.boundsInParent()
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (offline) {
|
||||
IconButton(onClick = onExitOffline) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.CloudOff,
|
||||
contentDescription = stringResource(R.string.offline_mode_status),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
LibraryTab.entries.forEachIndexed { index, tab ->
|
||||
TabButton(
|
||||
tab = tab,
|
||||
artwork = artwork,
|
||||
progress =
|
||||
when (index) {
|
||||
page -> 1f - transition
|
||||
page + (if (fraction >= 0f) 1 else -1) -> transition
|
||||
else -> 0f
|
||||
},
|
||||
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(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
||||
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.SyncStatus
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -117,7 +119,12 @@ class LibraryViewModel(
|
||||
|
||||
val syncing: StateFlow<Boolean> =
|
||||
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)
|
||||
|
||||
private val activeSourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
@@ -206,7 +213,14 @@ class LibraryViewModel(
|
||||
thumbnail: Boolean,
|
||||
): 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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user