mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-12 07:46:33 +01:00
Compare commits
254
Commits
eef94eff63
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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 }}
|
group: ci-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
|
||||||
GRADLE_OPTS: -Dorg.gradle.daemon=false
|
|
||||||
NIX_CONFIG: experimental-features = nix-command flakes
|
|
||||||
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: sh
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-26.04
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
env:
|
||||||
|
GRADLE_OPTS: -Dorg.gradle.daemon=false
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
|
||||||
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
|
|
||||||
with:
|
with:
|
||||||
primary-key: nix-v2-${{ hashFiles('flake.lock', '**/*.nix') }}
|
persist-credentials: false
|
||||||
restore-prefixes-first-match: nix-v2-
|
- uses: ./.github/actions/build
|
||||||
gc-max-store-size-linux: 6G
|
|
||||||
- uses: actions/cache@v6
|
|
||||||
with:
|
with:
|
||||||
path: |
|
gradle-args: ":app:ktlintCheck :app:lintDebug :app:testDebugUnitTest :app:assembleDebug :app:assembleNightly --no-configuration-cache --console=plain --no-daemon"
|
||||||
~/.gradle/caches
|
- name: Collect nightly builds
|
||||||
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
if: github.ref == 'refs/heads/main'
|
||||||
restore-keys: gradle-
|
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
|
- uses: actions/cache@v6
|
||||||
with:
|
with:
|
||||||
path: .integration/music
|
path: .integration/music
|
||||||
key: integration-music-v1
|
key: integration-music-v3
|
||||||
- run: nix develop --command gradle :app:ktlintCheck :app:lintDebug :app:testDebugUnitTest --no-configuration-cache --console=plain
|
|
||||||
- run: nix develop .#integration --command nu tools/integration-test.nu
|
- 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/
|
.gradle/
|
||||||
build/
|
build/
|
||||||
|
!.github/actions/build/
|
||||||
local.properties
|
local.properties
|
||||||
|
|
||||||
*.apk
|
*.apk
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Guidance for agents (and humans) working in this repository.
|
|||||||
|
|
||||||
## What this is
|
## 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
|
## Environment
|
||||||
|
|
||||||
@@ -34,11 +34,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
|
|||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
- Forgejo Actions, `.forgejo/workflows/ci.yml`, a single `ci` job that runs the lint, unit test and integration test steps (one cache restore/save and Gradle warm-up instead of three).
|
- The CI workflows live under `.forgejo/` and `.github/`, and the GitHub mirror and the GitHub PR import under `.forgejo/`; see `.forgejo/README.md`.
|
||||||
- Runs on the `nix-docker` runner (the `localhost/nix-ci` image built by the separate `nix-home` repo) inside the devshell, restoring the Nix store via `cache-nix-action` and caching Gradle and the integration test music.
|
|
||||||
- The `nix-docker` and `bookworm` runner labels, their images and the runner's networking are defined in the separate `nix-home` repo (`hosts-incus/git-runner.nix`).
|
|
||||||
- Pull requests from a fork need approval before the workflow runs.
|
|
||||||
- The GitHub mirror and the GitHub PR import live under `.forgejo/`; see `.forgejo/README.md`.
|
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
@@ -48,6 +44,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
|
|||||||
- Networking uses OkHttp and DOM XML parsing; auth uses the Subsonic token scheme by default.
|
- Networking uses OkHttp and DOM XML parsing; auth uses the Subsonic token scheme by default.
|
||||||
- Use coroutines and `Flow`; library reads are exposed as `Flow` from Room. Large lists page with Paging 3 over Room `PagingSource` rather than loading the whole table.
|
- Use coroutines and `Flow`; library reads are exposed as `Flow` from Room. Large lists page with Paging 3 over Room `PagingSource` rather than loading the whole table.
|
||||||
- The library is mirrored in full at sync and must work offline. Never fetch library metadata on demand for browsing, filtering or sorting; only media bytes (streams, downloads) and images are on-demand. A server-derived sort key must be synced into a local column, not requested lazily when the sort is selected.
|
- The library is mirrored in full at sync and must work offline. Never fetch library metadata on demand for browsing, filtering or sorting; only media bytes (streams, downloads) and images are on-demand. A server-derived sort key must be synced into a local column, not requested lazily when the sort is selected.
|
||||||
|
- Auto-sync is a foreground-only, in-process timer (`AutoSync`, driven by the Activity lifecycle), not a background worker: do not move it to WorkManager or an alarm. `SyncManager` coalesces a request that arrives while a sync is running into a single follow-up; the library and Home sync actions no-op while one is running.
|
||||||
- Playback is Media3: `PlaybackService` is a `MediaSessionService` owning an `ExoPlayer`, and `PlaybackController` (app-scoped Koin singleton) is the only thing the UI talks to. The queue is a `queue_entries` list of references with optional ranges plus a `playback_cursor` row; keep only a bounded window in memory (never the whole queue, and never load a whole table to build one). Stream URLs are freshly salted per request, so attach artwork by `CoverArtRef` cache key, not by URL.
|
- Playback is Media3: `PlaybackService` is a `MediaSessionService` owning an `ExoPlayer`, and `PlaybackController` (app-scoped Koin singleton) is the only thing the UI talks to. The queue is a `queue_entries` list of references with optional ranges plus a `playback_cursor` row; keep only a bounded window in memory (never the whole queue, and never load a whole table to build one). Stream URLs are freshly salted per request, so attach artwork by `CoverArtRef` cache key, not by URL.
|
||||||
- UI state lives in `androidx.lifecycle.ViewModel`, wired with Koin (`koinViewModel()`); screens split into a stateful `*Route` and a stateless `*Screen` for screenshot tests.
|
- UI state lives in `androidx.lifecycle.ViewModel`, wired with Koin (`koinViewModel()`); screens split into a stateful `*Route` and a stateless `*Screen` for screenshot tests.
|
||||||
- UI and playback preferences go in DataStore (`UserPreferences`), not Room.
|
- UI and playback preferences go in DataStore (`UserPreferences`), not Room.
|
||||||
@@ -59,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)`.
|
- Room 3 does not map `PagingSource` automatically: a DAO that returns it needs `@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)`.
|
||||||
- Material icons are not pulled in by `material3`; use the BOM-managed `material-icons-core`/`material-icons-extended` (frozen at 1.7.8). Extended is large in debug builds but R8 strips unused icons from release.
|
- Material icons are not pulled in by `material3`; use the BOM-managed `material-icons-core`/`material-icons-extended` (frozen at 1.7.8). Extended is large in debug builds but R8 strips unused icons from release.
|
||||||
- Robolectric creates a fresh `Application` per test in one JVM, so `SubtracksApp.onCreate` stops any running Koin before `startKoin`.
|
- Robolectric creates a fresh `Application` per test in one JVM, so `SubtracksApp.onCreate` stops any running Koin before `startKoin`.
|
||||||
- `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).
|
- Integration servers: navidrome on 4533 (`admin`/`password`), gonic on 4747 (`admin`/`admin`), lms on 5082 (`admin`/`subtracks-lms`), Nextcloud Music on 8090 (`admin`/`subtracks-nextcloud`, base URL `http://localhost:8090/index.php/apps/music/subsonic/`). The test music is cached in `.integration/music` (gitignored).
|
||||||
- lms and Nextcloud Music authenticate the Subsonic API with per-user **API keys** instead of passwords. The harness seeds a key and most tests use `useTokenAuth = false`; `SubsonicAuthIntegrationTest` drives `useTokenAuth = true` and asserts the fallback to plaintext (lms answers code 42, Nextcloud 41).
|
- lms and Nextcloud Music authenticate the Subsonic API with per-user **API keys** instead of passwords. The harness seeds a key and most tests use `useTokenAuth = false`; `SubsonicAuthIntegrationTest` drives `useTokenAuth = true` and asserts the fallback to plaintext (lms answers code 42, Nextcloud 41).
|
||||||
- `nix develop` does not source your shell rc files (so tools like atuin are not active); use direnv if you want your normal interactive shell.
|
- `nix develop` does not source your shell rc files (so tools like atuin are not active); use direnv if you want your normal interactive shell.
|
||||||
|
- 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>
|
<a href="https://github.com/austinried/subtracks/releases/"><img src=".assets/github-badge.png" width="250"/></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
See [signing/README.md](signing/README.md) for the fingerprints and the commands to verify a download.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Home screen to browse your library over many categories, genres, and more
|
- **Large Libraries:** Built for very large libraries, from syncing the whole collection to playing playlists with thousands of tracks.
|
||||||
- Album and artist artwork, shown full-resolution in the detail and now playing views
|
- **Downloads:** Song and bulk downloads, with download quality (encoding, bitrate) and metered-connection settings.
|
||||||
- Downloads with album, artist and playlist bulk downloads, download quality and a metered-connection toggle
|
- **Offline Mode:** Stars, scrobbles and play counts queue up while you're offline and sync back to the server when you're online again.
|
||||||
- Search across albums, artists, playlists and songs, with per-list sort and filter controls
|
- **Artwork-first:** Album and artist art everywhere, shown full-resolution in the detail and now playing views.
|
||||||
- Gapless playback, an editable queue you can reorder and jump through, and loop and shuffle modes
|
- **Adaptive Theming:** Colours drawn from the current song's artwork.
|
||||||
- Up next queue to add items into the currently playing queue without rewriting it
|
- **Search:** Fast search across the whole library, with per-list sort and filter controls.
|
||||||
- Multiple server support with quick switching
|
- **Playback:** Gapless playback, an editable queue you can reorder and jump through, plus loop and shuffle modes.
|
||||||
- Stream quality (bitrate and format) settings for Wi-Fi and mobile and automatic switching
|
- **ReplayGain:** Loudness normalization from the tags your server reports, with Auto, track and album modes and a preamp.
|
||||||
- Scrobbling and starred items, cached while offline and sent back to the server when online
|
- **Up Next:** Add items into the currently playing queue without rewriting it.
|
||||||
- Media notification and headset/Bluetooth controls
|
- **Multiple Servers:** Connect several servers and switch between them quickly.
|
||||||
- Adaptive theming from the current song's artwork
|
- **Stream Quality:** Bitrate and format settings for Wi-Fi and mobile, with automatic switching.
|
||||||
|
- **System Controls:** Media notification and headset/Bluetooth controls.
|
||||||
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.
|
|
||||||
|
|
||||||
## Building
|
## 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).
|
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
|
## Translations
|
||||||
|
|
||||||
Want to see subtracks in your language? Visit the project on [Weblate](https://hosted.weblate.org/engage/subtracks/) to help!
|
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
|
## 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
|
## License
|
||||||
|
|
||||||
|
|||||||
+103
-6
@@ -14,27 +14,48 @@ ktlint {
|
|||||||
version.set(libs.versions.ktlint.get())
|
version.set(libs.versions.ktlint.get())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val commitHash: String =
|
||||||
|
runCatching {
|
||||||
|
providers
|
||||||
|
.exec {
|
||||||
|
commandLine("git", "rev-parse", "HEAD")
|
||||||
|
workingDir = rootProject.layout.projectDirectory.asFile
|
||||||
|
isIgnoreExitValue = true
|
||||||
|
}.standardOutput.asText
|
||||||
|
.get()
|
||||||
|
.trim()
|
||||||
|
.take(7)
|
||||||
|
}.getOrDefault("")
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.subtracks"
|
namespace = "com.subtracks"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
buildToolsVersion = "37.0.0"
|
buildToolsVersion = "37.0.0"
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.subtracks.next"
|
applicationId = "com.subtracks"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 13
|
versionCode = 14
|
||||||
versionName = "3.0.0"
|
versionName = "3.1.0"
|
||||||
|
buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
androidResources {
|
androidResources {
|
||||||
generateLocaleConfig = true
|
generateLocaleConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependenciesInfo {
|
||||||
|
// The APK signing-block copy is encrypted per build, breaking APK reproducibility, and
|
||||||
|
// F-Droid rejects it; the bundle keeps its copy for Play.
|
||||||
|
includeInApk = false
|
||||||
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
@@ -43,7 +64,28 @@ android {
|
|||||||
|
|
||||||
lint {
|
lint {
|
||||||
checkReleaseBuilds = false
|
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 {
|
testOptions {
|
||||||
@@ -57,12 +99,43 @@ android {
|
|||||||
.kotlin.directories
|
.kotlin.directories
|
||||||
.add("src/integrationTest/kotlin")
|
.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 {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix = ".nightly"
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
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")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +144,7 @@ android {
|
|||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(JvmTarget.JVM_17)
|
jvmTarget.set(JvmTarget.JVM_17)
|
||||||
|
allWarningsAsErrors.set(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +156,8 @@ ksp {
|
|||||||
arg("room.schemaLocation", "$projectDir/schemas")
|
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 {
|
aboutLibraries {
|
||||||
collect {
|
collect {
|
||||||
configPath = file("config")
|
configPath = file("config")
|
||||||
@@ -102,15 +178,20 @@ val integrationTestClasses =
|
|||||||
|
|
||||||
val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
|
val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
|
||||||
|
|
||||||
|
val queryTimingClass = "com.subtracks.data.db.QueryTimingMeasurementTest"
|
||||||
|
|
||||||
tasks.withType<Test>().configureEach {
|
tasks.withType<Test>().configureEach {
|
||||||
|
// Robolectric fetches its Android SDK jar itself, outside Gradle's repositories.
|
||||||
|
systemProperty("robolectric.dependency.repo.url", providers.gradleProperty("mavenCentralMirrorUrl").get())
|
||||||
if (name.endsWith("UnitTest")) {
|
if (name.endsWith("UnitTest")) {
|
||||||
filter {
|
filter {
|
||||||
(integrationTestClasses + demoScreenshotClass).forEach { excludeTestsMatching(it) }
|
(integrationTestClasses + demoScreenshotClass + queryTimingClass).forEach { excludeTestsMatching(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
testLogging {
|
testLogging {
|
||||||
exceptionFormat = TestExceptionFormat.FULL
|
exceptionFormat = TestExceptionFormat.FULL
|
||||||
}
|
}
|
||||||
|
outputs.cacheIf { false }
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register<Test>("integrationTest") {
|
tasks.register<Test>("integrationTest") {
|
||||||
@@ -141,6 +222,20 @@ tasks.register<Test>("demoScreenshots") {
|
|||||||
outputs.upToDateWhen { false }
|
outputs.upToDateWhen { false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.register<Test>("queryTiming") {
|
||||||
|
description = "Measures SQL query timings for the logging threshold (not a CI gate)"
|
||||||
|
group = "verification"
|
||||||
|
val unitTest = tasks.named<Test>("testDebugUnitTest")
|
||||||
|
dependsOn("compileDebugUnitTestKotlin")
|
||||||
|
testClassesDirs = files(provider { unitTest.get().testClassesDirs })
|
||||||
|
classpath = files(provider { unitTest.get().classpath })
|
||||||
|
filter {
|
||||||
|
includeTestsMatching(queryTimingClass)
|
||||||
|
}
|
||||||
|
providers.gradleProperty("measureSongs").orNull?.let { systemProperty("measure.songs", it) }
|
||||||
|
outputs.upToDateWhen { false }
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
@@ -166,7 +261,9 @@ dependencies {
|
|||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.androidx.media3.exoplayer)
|
implementation(libs.androidx.media3.exoplayer)
|
||||||
implementation(libs.androidx.media3.session)
|
implementation(libs.androidx.media3.session)
|
||||||
|
implementation(libs.androidx.media3.datasource)
|
||||||
implementation(libs.androidx.media3.datasource.okhttp)
|
implementation(libs.androidx.media3.datasource.okhttp)
|
||||||
|
implementation(libs.androidx.media3.database)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.coil.network.okhttp)
|
implementation(libs.coil.network.okhttp)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"uniqueId": "com.subtracks:next",
|
"uniqueId": "com.subtracks",
|
||||||
"name": "Subtracks",
|
"name": "Subtracks",
|
||||||
"description": "Subtracks itself, a client for Subsonic-compatible servers.",
|
"description": "Subtracks itself, a client for Subsonic-compatible servers.",
|
||||||
"website": "https://github.com/austinried/subtracks",
|
"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)
|
assertNotNull(kosmo.starred)
|
||||||
assertTrue(kosmo.created <= System.currentTimeMillis() / 1000)
|
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)
|
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 }
|
.filter { it.artistId == artists.first { a -> a.name == "Ugress" }.id }
|
||||||
.map { it.name }
|
.map { it.name }
|
||||||
.sorted()
|
.sorted()
|
||||||
assertEquals(listOf("Kosmonaut", "Retroconnaissance EP"), ugressAlbums)
|
assertEquals(listOf("Kosmonaut", "Retroconnaissance"), ugressAlbums)
|
||||||
|
|
||||||
val bradAlbums =
|
val bradAlbums =
|
||||||
albums
|
albums
|
||||||
|
|||||||
+2
-2
@@ -22,7 +22,7 @@ class SubsonicWriteIntegrationTest(
|
|||||||
.albums()
|
.albums()
|
||||||
.toList()
|
.toList()
|
||||||
.flatten()
|
.flatten()
|
||||||
.first { it.name == "Retroconnaissance EP" }
|
.first { it.name == "Retroconnaissance" }
|
||||||
|
|
||||||
source.setStar(StarType.Album, album.id, true)
|
source.setStar(StarType.Album, album.id, true)
|
||||||
try {
|
try {
|
||||||
@@ -94,7 +94,7 @@ class SubsonicWriteIntegrationTest(
|
|||||||
.songs()
|
.songs()
|
||||||
.toList()
|
.toList()
|
||||||
.flatten()
|
.flatten()
|
||||||
.first { it.album == "Retroconnaissance EP" }
|
.first { it.album == "Retroconnaissance" }
|
||||||
|
|
||||||
source.setStar(StarType.Song, song.id, true)
|
source.setStar(StarType.Song, song.id, true)
|
||||||
try {
|
try {
|
||||||
|
|||||||
+2
-11
@@ -1,11 +1,7 @@
|
|||||||
package com.subtracks.data.sync
|
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 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.model.Source
|
||||||
import com.subtracks.data.source.subsonic.TestServers
|
import com.subtracks.data.source.subsonic.TestServers
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -18,12 +14,7 @@ class MultiSourceSyncIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
fun syncsEveryServerIntoOneLibraryWithoutInterference() =
|
fun syncsEveryServerIntoOneLibraryWithoutInterference() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val db = createTestDatabase()
|
||||||
val db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
try {
|
try {
|
||||||
TestServers.all.forEachIndexed { index, server ->
|
TestServers.all.forEachIndexed { index, server ->
|
||||||
val id = index + 1L
|
val id = index + 1L
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
package com.subtracks.data.sync
|
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 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.model.Source
|
||||||
import com.subtracks.data.source.subsonic.SubsonicClient
|
import com.subtracks.data.source.subsonic.SubsonicClient
|
||||||
import com.subtracks.data.source.subsonic.SubsonicSource
|
import com.subtracks.data.source.subsonic.SubsonicSource
|
||||||
@@ -28,12 +24,7 @@ class PruneSyncIntegrationTest {
|
|||||||
val removed = File(musicDir, REMOVED_ALBUM_DIR)
|
val removed = File(musicDir, REMOVED_ALBUM_DIR)
|
||||||
check(removed.isDirectory) { "$removed is missing; the harness must prepare the prune library" }
|
check(removed.isDirectory) { "$removed is missing; the harness must prepare the prune library" }
|
||||||
|
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val db = createTestDatabase()
|
||||||
val db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
try {
|
try {
|
||||||
db.sourcesDao().upsertSource(
|
db.sourcesDao().upsertSource(
|
||||||
Source(id = 1, name = "prune", address = baseUrl, isActive = true, createdAt = 0),
|
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)")
|
?: error("$name is not set; run the integration harness (tools/integration-test.nu)")
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val REMOVED_ALBUM_DIR = "197"
|
const val REMOVED_ALBUM_DIR = "7GZYpfn8wypaTR00LWeNI4"
|
||||||
const val REMOVED_ALBUM_NAME = "Retroconnaissance EP"
|
const val REMOVED_ALBUM_NAME = "Retroconnaissance"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-11
@@ -1,11 +1,7 @@
|
|||||||
package com.subtracks.data.sync
|
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 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.model.Source
|
||||||
import com.subtracks.data.source.subsonic.TestServers
|
import com.subtracks.data.source.subsonic.TestServers
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -19,12 +15,7 @@ class SyncServiceIntegrationTest {
|
|||||||
fun syncMirrorsTheLibraryIntoRoomForEveryServer() =
|
fun syncMirrorsTheLibraryIntoRoomForEveryServer() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
servers.forEach { server ->
|
servers.forEach { server ->
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val db = createTestDatabase()
|
||||||
val db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
try {
|
try {
|
||||||
db.sourcesDao().upsertSource(
|
db.sourcesDao().upsertSource(
|
||||||
Source(id = 1, name = server.name, address = server.baseUrl, isActive = true, createdAt = 0),
|
Source(id = 1, name = server.name, address = server.baseUrl, isActive = true, createdAt = 0),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
|
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
|
||||||
@@ -18,7 +19,8 @@
|
|||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Subtracks">
|
android:theme="@style/Theme.Subtracks"
|
||||||
|
tools:ignore="DataExtractionRules,UnusedAttribute">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
@@ -35,7 +37,8 @@
|
|||||||
<service
|
<service
|
||||||
android:name=".playback.PlaybackService"
|
android:name=".playback.PlaybackService"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:foregroundServiceType="mediaPlayback">
|
android:foregroundServiceType="mediaPlayback"
|
||||||
|
tools:ignore="ExportedService">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
@@ -44,6 +47,16 @@
|
|||||||
<receiver
|
<receiver
|
||||||
android:name=".data.download.DownloadCancelReceiver"
|
android:name=".data.download.DownloadCancelReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.subtracks.data.sync.AutoSync
|
||||||
import com.subtracks.ui.NowPlayingLauncher
|
import com.subtracks.ui.NowPlayingLauncher
|
||||||
import com.subtracks.ui.SubtracksRoot
|
import com.subtracks.ui.SubtracksRoot
|
||||||
import com.subtracks.ui.theme.SubtracksTheme
|
import com.subtracks.ui.theme.SubtracksTheme
|
||||||
@@ -19,10 +20,21 @@ import org.koin.core.context.GlobalContext
|
|||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val launchViewModel: NowPlayingLaunchViewModel by viewModels()
|
private val launchViewModel: NowPlayingLaunchViewModel by viewModels()
|
||||||
private val nowPlayingLauncher: NowPlayingLauncher by lazy { GlobalContext.get().get() }
|
private val nowPlayingLauncher: NowPlayingLauncher by lazy { GlobalContext.get().get() }
|
||||||
|
private val autoSync: AutoSync by lazy { GlobalContext.get().get() }
|
||||||
|
|
||||||
private val requestNotifications =
|
private val requestNotifications =
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
autoSync.setForeground(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
autoSync.setForeground(false)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
requestNotificationPermission()
|
requestNotificationPermission()
|
||||||
|
|||||||
@@ -5,20 +5,28 @@ import coil3.ImageLoader
|
|||||||
import coil3.SingletonImageLoader
|
import coil3.SingletonImageLoader
|
||||||
import coil3.annotation.DelicateCoilApi
|
import coil3.annotation.DelicateCoilApi
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import com.subtracks.data.LegacyDataCleanup
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.ArtworkSeedStore
|
import com.subtracks.data.repo.ArtworkSeedStore
|
||||||
import com.subtracks.di.appModule
|
import com.subtracks.di.appModule
|
||||||
|
import com.subtracks.log.Log
|
||||||
import com.subtracks.ui.theme.ArtworkSeedCache
|
import com.subtracks.ui.theme.ArtworkSeedCache
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
import org.koin.core.context.stopKoin
|
import org.koin.core.context.stopKoin
|
||||||
|
|
||||||
class SubtracksApp : Application() {
|
class SubtracksApp : Application() {
|
||||||
private val http = OkHttpClient()
|
private val http =
|
||||||
|
UserAgent
|
||||||
|
.httpClient()
|
||||||
|
.newBuilder()
|
||||||
|
.addInterceptor(Log.interceptor())
|
||||||
|
.build()
|
||||||
|
|
||||||
@OptIn(DelicateCoilApi::class)
|
@OptIn(DelicateCoilApi::class)
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
Log.init(this)
|
||||||
|
LegacyDataCleanup.run(this)
|
||||||
stopKoin()
|
stopKoin()
|
||||||
SingletonImageLoader.reset()
|
SingletonImageLoader.reset()
|
||||||
val koin =
|
val koin =
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.subtracks
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
|
||||||
|
object UserAgent {
|
||||||
|
val value: String = "subtracks (Android ${Build.VERSION.RELEASE}; ${BuildConfig.VERSION_NAME})"
|
||||||
|
|
||||||
|
fun interceptor(): Interceptor =
|
||||||
|
Interceptor { chain ->
|
||||||
|
chain.proceed(
|
||||||
|
chain
|
||||||
|
.request()
|
||||||
|
.newBuilder()
|
||||||
|
.header("User-Agent", value)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun httpClient(): OkHttpClient = OkHttpClient.Builder().addInterceptor(interceptor()).build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.subtracks.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
// One-shot cleanup of the 1.x React Native app, which shipped as com.subtracks before this rewrite.
|
||||||
|
// It left an AsyncStorage database (RKStorage), an older `servers` dump and a downloaded/cached
|
||||||
|
// artwork+audio tree (under the app's external files dir) that v3 never reads. Only a real 1.x upgrade
|
||||||
|
// has those paths, so a fresh install does nothing and an upgrade is cleaned exactly once.
|
||||||
|
//
|
||||||
|
// Delete this file and its call in SubtracksApp once installs can no longer be coming from 1.x.
|
||||||
|
internal object LegacyDataCleanup {
|
||||||
|
fun run(context: Context) {
|
||||||
|
// The cached trees can be large, so keep the walk off the main thread.
|
||||||
|
Thread { run(context.dataDir, context.getExternalFilesDir(null)) }.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun run(
|
||||||
|
dataDir: File,
|
||||||
|
externalFilesDir: File?,
|
||||||
|
) {
|
||||||
|
val database = File(dataDir, "databases/RKStorage")
|
||||||
|
val servers = File(dataDir, "files/servers")
|
||||||
|
val cache = externalFilesDir?.let { File(it, "s") }
|
||||||
|
if (!database.exists() && !servers.exists() && cache?.exists() != true) return
|
||||||
|
deleteDatabase(database)
|
||||||
|
deleteTree(servers)
|
||||||
|
cache?.let(::deleteTree)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteDatabase(database: File) {
|
||||||
|
listOf("", "-wal", "-shm", "-journal").forEach { deleteTree(File(database.path + it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteTree(target: File) {
|
||||||
|
if (!target.exists()) return
|
||||||
|
val removed = target.deleteRecursively()
|
||||||
|
Log.i("legacy", "removed ${target.path} ($removed)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 android.content.Context
|
||||||
import androidx.room3.Room
|
import androidx.room3.Room
|
||||||
|
import androidx.room3.RoomDatabase
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
|
||||||
fun createAndroidDatabase(context: Context): SubtracksDatabase =
|
fun createAndroidDatabase(context: Context): SubtracksDatabase {
|
||||||
Room
|
val driver = LoggingSQLiteDriver(BundledSQLiteDriver(), Log::sql)
|
||||||
|
return Room
|
||||||
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
||||||
.setDriver(BundledSQLiteDriver())
|
.setDriver(driver)
|
||||||
.addMigrations(*MIGRATIONS)
|
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
|
||||||
|
.addSubtracksDatabaseCallbacks()
|
||||||
.build()
|
.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
|
// A song is attributed to its album artist and to its credited track artist, so a guest artist's
|
||||||
// downloads are visible even though they lead no albums of their own.
|
// downloads are visible even though they lead no albums of their own.
|
||||||
private const val ARTIST_DOWNLOAD_ROWS =
|
// Starts from songs so status totals include every song the artist is credited on.
|
||||||
"SELECT albums.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
internal const val ARTIST_DOWNLOAD_ROWS =
|
||||||
"FROM songs " +
|
"SELECT credits.artistId AS id, credits.songId AS songId, song_downloads.status AS status " +
|
||||||
|
"FROM (" + CREDITED_SONG_ROWS + ") credits " +
|
||||||
|
"LEFT JOIN song_downloads ON song_downloads.sourceId = :sourceId " +
|
||||||
|
"AND song_downloads.songId = credits.songId"
|
||||||
|
|
||||||
|
// Starts from downloads so lists and filters over partially downloaded libraries do not scan songs.
|
||||||
|
// Kept hand-written for that plan.
|
||||||
|
internal const val DOWNLOADED_ARTIST_ROWS =
|
||||||
|
"SELECT albums.artistId AS id, song_downloads.songId AS songId, " +
|
||||||
|
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||||
|
"FROM song_downloads " +
|
||||||
|
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
"WHERE song_downloads.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||||
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
"UNION ALL " +
|
||||||
"SELECT songs.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
"SELECT album_artists.artistId AS id, song_downloads.songId AS songId, " +
|
||||||
"FROM songs " +
|
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||||
|
"FROM song_downloads " +
|
||||||
|
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
"JOIN album_artists ON album_artists.sourceId = albums.sourceId AND album_artists.albumId = albums.id " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL"
|
"WHERE song_downloads.sourceId = :sourceId " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT songs.artistId AS id, song_downloads.songId AS songId, " +
|
||||||
|
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||||
|
"FROM song_downloads " +
|
||||||
|
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
|
||||||
|
"WHERE song_downloads.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT song_artists.artistId AS id, song_downloads.songId AS songId, " +
|
||||||
|
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||||
|
"FROM song_downloads " +
|
||||||
|
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
|
||||||
|
"JOIN song_artists ON song_artists.sourceId = songs.sourceId AND song_artists.songId = songs.id " +
|
||||||
|
"WHERE song_downloads.sourceId = :sourceId"
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface DownloadDao {
|
interface DownloadDao {
|
||||||
@@ -38,10 +63,15 @@ interface DownloadDao {
|
|||||||
suspend fun all(): List<SongDownload>
|
suspend fun all(): List<SongDownload>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT COUNT(DISTINCT songId) AS total, " +
|
"SELECT COALESCE((SELECT songCount FROM artists " +
|
||||||
|
"WHERE sourceId = :sourceId AND id = :artistId), 0) AS total, " +
|
||||||
|
"COALESCE(statuses.downloaded, 0) AS downloaded, " +
|
||||||
|
"COALESCE(statuses.downloading, 0) AS downloading " +
|
||||||
|
"FROM (SELECT " +
|
||||||
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
|
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
|
||||||
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
|
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
|
||||||
"FROM (" + ARTIST_DOWNLOAD_ROWS + ") WHERE id = :artistId",
|
"FROM (" + DOWNLOADED_ARTIST_ROWS + ") " +
|
||||||
|
"WHERE id = :artistId AND status IN ('Completed', 'Queued', 'Running')) statuses",
|
||||||
)
|
)
|
||||||
fun artistStatus(
|
fun artistStatus(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -89,12 +119,15 @@ interface DownloadDao {
|
|||||||
fun albumStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
|
fun albumStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT id, " +
|
"SELECT statuses.id, artists.songCount AS total, statuses.downloaded, statuses.downloading " +
|
||||||
"COUNT(DISTINCT songId) AS total, " +
|
"FROM (SELECT id, " +
|
||||||
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
|
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
|
||||||
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
|
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
|
||||||
"FROM (" + ARTIST_DOWNLOAD_ROWS + ") GROUP BY id " +
|
"FROM (" + DOWNLOADED_ARTIST_ROWS + ") " +
|
||||||
"HAVING downloaded > 0 OR downloading > 0",
|
"WHERE status IN ('Completed', 'Queued', 'Running') " +
|
||||||
|
"GROUP BY id) statuses " +
|
||||||
|
"JOIN artists ON artists.sourceId = :sourceId AND artists.id = statuses.id " +
|
||||||
|
"WHERE statuses.downloaded > 0 OR statuses.downloading > 0",
|
||||||
)
|
)
|
||||||
fun artistStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
|
fun artistStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
|
||||||
|
|
||||||
@@ -114,24 +147,30 @@ interface DownloadDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT d.sourceId AS sourceId, d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " +
|
"SELECT d.sourceId AS sourceId, d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " +
|
||||||
"al.artistId AS artistId, ar.name AS artistName, d.status AS status, " +
|
"al.artistId AS artistId, COALESCE(ar.name, al.albumArtist) AS artistName, d.status AS status, " +
|
||||||
"d.bytes AS bytes, d.total AS total " +
|
"d.bytes AS bytes, d.total AS total, " +
|
||||||
|
"COALESCE(NULLIF(ar.sortName, ''), NULLIF(al.albumArtistSort, ''), ar.name, al.albumArtist, '') AS artistSort, " +
|
||||||
|
"COALESCE(NULLIF(al.sortName, ''), al.name, '') AS albumSort, " +
|
||||||
|
"COALESCE(NULLIF(s.sortName, ''), s.title, '') AS songSort " +
|
||||||
"FROM song_downloads d " +
|
"FROM song_downloads d " +
|
||||||
"JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
"JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
||||||
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
|
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
|
||||||
"LEFT JOIN artists ar ON ar.sourceId = al.sourceId AND ar.id = al.artistId " +
|
"LEFT JOIN artists ar ON ar.sourceId = al.sourceId AND ar.id = al.artistId " +
|
||||||
"WHERE d.sourceId = :sourceId",
|
"WHERE d.sourceId = :sourceId ORDER BY s.disc, s.track, s.id",
|
||||||
)
|
)
|
||||||
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
|
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
|
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
|
||||||
|
"al.artistId AS albumArtistId, " +
|
||||||
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
|
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
|
||||||
"ar.id AS artistRow, ar.coverArt AS artistCoverArt " +
|
"ar.id AS artistRow, ar.coverArt AS artistCoverArt, " +
|
||||||
|
"aar.coverArt AS albumArtistCoverArt " +
|
||||||
"FROM song_downloads d " +
|
"FROM song_downloads d " +
|
||||||
"LEFT JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
"LEFT JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
||||||
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
|
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
|
||||||
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId",
|
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId " +
|
||||||
|
"LEFT JOIN artists aar ON aar.sourceId = al.sourceId AND aar.id = al.artistId",
|
||||||
)
|
)
|
||||||
suspend fun artwork(): List<DownloadArtwork>
|
suspend fun artwork(): List<DownloadArtwork>
|
||||||
|
|
||||||
|
|||||||
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.AlbumSort
|
||||||
import com.subtracks.data.prefs.ArtistSort
|
import com.subtracks.data.prefs.ArtistSort
|
||||||
import com.subtracks.data.prefs.PlaylistSort
|
import com.subtracks.data.prefs.PlaylistSort
|
||||||
|
import com.subtracks.data.prefs.SortNameMode
|
||||||
|
|
||||||
// These filters mirror the download-aware ones in LibraryDao but never mention song_downloads, so
|
// The default filters intentionally omit song_downloads: the unfiltered lists do not need the
|
||||||
// the resulting PagingSource is not invalidated by download writes (including progress updates).
|
// join, and their paging sources do not observe download writes (including progress updates).
|
||||||
internal const val ALBUMS_FILTER_BASE =
|
internal const val ALBUMS_FILTER = """
|
||||||
"FROM albums WHERE sourceId = ? " +
|
FROM albums WHERE sourceId = :sourceId
|
||||||
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||||
"AND (? = '' " +
|
AND (:search = ''
|
||||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM album_search
|
||||||
"WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
WHERE album_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||||
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
|
||||||
"OR instr(lower(albumArtist), lower(?)) > 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 =
|
internal const val ALBUMS_FILTER_DOWNLOADED = """
|
||||||
"FROM artists WHERE sourceId = ? " +
|
FROM albums WHERE sourceId = :sourceId
|
||||||
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||||
"AND (? = '' " +
|
AND EXISTS (SELECT 1 FROM songs
|
||||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id
|
||||||
"WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')
|
||||||
"OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) "
|
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 =
|
internal const val ARTISTS_FILTER = """
|
||||||
"FROM playlists WHERE sourceId = ? " +
|
FROM artists WHERE sourceId = :sourceId
|
||||||
"AND (? = '' " +
|
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " +
|
AND $ARTIST_CREDITED
|
||||||
"WHERE playlist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
AND (:search = ''
|
||||||
"OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) "
|
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,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
sort: AlbumSort,
|
sort: AlbumSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
): RoomRawQuery {
|
downloaded: Boolean,
|
||||||
val order = albumOrder(sort, descending)
|
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||||
return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt ->
|
): RoomRawQuery =
|
||||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
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(
|
internal fun rediscoverAlbumsQuery(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -51,90 +169,126 @@ internal fun rediscoverAlbumsQuery(
|
|||||||
search: String,
|
search: String,
|
||||||
cutoff: Long,
|
cutoff: Long,
|
||||||
): RoomRawQuery =
|
): RoomRawQuery =
|
||||||
RoomRawQuery(
|
rawQuery(
|
||||||
"SELECT * $ALBUMS_FILTER_BASE AND played IS NOT NULL AND played < ? " +
|
sql =
|
||||||
"ORDER BY played ASC, name COLLATE NOCASE, id",
|
"SELECT * $ALBUMS_FILTER AND played IS NOT NULL AND played < :cutoff " +
|
||||||
) { stmt ->
|
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
|
||||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
bindings =
|
||||||
stmt.bindLong(11, cutoff)
|
mapOf(
|
||||||
}
|
"sourceId" to sourceId,
|
||||||
|
"starredFilter" to starredFilter,
|
||||||
|
"search" to search,
|
||||||
|
"cutoff" to cutoff,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
private fun SQLiteStatement.bindAlbumFilters(
|
// RoomRawQuery takes a positional bind function, but SQLite gives a repeated named parameter one
|
||||||
sourceId: Long,
|
// index, so the SQL can keep its names and only the first-appearance order needs binding.
|
||||||
starredFilter: Int,
|
internal fun rawQuery(
|
||||||
search: String,
|
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)
|
when (value) {
|
||||||
bindInt(2, starredFilter)
|
null -> bindNull(index)
|
||||||
bindInt(3, starredFilter)
|
is Int -> bindLong(index, value.toLong())
|
||||||
bindInt(4, starredFilter)
|
is Long -> bindLong(index, value)
|
||||||
bindText(5, search)
|
is String -> bindText(index, value)
|
||||||
bindText(6, search)
|
else -> error("Unsupported SQL parameter: ${value::class.qualifiedName}")
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun playlistsDefaultQuery(
|
internal fun albumNameSortKey(mode: SortNameMode): String =
|
||||||
sourceId: Long,
|
when (mode) {
|
||||||
search: String,
|
SortNameMode.SortName -> "sortName COLLATE NOCASE"
|
||||||
sort: PlaylistSort,
|
SortNameMode.Name -> "name COLLATE NOCASE"
|
||||||
descending: Boolean,
|
SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE"
|
||||||
): RoomRawQuery {
|
}
|
||||||
val order = playlistOrder(sort, descending)
|
|
||||||
return RoomRawQuery("SELECT * $PLAYLISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
internal fun albumArtistSortKey(mode: SortNameMode): String =
|
||||||
stmt.bindLong(1, sourceId)
|
when (mode) {
|
||||||
stmt.bindText(2, search)
|
SortNameMode.SortName -> {
|
||||||
stmt.bindText(3, search)
|
"albumArtistSort COLLATE NOCASE"
|
||||||
stmt.bindText(4, search)
|
}
|
||||||
stmt.bindText(5, search)
|
|
||||||
stmt.bindText(6, search)
|
SortNameMode.Name -> {
|
||||||
|
"albumArtist COLLATE NOCASE"
|
||||||
|
}
|
||||||
|
|
||||||
|
SortNameMode.NonLatin -> {
|
||||||
|
"(CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) COLLATE NOCASE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun artistNameSortKey(mode: SortNameMode): String =
|
||||||
|
when (mode) {
|
||||||
|
SortNameMode.SortName -> "sortName COLLATE NOCASE"
|
||||||
|
SortNameMode.Name -> "name COLLATE NOCASE"
|
||||||
|
SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun albumOrder(
|
private fun albumOrder(
|
||||||
sort: AlbumSort,
|
sort: AlbumSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
|
mode: SortNameMode,
|
||||||
): String =
|
): String =
|
||||||
when (sort) {
|
when (sort) {
|
||||||
AlbumSort.Name -> if (descending) ALBUM_ORDER_BY_NAME_REVERSED else ALBUM_ORDER_BY_NAME
|
AlbumSort.Name -> {
|
||||||
AlbumSort.Artist -> if (descending) ALBUM_ORDER_BY_ARTIST_REVERSED else ALBUM_ORDER_BY_ARTIST
|
val name = albumNameSortKey(mode)
|
||||||
AlbumSort.Year -> if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR
|
if (descending) "$name DESC, id DESC" else "$name, id"
|
||||||
AlbumSort.Added -> if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED
|
}
|
||||||
AlbumSort.Starred -> if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED
|
|
||||||
AlbumSort.Frequent -> if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT
|
AlbumSort.Artist -> {
|
||||||
AlbumSort.Recent -> if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT
|
val artist = albumArtistSortKey(mode)
|
||||||
|
val name = albumNameSortKey(mode)
|
||||||
|
if (descending) "$artist DESC, year DESC, $name DESC, id DESC" else "$artist, year, $name, id"
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Year -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Added -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Starred -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Frequent -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Recent -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun artistOrder(
|
private fun artistOrder(
|
||||||
sort: ArtistSort,
|
sort: ArtistSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
|
mode: SortNameMode,
|
||||||
): String =
|
): String =
|
||||||
when (sort) {
|
when (sort) {
|
||||||
ArtistSort.Name -> {
|
ArtistSort.Name -> {
|
||||||
if (descending) ARTIST_ORDER_BY_NAME_REVERSED else ARTIST_ORDER_BY_NAME
|
val name = artistNameSortKey(mode)
|
||||||
|
if (descending) "$name DESC, id DESC" else "$name, id"
|
||||||
}
|
}
|
||||||
|
|
||||||
ArtistSort.AlbumCount -> {
|
ArtistSort.AlbumCount -> {
|
||||||
|
|||||||
@@ -384,6 +384,285 @@ val MIGRATION_26_27 =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_27_28 =
|
||||||
|
object : Migration(27, 28) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `trackGain` REAL")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `albumGain` REAL")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `trackPeak` REAL")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `albumPeak` REAL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_28_29 =
|
||||||
|
object : Migration(28, 29) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `artists` TEXT NOT NULL DEFAULT ''")
|
||||||
|
connection.execSQL("ALTER TABLE `albums` ADD COLUMN `artists` TEXT NOT NULL DEFAULT ''")
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `album_artists` (`sourceId` INTEGER NOT NULL, `albumId` TEXT NOT NULL, " +
|
||||||
|
"`position` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
|
||||||
|
"PRIMARY KEY(`sourceId`, `albumId`, `position`), " +
|
||||||
|
"FOREIGN KEY(`sourceId`, `albumId`) REFERENCES `albums`(`sourceId`, `id`) " +
|
||||||
|
"ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_album_artists_sourceId_artistId` ON `album_artists` (`sourceId`, `artistId`)",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `song_artists` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, " +
|
||||||
|
"`position` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
|
||||||
|
"PRIMARY KEY(`sourceId`, `songId`, `position`), " +
|
||||||
|
"FOREIGN KEY(`sourceId`, `songId`) REFERENCES `songs`(`sourceId`, `id`) " +
|
||||||
|
"ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_song_artists_sourceId_artistId` ON `song_artists` (`sourceId`, `artistId`)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_29_30 =
|
||||||
|
object : Migration(29, 30) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `songCount` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
connection.execSQL(
|
||||||
|
"WITH credited AS (" +
|
||||||
|
"SELECT sourceId, artistId, songId FROM song_artists " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT sourceId, artistId, id AS songId FROM songs WHERE artistId IS NOT NULL " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT album_artists.sourceId, album_artists.artistId, songs.id " +
|
||||||
|
"FROM album_artists JOIN songs " +
|
||||||
|
"ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT albums.sourceId, albums.artistId, songs.id " +
|
||||||
|
"FROM albums JOIN songs ON songs.sourceId = albums.sourceId AND songs.albumId = albums.id " +
|
||||||
|
"WHERE albums.artistId IS NOT NULL" +
|
||||||
|
"), counts AS (" +
|
||||||
|
"SELECT sourceId, artistId AS id, COUNT(*) AS songCount FROM credited GROUP BY sourceId, artistId" +
|
||||||
|
") " +
|
||||||
|
"UPDATE artists SET songCount = c.songCount " +
|
||||||
|
"FROM (SELECT artists.sourceId AS sourceId, artists.id AS id, " +
|
||||||
|
"COALESCE(counts.songCount, 0) AS songCount " +
|
||||||
|
"FROM artists LEFT JOIN counts " +
|
||||||
|
"ON counts.sourceId = artists.sourceId AND counts.id = artists.id) c " +
|
||||||
|
"WHERE artists.sourceId = c.sourceId AND artists.id = c.id " +
|
||||||
|
"AND artists.songCount != c.songCount",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_30_31 =
|
||||||
|
object : Migration(30, 31) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
|
||||||
|
connection.execSQL("ALTER TABLE `albums` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
|
||||||
|
connection.execSQL("ALTER TABLE `albums` ADD COLUMN `albumArtistSort` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `artistSort` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
|
||||||
|
dropSearchIndex(connection, "artist_search")
|
||||||
|
dropSearchIndex(connection, "album_search")
|
||||||
|
dropSearchIndex(connection, "song_search")
|
||||||
|
connection.execSQL("UPDATE `artists` SET `sortName` = `name`")
|
||||||
|
connection.execSQL("UPDATE `albums` SET `sortName` = `name`")
|
||||||
|
connection.execSQL("UPDATE `songs` SET `sortName` = `title`")
|
||||||
|
connection.execSQL(
|
||||||
|
"UPDATE `albums` SET `albumArtistSort` = COALESCE(" +
|
||||||
|
"(SELECT a.`sortName` FROM `artists` a " +
|
||||||
|
"WHERE a.`sourceId` = `albums`.`sourceId` AND a.`id` = `albums`.`artistId`), " +
|
||||||
|
"(SELECT a.`sortName` FROM `album_artists` aa JOIN `artists` a " +
|
||||||
|
"ON a.`sourceId` = aa.`sourceId` AND a.`id` = aa.`artistId` " +
|
||||||
|
"WHERE aa.`sourceId` = `albums`.`sourceId` AND aa.`albumId` = `albums`.`id` " +
|
||||||
|
"ORDER BY aa.`position` LIMIT 1), " +
|
||||||
|
"`albumArtist`, '')",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"UPDATE `songs` SET `artistSort` = COALESCE(" +
|
||||||
|
"(SELECT a.`sortName` FROM `artists` a " +
|
||||||
|
"WHERE a.`sourceId` = `songs`.`sourceId` AND a.`id` = `songs`.`artistId`), " +
|
||||||
|
"(SELECT a.`sortName` FROM `song_artists` sa JOIN `artists` a " +
|
||||||
|
"ON a.`sourceId` = sa.`sourceId` AND a.`id` = sa.`artistId` " +
|
||||||
|
"WHERE sa.`sourceId` = `songs`.`sourceId` AND sa.`songId` = `songs`.`id` " +
|
||||||
|
"ORDER BY sa.`position` LIMIT 1), " +
|
||||||
|
"`artist`, '')",
|
||||||
|
)
|
||||||
|
ARTIST_INDEXES.forEach { connection.execSQL("DROP INDEX IF EXISTS `$it`") }
|
||||||
|
ALBUM_INDEXES.forEach { connection.execSQL("DROP INDEX IF EXISTS `$it`") }
|
||||||
|
ARTIST_INDEX_SQL.forEach { connection.execSQL(it) }
|
||||||
|
ALBUM_INDEX_SQL.forEach { connection.execSQL(it) }
|
||||||
|
createSearchIndex(connection, "artist_search", "artists", listOf("name", "sortName"))
|
||||||
|
createSearchIndex(connection, "album_search", "albums", listOf("name", "albumArtist", "sortName", "albumArtistSort"))
|
||||||
|
createSearchIndex(connection, "song_search", "songs", listOf("title", "artist", "sortName", "artistSort"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ARTIST_INDEXES =
|
||||||
|
listOf("index_artists_name", "index_artists_albumCount", "index_artists_starred", "index_artists_frequent", "index_artists_recent")
|
||||||
|
|
||||||
|
private val ALBUM_INDEXES =
|
||||||
|
listOf(
|
||||||
|
"index_albums_name",
|
||||||
|
"index_albums_artist",
|
||||||
|
"index_albums_year",
|
||||||
|
"index_albums_added",
|
||||||
|
"index_albums_starred",
|
||||||
|
"index_albums_frequent",
|
||||||
|
"index_albums_recent",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val ARTIST_INDEX_SQL =
|
||||||
|
listOf(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artists_name` ON `artists` (`sourceId`, `sortName`, `id`)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artists_albumCount` ON `artists` " +
|
||||||
|
"(`sourceId` ASC, `albumCount` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artists_starred` ON `artists` " +
|
||||||
|
"(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artists_frequent` ON `artists` " +
|
||||||
|
"(`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artists_recent` ON `artists` " +
|
||||||
|
"(`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val ALBUM_INDEX_SQL =
|
||||||
|
listOf(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_name` ON `albums` (`sourceId`, `sortName`, `id`)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_artist` ON `albums` " +
|
||||||
|
"(`sourceId`, `albumArtistSort`, `year`, `sortName`, `id`)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_year` ON `albums` " +
|
||||||
|
"(`sourceId` ASC, `year` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_added` ON `albums` " +
|
||||||
|
"(`sourceId` ASC, `created` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_starred` ON `albums` " +
|
||||||
|
"(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_frequent` ON `albums` " +
|
||||||
|
"(`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_recent` ON `albums` " +
|
||||||
|
"(`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun dropSearchIndex(
|
||||||
|
connection: SQLiteConnection,
|
||||||
|
table: String,
|
||||||
|
) {
|
||||||
|
listOf("BEFORE_UPDATE", "BEFORE_DELETE", "AFTER_UPDATE", "AFTER_INSERT").forEach {
|
||||||
|
connection.execSQL("DROP TRIGGER IF EXISTS `room_fts_content_sync_${table}_$it`")
|
||||||
|
}
|
||||||
|
connection.execSQL("DROP TABLE IF EXISTS `$table`")
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_31_32 =
|
||||||
|
object : Migration(31, 32) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `credited` INTEGER NOT NULL DEFAULT 1")
|
||||||
|
connection.execSQL(
|
||||||
|
"UPDATE `artists` SET `credited` = 0 " +
|
||||||
|
"WHERE EXISTS (SELECT 1 FROM `album_artists` WHERE `sourceId` = `artists`.`sourceId`) " +
|
||||||
|
"AND EXISTS (SELECT 1 FROM `song_artists` WHERE `sourceId` = `artists`.`sourceId`) " +
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM `album_artists` aa " +
|
||||||
|
"WHERE aa.`sourceId` = `artists`.`sourceId` AND aa.`artistId` = `artists`.`id`) " +
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM `song_artists` sa " +
|
||||||
|
"WHERE sa.`sourceId` = `artists`.`sourceId` AND sa.`artistId` = `artists`.`id`)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_32_33 =
|
||||||
|
object : Migration(32, 33) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `sources` ADD COLUMN `creditsStale` INTEGER NOT NULL DEFAULT 1")
|
||||||
|
connection.execSQL("DROP INDEX IF EXISTS `index_songs_starred`")
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_songs_starred` ON `songs` " +
|
||||||
|
"(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recomputes the artist totals from the same credit unions the sync uses, so badges, the artist
|
||||||
|
// page and the shuffle queue agree for libraries that predate the change.
|
||||||
|
val MIGRATION_33_34 =
|
||||||
|
object : Migration(33, 34) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL(
|
||||||
|
"WITH credited_songs AS (" +
|
||||||
|
"SELECT songs.`sourceId` AS sourceId, songs.`artistId` AS artistId, " +
|
||||||
|
"songs.`id` AS songId, songs.`playCount` AS playCount, songs.`played` AS played " +
|
||||||
|
"FROM songs WHERE songs.`artistId` IS NOT NULL " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT song_artists.`sourceId`, song_artists.`artistId`, songs.`id`, " +
|
||||||
|
"songs.`playCount`, songs.`played` " +
|
||||||
|
"FROM song_artists JOIN songs " +
|
||||||
|
"ON songs.`sourceId` = song_artists.`sourceId` AND songs.`id` = song_artists.`songId` " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT albums.`sourceId`, albums.`artistId`, songs.`id`, " +
|
||||||
|
"songs.`playCount`, songs.`played` " +
|
||||||
|
"FROM albums JOIN songs " +
|
||||||
|
"ON songs.`sourceId` = albums.`sourceId` AND songs.`albumId` = albums.`id` " +
|
||||||
|
"WHERE albums.`artistId` IS NOT NULL " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT album_artists.`sourceId`, album_artists.`artistId`, songs.`id`, " +
|
||||||
|
"songs.`playCount`, songs.`played` " +
|
||||||
|
"FROM album_artists JOIN songs " +
|
||||||
|
"ON songs.`sourceId` = album_artists.`sourceId` AND songs.`albumId` = album_artists.`albumId`" +
|
||||||
|
"), credited_albums AS (" +
|
||||||
|
"SELECT albums.`sourceId` AS sourceId, albums.`artistId` AS artistId, " +
|
||||||
|
"albums.`id` AS albumId " +
|
||||||
|
"FROM albums WHERE albums.`artistId` IS NOT NULL " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT album_artists.`sourceId`, album_artists.`artistId`, albums.`id` " +
|
||||||
|
"FROM album_artists JOIN albums " +
|
||||||
|
"ON albums.`sourceId` = album_artists.`sourceId` AND albums.`id` = album_artists.`albumId` " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT songs.`sourceId`, songs.`artistId`, songs.`albumId` " +
|
||||||
|
"FROM songs JOIN albums " +
|
||||||
|
"ON albums.`sourceId` = songs.`sourceId` AND albums.`id` = songs.`albumId` " +
|
||||||
|
"WHERE songs.`artistId` IS NOT NULL " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT song_artists.`sourceId`, song_artists.`artistId`, songs.`albumId` " +
|
||||||
|
"FROM song_artists JOIN songs " +
|
||||||
|
"ON songs.`sourceId` = song_artists.`sourceId` AND songs.`id` = song_artists.`songId` " +
|
||||||
|
"JOIN albums ON albums.`sourceId` = songs.`sourceId` AND albums.`id` = songs.`albumId`" +
|
||||||
|
"), totals AS (" +
|
||||||
|
"SELECT sourceId, artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
|
||||||
|
"SUM(playCount) AS playCount, MAX(played) AS played " +
|
||||||
|
"FROM credited_songs GROUP BY sourceId, artistId" +
|
||||||
|
"), album_totals AS (" +
|
||||||
|
"SELECT sourceId, artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
|
||||||
|
"FROM credited_albums GROUP BY sourceId, artistId" +
|
||||||
|
") " +
|
||||||
|
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
|
||||||
|
"playCount = c.playCount, played = c.played " +
|
||||||
|
"FROM (SELECT artists.`sourceId` AS sourceId, artists.`id` AS id, " +
|
||||||
|
"COALESCE(totals.songCount, 0) AS songCount, " +
|
||||||
|
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
|
||||||
|
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played " +
|
||||||
|
"FROM artists LEFT JOIN totals " +
|
||||||
|
"ON totals.sourceId = artists.`sourceId` AND totals.id = artists.`id` " +
|
||||||
|
"LEFT JOIN album_totals " +
|
||||||
|
"ON album_totals.sourceId = artists.`sourceId` AND album_totals.id = artists.`id` " +
|
||||||
|
"WHERE EXISTS (SELECT 1 FROM album_artists WHERE album_artists.`sourceId` = artists.`sourceId`) " +
|
||||||
|
"OR EXISTS (SELECT 1 FROM song_artists WHERE song_artists.`sourceId` = artists.`sourceId`)" +
|
||||||
|
") c " +
|
||||||
|
"WHERE artists.`sourceId` = c.sourceId AND artists.`id` = c.id " +
|
||||||
|
"AND (artists.`songCount` != c.songCount OR artists.`albumCount` != c.albumCount " +
|
||||||
|
"OR artists.`playCount` != c.playCount OR artists.`played` IS NOT c.played)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_34_35 =
|
||||||
|
object : Migration(34, 35) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `artist_bios` (" +
|
||||||
|
"`sourceId` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `biography` TEXT, " +
|
||||||
|
"PRIMARY KEY(`sourceId`, `artistId`), " +
|
||||||
|
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
|
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
|
||||||
private suspend fun createSearchIndex(
|
private suspend fun createSearchIndex(
|
||||||
connection: SQLiteConnection,
|
connection: SQLiteConnection,
|
||||||
@@ -416,6 +695,80 @@ private suspend fun createSearchIndex(
|
|||||||
connection.execSQL("INSERT INTO `$table`(`$table`) VALUES('rebuild')")
|
connection.execSQL("INSERT INTO `$table`(`$table`) VALUES('rebuild')")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_35_36 =
|
||||||
|
object : Migration(35, 36) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `artistImageUrl` TEXT")
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `coverArts` TEXT NOT NULL DEFAULT ''")
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `downloadedCoverArts` TEXT NOT NULL DEFAULT ''")
|
||||||
|
optimizeArtistSearchTriggers(connection)
|
||||||
|
backfillArtistArt(connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_36_37 =
|
||||||
|
object : Migration(36, 37) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_name_plain` ON `albums` (`sourceId`, `name`, `id`)",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_artist_plain` " +
|
||||||
|
"ON `albums` (`sourceId`, `albumArtist`, `year`, `name`, `id`)",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artists_name_plain` ON `artists` (`sourceId`, `name`, `id`)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_37_38 =
|
||||||
|
object : Migration(37, 38) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `playlists` ADD COLUMN `owner` TEXT")
|
||||||
|
connection.execSQL("ALTER TABLE `playlists` ADD COLUMN `shared` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun artistSearchTriggersOptimized(connection: SQLiteConnection): Boolean {
|
||||||
|
val triggers = mutableListOf<String>()
|
||||||
|
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?)").use { statement ->
|
||||||
|
statement.bindText(1, "room_fts_content_sync_artist_search_BEFORE_UPDATE")
|
||||||
|
statement.bindText(2, "room_fts_content_sync_artist_search_AFTER_UPDATE")
|
||||||
|
while (statement.step()) triggers += statement.getText(0)
|
||||||
|
}
|
||||||
|
return triggers.size == 2 && triggers.all { it.contains("UPDATE OF `name`, `sortName`") }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun optimizeArtistSearchTriggers(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("DROP TRIGGER IF EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE")
|
||||||
|
connection.execSQL("DROP TRIGGER IF EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE")
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE " +
|
||||||
|
"BEFORE UPDATE OF `name`, `sortName` ON `artists` BEGIN " +
|
||||||
|
"DELETE FROM `artist_search` WHERE `rowid`=OLD.`rowid`; END",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE " +
|
||||||
|
"AFTER UPDATE OF `name`, `sortName` ON `artists` BEGIN " +
|
||||||
|
"INSERT INTO `artist_search`(`rowid`, `name`, `sortName`) " +
|
||||||
|
"VALUES (NEW.`rowid`, NEW.`name`, NEW.`sortName`); END",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun backfillArtistArt(connection: SQLiteConnection) {
|
||||||
|
val sourceIds = mutableListOf<Long>()
|
||||||
|
connection.prepare("SELECT id FROM sources").use { statement ->
|
||||||
|
while (statement.step()) sourceIds += statement.getLong(0)
|
||||||
|
}
|
||||||
|
sourceIds.forEach { sourceId ->
|
||||||
|
connection.prepare(ARTIST_ART_UPDATE_SQL).use { statement ->
|
||||||
|
statement.bindLong(1, sourceId)
|
||||||
|
statement.step()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val MIGRATIONS: Array<Migration> =
|
val MIGRATIONS: Array<Migration> =
|
||||||
arrayOf(
|
arrayOf(
|
||||||
MIGRATION_1_2,
|
MIGRATION_1_2,
|
||||||
@@ -444,4 +797,15 @@ val MIGRATIONS: Array<Migration> =
|
|||||||
MIGRATION_24_25,
|
MIGRATION_24_25,
|
||||||
MIGRATION_25_26,
|
MIGRATION_25_26,
|
||||||
MIGRATION_26_27,
|
MIGRATION_26_27,
|
||||||
|
MIGRATION_27_28,
|
||||||
|
MIGRATION_28_29,
|
||||||
|
MIGRATION_29_30,
|
||||||
|
MIGRATION_30_31,
|
||||||
|
MIGRATION_31_32,
|
||||||
|
MIGRATION_32_33,
|
||||||
|
MIGRATION_33_34,
|
||||||
|
MIGRATION_34_35,
|
||||||
|
MIGRATION_35_36,
|
||||||
|
MIGRATION_36_37,
|
||||||
|
MIGRATION_37_38,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package com.subtracks.data.db
|
|||||||
|
|
||||||
import androidx.room3.Dao
|
import androidx.room3.Dao
|
||||||
import androidx.room3.Insert
|
import androidx.room3.Insert
|
||||||
|
import androidx.room3.OnConflictStrategy
|
||||||
import androidx.room3.Query
|
import androidx.room3.Query
|
||||||
import androidx.room3.Upsert
|
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
import com.subtracks.data.model.PlaybackCursor
|
import com.subtracks.data.model.PlaybackCursor
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
@@ -19,13 +19,16 @@ private const val ALBUM_SONGS_ORDER = " ORDER BY songs.disc, songs.track, songs.
|
|||||||
|
|
||||||
private const val ALBUM_SONGS_SQL = ALBUM_SONGS_SELECT + ALBUM_SONGS_ORDER
|
private const val ALBUM_SONGS_SQL = ALBUM_SONGS_SELECT + ALBUM_SONGS_ORDER
|
||||||
|
|
||||||
|
private const val ARTIST_SONG_IDS =
|
||||||
|
"(SELECT songId FROM ($CREDITED_SONG_ROWS) WHERE artistId = :artistId)"
|
||||||
|
|
||||||
private const val ARTIST_SONGS_SELECT =
|
private const val ARTIST_SONGS_SELECT =
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)"
|
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS"
|
||||||
|
|
||||||
private const val ARTIST_SONGS_ORDER =
|
private const val ARTIST_SONGS_ORDER =
|
||||||
" ORDER BY albums.year DESC, albums.name COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
|
" ORDER BY albums.id IS NULL, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
|
||||||
|
|
||||||
private const val SONG_SQL =
|
private const val SONG_SQL =
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
@@ -55,7 +58,7 @@ interface QueueDao {
|
|||||||
@Query("SELECT * FROM playback_cursor WHERE id = 1")
|
@Query("SELECT * FROM playback_cursor WHERE id = 1")
|
||||||
suspend fun cursor(): PlaybackCursor?
|
suspend fun cursor(): PlaybackCursor?
|
||||||
|
|
||||||
@Upsert
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun setCursor(cursor: PlaybackCursor)
|
suspend fun setCursor(cursor: PlaybackCursor)
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
@@ -82,8 +85,8 @@ interface QueueDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT COUNT(*) FROM songs " +
|
"SELECT COUNT(*) FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)",
|
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS",
|
||||||
)
|
)
|
||||||
suspend fun artistLength(
|
suspend fun artistLength(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -190,8 +193,8 @@ interface QueueDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.id FROM songs " +
|
"SELECT songs.id FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)" +
|
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS" +
|
||||||
ARTIST_SONGS_ORDER,
|
ARTIST_SONGS_ORDER,
|
||||||
)
|
)
|
||||||
suspend fun artistSongIds(
|
suspend fun artistSongIds(
|
||||||
@@ -211,14 +214,34 @@ interface QueueDao {
|
|||||||
): List<String>
|
): List<String>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT CASE WHEN EXISTS (SELECT 1 FROM songs WHERE sourceId = :sourceId AND albumId = :albumId AND id = :songId) " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"THEN (SELECT COUNT(*) FROM songs ranked CROSS JOIN songs target " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.id IN (:ids)",
|
"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,
|
sourceId: Long,
|
||||||
ids: List<String>,
|
albumId: String,
|
||||||
): List<AlbumSongItem>
|
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")
|
@Query("SELECT COUNT(*) FROM song_genres WHERE sourceId = :sourceId AND genre = :genre")
|
||||||
suspend fun genreLength(
|
suspend fun genreLength(
|
||||||
@@ -231,7 +254,7 @@ interface QueueDao {
|
|||||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
||||||
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
"ORDER BY songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||||
)
|
)
|
||||||
suspend fun genreSongs(
|
suspend fun genreSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -240,17 +263,6 @@ interface QueueDao {
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
): List<AlbumSongItem>
|
): 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'")
|
@Query("SELECT COUNT(*) FROM song_downloads WHERE sourceId = :sourceId AND status = 'Completed'")
|
||||||
suspend fun downloadedLength(sourceId: Long): Long
|
suspend fun downloadedLength(sourceId: Long): Long
|
||||||
|
|
||||||
@@ -261,7 +273,7 @@ interface QueueDao {
|
|||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
||||||
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
"ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||||
)
|
)
|
||||||
suspend fun starredSongs(
|
suspend fun starredSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -269,30 +281,16 @@ interface QueueDao {
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
): List<AlbumSongItem>
|
): 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(
|
@Query(
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM song_downloads d " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM song_downloads d " +
|
||||||
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||||
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||||
)
|
)
|
||||||
suspend fun downloadedSongs(
|
suspend fun downloadedSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
): List<AlbumSongItem>
|
): 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")
|
@Query("SELECT id FROM sources WHERE isActive = 1 LIMIT 1")
|
||||||
suspend fun activeSourceIdOnce(): Long?
|
suspend fun activeSourceIdOnce(): Long?
|
||||||
|
|
||||||
|
@Query("SELECT creditsStale FROM sources WHERE id = :sourceId")
|
||||||
|
suspend fun creditsStale(sourceId: Long): Boolean?
|
||||||
|
|
||||||
|
@Query("UPDATE sources SET creditsStale = :stale WHERE id = :sourceId AND creditsStale != :stale")
|
||||||
|
suspend fun setCreditsStale(
|
||||||
|
sourceId: Long,
|
||||||
|
stale: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Query("UPDATE sources SET name = :name, address = :address WHERE id = :sourceId")
|
||||||
|
suspend fun updateSourceDetails(
|
||||||
|
sourceId: Long,
|
||||||
|
name: String,
|
||||||
|
address: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Insert
|
@Insert
|
||||||
suspend fun insertSource(source: Source): Long
|
suspend fun insertSource(source: Source): Long
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import androidx.room3.ColumnTypeConverters
|
|||||||
import androidx.room3.Database
|
import androidx.room3.Database
|
||||||
import androidx.room3.RoomDatabase
|
import androidx.room3.RoomDatabase
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.AlbumArtistRef
|
||||||
import com.subtracks.data.model.AlbumSearch
|
import com.subtracks.data.model.AlbumSearch
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistBio
|
||||||
|
import com.subtracks.data.model.ArtistRefsConverter
|
||||||
import com.subtracks.data.model.ArtistSearch
|
import com.subtracks.data.model.ArtistSearch
|
||||||
import com.subtracks.data.model.ArtworkSeed
|
import com.subtracks.data.model.ArtworkSeed
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
@@ -20,10 +23,12 @@ import com.subtracks.data.model.PlaylistSong
|
|||||||
import com.subtracks.data.model.QueueEntry
|
import com.subtracks.data.model.QueueEntry
|
||||||
import com.subtracks.data.model.QueueKindConverter
|
import com.subtracks.data.model.QueueKindConverter
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.model.SongArtistRef
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.model.SongGenre
|
import com.subtracks.data.model.SongGenre
|
||||||
import com.subtracks.data.model.SongSearch
|
import com.subtracks.data.model.SongSearch
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
|
import com.subtracks.data.model.StringListConverter
|
||||||
import com.subtracks.data.model.SubsonicSource
|
import com.subtracks.data.model.SubsonicSource
|
||||||
import com.subtracks.data.model.UpNextEntry
|
import com.subtracks.data.model.UpNextEntry
|
||||||
|
|
||||||
@@ -32,15 +37,18 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
Source::class,
|
Source::class,
|
||||||
SubsonicSource::class,
|
SubsonicSource::class,
|
||||||
Artist::class,
|
Artist::class,
|
||||||
|
ArtistBio::class,
|
||||||
ArtistSearch::class,
|
ArtistSearch::class,
|
||||||
Album::class,
|
Album::class,
|
||||||
AlbumSearch::class,
|
AlbumSearch::class,
|
||||||
Disc::class,
|
Disc::class,
|
||||||
|
AlbumArtistRef::class,
|
||||||
Playlist::class,
|
Playlist::class,
|
||||||
PlaylistSearch::class,
|
PlaylistSearch::class,
|
||||||
PlaylistSong::class,
|
PlaylistSong::class,
|
||||||
Song::class,
|
Song::class,
|
||||||
SongGenre::class,
|
SongGenre::class,
|
||||||
|
SongArtistRef::class,
|
||||||
SongSearch::class,
|
SongSearch::class,
|
||||||
QueueEntry::class,
|
QueueEntry::class,
|
||||||
UpNextEntry::class,
|
UpNextEntry::class,
|
||||||
@@ -49,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
SongDownload::class,
|
SongDownload::class,
|
||||||
PendingAction::class,
|
PendingAction::class,
|
||||||
],
|
],
|
||||||
version = 27,
|
version = 38,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@ColumnTypeConverters(
|
@ColumnTypeConverters(
|
||||||
@@ -57,6 +65,8 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
DownloadStatusConverter::class,
|
DownloadStatusConverter::class,
|
||||||
DownloadErrorConverter::class,
|
DownloadErrorConverter::class,
|
||||||
PendingActionKindConverter::class,
|
PendingActionKindConverter::class,
|
||||||
|
ArtistRefsConverter::class,
|
||||||
|
StringListConverter::class,
|
||||||
)
|
)
|
||||||
abstract class SubtracksDatabase : RoomDatabase() {
|
abstract class SubtracksDatabase : RoomDatabase() {
|
||||||
abstract fun libraryDao(): LibraryDao
|
abstract fun libraryDao(): LibraryDao
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ package com.subtracks.data.download
|
|||||||
|
|
||||||
import android.app.DownloadManager
|
import android.app.DownloadManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.subtracks.UserAgent
|
||||||
import com.subtracks.data.model.DownloadError
|
import com.subtracks.data.model.DownloadError
|
||||||
|
|
||||||
class SystemDownloadEngine(
|
class SystemDownloadEngine(
|
||||||
@@ -12,10 +13,11 @@ class SystemDownloadEngine(
|
|||||||
override fun enqueue(request: EngineRequest): Long {
|
override fun enqueue(request: EngineRequest): Long {
|
||||||
val download =
|
val download =
|
||||||
DownloadManager
|
DownloadManager
|
||||||
.Request(Uri.parse(request.uri))
|
.Request(request.uri.toUri())
|
||||||
.setTitle(request.title)
|
.setTitle(request.title)
|
||||||
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path)
|
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path)
|
||||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
|
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
|
||||||
|
.addRequestHeader("User-Agent", UserAgent.value)
|
||||||
// Wi-Fi only unless the preference allows a metered network, and roaming counts as
|
// Wi-Fi only unless the preference allows a metered network, and roaming counts as
|
||||||
// one of those; the platform otherwise permits it by default.
|
// one of those; the platform otherwise permits it by default.
|
||||||
.setAllowedOverMetered(request.allowMetered)
|
.setAllowedOverMetered(request.allowMetered)
|
||||||
|
|||||||
@@ -10,3 +10,8 @@ fun coverArtKey(
|
|||||||
coverArt: String,
|
coverArt: String,
|
||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): String = "$sourceId:$coverArt:$thumbnail"
|
): String = "$sourceId:$coverArt:$thumbnail"
|
||||||
|
|
||||||
|
fun artistImageKey(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): String = "$sourceId:artist-image:$artistId"
|
||||||
|
|||||||
@@ -58,9 +58,14 @@ data class SongDownload(
|
|||||||
@ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0,
|
@ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0,
|
||||||
) {
|
) {
|
||||||
val progress: Float?
|
val progress: Float?
|
||||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
get() = fractionOf(bytes, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun fractionOf(
|
||||||
|
bytes: Long,
|
||||||
|
total: Long,
|
||||||
|
): Float? = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||||
|
|
||||||
enum class DownloadList { Album, Playlist, Artist }
|
enum class DownloadList { Album, Playlist, Artist }
|
||||||
|
|
||||||
enum class BulkDownloadAction { Download, Cancel, Delete }
|
enum class BulkDownloadAction { Download, Cancel, Delete }
|
||||||
@@ -94,19 +99,24 @@ data class DownloadedSong(
|
|||||||
val size: Long = 0,
|
val size: Long = 0,
|
||||||
val bytes: Long = 0,
|
val bytes: Long = 0,
|
||||||
val total: Long = 0,
|
val total: Long = 0,
|
||||||
|
val artistSort: String = artistName.orEmpty(),
|
||||||
|
val albumSort: String = albumName.orEmpty(),
|
||||||
|
val songSort: String = title,
|
||||||
) {
|
) {
|
||||||
val progress: Float?
|
val progress: Float?
|
||||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
get() = fractionOf(bytes, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class DownloadArtwork(
|
data class DownloadArtwork(
|
||||||
val sourceId: Long,
|
val sourceId: Long,
|
||||||
val albumId: String?,
|
val albumId: String?,
|
||||||
val artistId: String?,
|
val artistId: String?,
|
||||||
|
val albumArtistId: String?,
|
||||||
val albumRow: String?,
|
val albumRow: String?,
|
||||||
val artistRow: String?,
|
val artistRow: String?,
|
||||||
val albumCoverArt: String?,
|
val albumCoverArt: String?,
|
||||||
val artistCoverArt: String?,
|
val artistCoverArt: String?,
|
||||||
|
val albumArtistCoverArt: String?,
|
||||||
) {
|
) {
|
||||||
val hasMissingLibraryRow: Boolean
|
val hasMissingLibraryRow: Boolean
|
||||||
get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
|
get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ data class Source(
|
|||||||
val address: String,
|
val address: String,
|
||||||
val isActive: Boolean = false,
|
val isActive: Boolean = false,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
|
@ColumnInfo(defaultValue = "1") val creditsStale: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -39,6 +40,11 @@ data class SubsonicSource(
|
|||||||
val useTokenAuth: Boolean = true,
|
val useTokenAuth: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class ArtistRef(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "artists",
|
tableName = "artists",
|
||||||
primaryKeys = ["sourceId", "id"],
|
primaryKeys = ["sourceId", "id"],
|
||||||
@@ -52,25 +58,26 @@ data class SubsonicSource(
|
|||||||
],
|
],
|
||||||
indices = [
|
indices = [
|
||||||
Index("sourceId"),
|
Index("sourceId"),
|
||||||
Index(name = "index_artists_name", value = ["sourceId", "name", "id"]),
|
Index(name = "index_artists_name", value = ["sourceId", "sortName", "id"]),
|
||||||
|
Index(name = "index_artists_name_plain", value = ["sourceId", "name", "id"]),
|
||||||
Index(
|
Index(
|
||||||
name = "index_artists_albumCount",
|
name = "index_artists_albumCount",
|
||||||
value = ["sourceId", "albumCount", "name", "id"],
|
value = ["sourceId", "albumCount", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_artists_starred",
|
name = "index_artists_starred",
|
||||||
value = ["sourceId", "starred", "name", "id"],
|
value = ["sourceId", "starred", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_artists_frequent",
|
name = "index_artists_frequent",
|
||||||
value = ["sourceId", "playCount", "name", "id"],
|
value = ["sourceId", "playCount", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_artists_recent",
|
name = "index_artists_recent",
|
||||||
value = ["sourceId", "played", "name", "id"],
|
value = ["sourceId", "played", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -82,8 +89,32 @@ data class Artist(
|
|||||||
val albumCount: Long,
|
val albumCount: Long,
|
||||||
val starred: Long?,
|
val starred: Long?,
|
||||||
val coverArt: String? = null,
|
val coverArt: String? = null,
|
||||||
|
val artistImageUrl: String? = null,
|
||||||
|
@ColumnInfo(defaultValue = "''") val coverArts: List<String> = emptyList(),
|
||||||
|
@ColumnInfo(defaultValue = "''") val downloadedCoverArts: List<String> = emptyList(),
|
||||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||||
val played: Long? = null,
|
val played: Long? = null,
|
||||||
|
@ColumnInfo(defaultValue = "0") val songCount: Long = 0,
|
||||||
|
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = name,
|
||||||
|
@ColumnInfo(defaultValue = "1") val credited: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "artist_bios",
|
||||||
|
primaryKeys = ["sourceId", "artistId"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = Source::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["sourceId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
data class ArtistBio(
|
||||||
|
val sourceId: Long,
|
||||||
|
val artistId: String,
|
||||||
|
val biography: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -100,31 +131,33 @@ data class Artist(
|
|||||||
indices = [
|
indices = [
|
||||||
Index("sourceId"),
|
Index("sourceId"),
|
||||||
Index("sourceId", "artistId"),
|
Index("sourceId", "artistId"),
|
||||||
Index(name = "index_albums_name", value = ["sourceId", "name", "id"]),
|
Index(name = "index_albums_name", value = ["sourceId", "sortName", "id"]),
|
||||||
Index(name = "index_albums_artist", value = ["sourceId", "albumArtist", "year", "name", "id"]),
|
Index(name = "index_albums_name_plain", value = ["sourceId", "name", "id"]),
|
||||||
|
Index(name = "index_albums_artist", value = ["sourceId", "albumArtistSort", "year", "sortName", "id"]),
|
||||||
|
Index(name = "index_albums_artist_plain", value = ["sourceId", "albumArtist", "year", "name", "id"]),
|
||||||
Index(
|
Index(
|
||||||
name = "index_albums_year",
|
name = "index_albums_year",
|
||||||
value = ["sourceId", "year", "name", "id"],
|
value = ["sourceId", "year", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_albums_added",
|
name = "index_albums_added",
|
||||||
value = ["sourceId", "created", "name", "id"],
|
value = ["sourceId", "created", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_albums_starred",
|
name = "index_albums_starred",
|
||||||
value = ["sourceId", "starred", "name", "id"],
|
value = ["sourceId", "starred", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_albums_frequent",
|
name = "index_albums_frequent",
|
||||||
value = ["sourceId", "playCount", "name", "id"],
|
value = ["sourceId", "playCount", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_albums_recent",
|
name = "index_albums_recent",
|
||||||
value = ["sourceId", "played", "name", "id"],
|
value = ["sourceId", "played", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -144,7 +177,26 @@ data class Album(
|
|||||||
@Ignore val discTitles: Map<Long, String> = emptyMap(),
|
@Ignore val discTitles: Map<Long, String> = emptyMap(),
|
||||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||||
val played: Long? = null,
|
val played: Long? = null,
|
||||||
)
|
@ColumnInfo(defaultValue = "''") val artists: List<ArtistRef> = emptyList(),
|
||||||
|
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = name,
|
||||||
|
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val albumArtistSort: String = albumArtist.orEmpty(),
|
||||||
|
) {
|
||||||
|
val artistRefs: List<ArtistRef>
|
||||||
|
get() =
|
||||||
|
artists.ifEmpty {
|
||||||
|
artistId
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { listOf(ArtistRef(it, albumArtist.orEmpty())) }
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ArtistAlbums(
|
||||||
|
val albumCredits: List<Album> = emptyList(),
|
||||||
|
val appearances: List<Album> = emptyList(),
|
||||||
|
) {
|
||||||
|
val count: Int get() = albumCredits.size + appearances.size
|
||||||
|
}
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "discs",
|
tableName = "discs",
|
||||||
@@ -202,6 +254,8 @@ data class Playlist(
|
|||||||
val created: Long,
|
val created: Long,
|
||||||
@ColumnInfo(defaultValue = "0") val changed: Long = 0,
|
@ColumnInfo(defaultValue = "0") val changed: Long = 0,
|
||||||
@ColumnInfo(defaultValue = "0") val duration: Long = 0,
|
@ColumnInfo(defaultValue = "0") val duration: Long = 0,
|
||||||
|
val owner: String? = null,
|
||||||
|
@ColumnInfo(defaultValue = "0") val shared: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -243,8 +297,8 @@ data class PlaylistSong(
|
|||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_songs_starred",
|
name = "index_songs_starred",
|
||||||
value = ["sourceId", "starred", "id"],
|
value = ["sourceId", "starred", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -264,8 +318,24 @@ data class Song(
|
|||||||
@ColumnInfo(defaultValue = "0") val created: Long = 0,
|
@ColumnInfo(defaultValue = "0") val created: Long = 0,
|
||||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||||
val played: Long? = null,
|
val played: Long? = null,
|
||||||
|
val trackGain: Float? = null,
|
||||||
|
val albumGain: Float? = null,
|
||||||
|
val trackPeak: Float? = null,
|
||||||
|
val albumPeak: Float? = null,
|
||||||
@Ignore val genres: List<String> = emptyList(),
|
@Ignore val genres: List<String> = emptyList(),
|
||||||
)
|
@ColumnInfo(defaultValue = "''") val artists: List<ArtistRef> = emptyList(),
|
||||||
|
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = title,
|
||||||
|
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val artistSort: String = artist.orEmpty(),
|
||||||
|
) {
|
||||||
|
val artistRefs: List<ArtistRef>
|
||||||
|
get() =
|
||||||
|
artists.ifEmpty {
|
||||||
|
artistId
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { listOf(ArtistRef(it, artist.orEmpty())) }
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "song_genres",
|
tableName = "song_genres",
|
||||||
@@ -287,6 +357,48 @@ data class SongGenre(
|
|||||||
val genre: String,
|
val genre: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "album_artists",
|
||||||
|
primaryKeys = ["sourceId", "albumId", "position"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = Album::class,
|
||||||
|
parentColumns = ["sourceId", "id"],
|
||||||
|
childColumns = ["sourceId", "albumId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index("sourceId", "artistId")],
|
||||||
|
)
|
||||||
|
data class AlbumArtistRef(
|
||||||
|
val sourceId: Long,
|
||||||
|
val albumId: String,
|
||||||
|
val position: Long,
|
||||||
|
val artistId: String,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "song_artists",
|
||||||
|
primaryKeys = ["sourceId", "songId", "position"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = Song::class,
|
||||||
|
parentColumns = ["sourceId", "id"],
|
||||||
|
childColumns = ["sourceId", "songId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index("sourceId", "artistId")],
|
||||||
|
)
|
||||||
|
data class SongArtistRef(
|
||||||
|
val sourceId: Long,
|
||||||
|
val songId: String,
|
||||||
|
val position: Long,
|
||||||
|
val artistId: String,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
enum class PendingActionKind { Scrobble, Star, Unstar }
|
enum class PendingActionKind { Scrobble, Star, Unstar }
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -434,6 +546,8 @@ data class AlbumSearch(
|
|||||||
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
||||||
val name: String,
|
val name: String,
|
||||||
val albumArtist: String?,
|
val albumArtist: String?,
|
||||||
|
val sortName: String,
|
||||||
|
val albumArtistSort: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(tableName = "artist_search")
|
@Entity(tableName = "artist_search")
|
||||||
@@ -441,6 +555,7 @@ data class AlbumSearch(
|
|||||||
data class ArtistSearch(
|
data class ArtistSearch(
|
||||||
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
||||||
val name: String,
|
val name: String,
|
||||||
|
val sortName: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(tableName = "playlist_search")
|
@Entity(tableName = "playlist_search")
|
||||||
@@ -456,6 +571,8 @@ data class SongSearch(
|
|||||||
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
||||||
val title: String,
|
val title: String,
|
||||||
val artist: String?,
|
val artist: String?,
|
||||||
|
val sortName: String,
|
||||||
|
val artistSort: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
class QueueKindConverter {
|
class QueueKindConverter {
|
||||||
@@ -474,3 +591,53 @@ class PendingActionKindConverter {
|
|||||||
fun toPendingActionKind(value: String): PendingActionKind =
|
fun toPendingActionKind(value: String): PendingActionKind =
|
||||||
PendingActionKind.entries.firstOrNull { it.name == value } ?: PendingActionKind.Scrobble
|
PendingActionKind.entries.firstOrNull { it.name == value } ?: PendingActionKind.Scrobble
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ArtistRefsConverter {
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun fromArtistRefs(refs: List<ArtistRef>): String = ArtistRefsCodec.encode(refs)
|
||||||
|
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun toArtistRefs(value: String): List<ArtistRef> = ArtistRefsCodec.decode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
class StringListConverter {
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun fromList(values: List<String>): String = values.joinToString(LIST_SEPARATOR)
|
||||||
|
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun toList(value: String): List<String> = if (value.isEmpty()) emptyList() else value.split(LIST_SEPARATOR)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val LIST_SEPARATOR = "\u001F"
|
||||||
|
|
||||||
|
object ArtistRefsCodec {
|
||||||
|
fun encode(refs: List<ArtistRef>): String =
|
||||||
|
buildString {
|
||||||
|
refs.forEach { ref ->
|
||||||
|
append(ref.id.length).append(':').append(ref.id)
|
||||||
|
append(ref.name.length).append(':').append(ref.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decode(value: String): List<ArtistRef> {
|
||||||
|
val refs = mutableListOf<ArtistRef>()
|
||||||
|
var index = 0
|
||||||
|
while (index < value.length) {
|
||||||
|
val idLengthEnd = value.indexOf(':', index)
|
||||||
|
if (idLengthEnd < 0) return refs
|
||||||
|
val idLength = value.substring(index, idLengthEnd).toIntOrNull() ?: return refs
|
||||||
|
val idStart = idLengthEnd + 1
|
||||||
|
val idEnd = idStart + idLength
|
||||||
|
if (idLength < 0 || idEnd > value.length) return refs
|
||||||
|
val nameLengthEnd = value.indexOf(':', idEnd)
|
||||||
|
if (nameLengthEnd < 0) return refs
|
||||||
|
val nameLength = value.substring(idEnd, nameLengthEnd).toIntOrNull() ?: return refs
|
||||||
|
val nameStart = nameLengthEnd + 1
|
||||||
|
val nameEnd = nameStart + nameLength
|
||||||
|
if (nameLength < 0 || nameEnd > value.length) return refs
|
||||||
|
refs += ArtistRef(value.substring(idStart, idEnd), value.substring(nameStart, nameEnd))
|
||||||
|
index = nameEnd
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import androidx.datastore.core.DataStore
|
|||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
@@ -14,6 +16,8 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
enum class SortNameMode { SortName, Name, NonLatin }
|
||||||
|
|
||||||
enum class AlbumSort { Name, Artist, Year, Added, Starred, Frequent, Recent }
|
enum class AlbumSort { Name, Artist, Year, Added, Starred, Frequent, Recent }
|
||||||
|
|
||||||
enum class ArtistSort { Name, AlbumCount, Starred, Frequent, Recent }
|
enum class ArtistSort { Name, AlbumCount, Starred, Frequent, Recent }
|
||||||
@@ -48,6 +52,15 @@ data class StreamQuality(
|
|||||||
|
|
||||||
private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
|
private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
|
||||||
|
|
||||||
|
const val DEFAULT_SYNC_INTERVAL_MINUTES = 24 * 60
|
||||||
|
|
||||||
|
const val MIN_REPLAY_GAIN_PREAMP_DB = -15f
|
||||||
|
const val MAX_REPLAY_GAIN_PREAMP_DB = 15f
|
||||||
|
|
||||||
|
enum class SyncMode { Off, WifiOnly, Always }
|
||||||
|
|
||||||
|
enum class ReplayGainMode { Off, Track, Album, Auto }
|
||||||
|
|
||||||
data class ArtworkSeedValue(
|
data class ArtworkSeedValue(
|
||||||
val cacheKey: String,
|
val cacheKey: String,
|
||||||
val primary: Int,
|
val primary: Int,
|
||||||
@@ -105,6 +118,34 @@ class UserPreferences(
|
|||||||
|
|
||||||
private val syncConcurrencyKey = intPreferencesKey("sync_concurrency")
|
private val syncConcurrencyKey = intPreferencesKey("sync_concurrency")
|
||||||
|
|
||||||
|
fun syncMode(): Flow<SyncMode> =
|
||||||
|
store.data.map { prefs ->
|
||||||
|
prefs[syncModeKey]?.let { stored -> SyncMode.entries.firstOrNull { it.name == stored } } ?: SyncMode.WifiOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSyncMode(mode: SyncMode) {
|
||||||
|
store.edit { prefs -> prefs[syncModeKey] = mode.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val syncModeKey = stringPreferencesKey("sync_mode")
|
||||||
|
|
||||||
|
fun lastSyncAt(): Flow<Long> = store.data.map { prefs -> prefs[lastSyncAtKey] ?: 0L }
|
||||||
|
|
||||||
|
suspend fun setLastSyncAt(value: Long) {
|
||||||
|
store.edit { prefs -> prefs[lastSyncAtKey] = value }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val lastSyncAtKey = longPreferencesKey("last_sync_at")
|
||||||
|
|
||||||
|
fun syncIntervalMinutes(): Flow<Int> =
|
||||||
|
store.data.map { prefs -> (prefs[syncIntervalKey] ?: DEFAULT_SYNC_INTERVAL_MINUTES).coerceAtLeast(1) }
|
||||||
|
|
||||||
|
suspend fun setSyncIntervalMinutes(value: Int) {
|
||||||
|
store.edit { prefs -> prefs[syncIntervalKey] = value.coerceAtLeast(1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val syncIntervalKey = intPreferencesKey("sync_interval_minutes")
|
||||||
|
|
||||||
fun scrobbling(): Flow<Boolean> = store.data.map { prefs -> prefs[scrobblingKey] ?: true }
|
fun scrobbling(): Flow<Boolean> = store.data.map { prefs -> prefs[scrobblingKey] ?: true }
|
||||||
|
|
||||||
suspend fun setScrobbling(enabled: Boolean) {
|
suspend fun setScrobbling(enabled: Boolean) {
|
||||||
@@ -113,6 +154,47 @@ class UserPreferences(
|
|||||||
|
|
||||||
private val scrobblingKey = booleanPreferencesKey("scrobbling")
|
private val scrobblingKey = booleanPreferencesKey("scrobbling")
|
||||||
|
|
||||||
|
fun replayGainMode(): Flow<ReplayGainMode> =
|
||||||
|
store.data.map { prefs ->
|
||||||
|
prefs[replayGainModeKey]?.let { stored -> ReplayGainMode.entries.firstOrNull { it.name == stored } } ?: ReplayGainMode.Auto
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setReplayGainMode(mode: ReplayGainMode) {
|
||||||
|
store.edit { prefs -> prefs[replayGainModeKey] = mode.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val replayGainModeKey = stringPreferencesKey("replay_gain_mode")
|
||||||
|
|
||||||
|
fun replayGainPreampDb(): Flow<Float> = store.data.map { prefs -> prefs[replayGainPreampKey].sanitizedPreampDb() }
|
||||||
|
|
||||||
|
suspend fun setReplayGainPreampDb(value: Float) {
|
||||||
|
store.edit { prefs -> prefs[replayGainPreampKey] = value.sanitizedPreampDb() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Float?.sanitizedPreampDb(): Float =
|
||||||
|
this?.takeIf { it.isFinite() }?.coerceIn(MIN_REPLAY_GAIN_PREAMP_DB, MAX_REPLAY_GAIN_PREAMP_DB) ?: 0f
|
||||||
|
|
||||||
|
private val replayGainPreampKey = floatPreferencesKey("replay_gain_preamp_db")
|
||||||
|
|
||||||
|
fun keepScreenOn(): Flow<Boolean> = store.data.map { prefs -> prefs[keepScreenOnKey] ?: false }
|
||||||
|
|
||||||
|
suspend fun setKeepScreenOn(enabled: Boolean) {
|
||||||
|
store.edit { prefs -> prefs[keepScreenOnKey] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val keepScreenOnKey = booleanPreferencesKey("keep_screen_on")
|
||||||
|
|
||||||
|
fun sortNameMode(): Flow<SortNameMode> =
|
||||||
|
store.data.map { prefs ->
|
||||||
|
prefs[sortNameModeKey]?.let { stored -> SortNameMode.entries.firstOrNull { it.name == stored } } ?: SortNameMode.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSortNameMode(mode: SortNameMode) {
|
||||||
|
store.edit { prefs -> prefs[sortNameModeKey] = mode.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sortNameModeKey = stringPreferencesKey("sort_name_mode")
|
||||||
|
|
||||||
fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
|
fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
|
||||||
|
|
||||||
suspend fun setOfflineMode(enabled: Boolean) {
|
suspend fun setOfflineMode(enabled: Boolean) {
|
||||||
@@ -121,6 +203,14 @@ class UserPreferences(
|
|||||||
|
|
||||||
private val offlineModeKey = booleanPreferencesKey("offline_mode")
|
private val offlineModeKey = booleanPreferencesKey("offline_mode")
|
||||||
|
|
||||||
|
fun verboseLogging(): Flow<Boolean> = store.data.map { prefs -> prefs[verboseLoggingKey] ?: false }
|
||||||
|
|
||||||
|
suspend fun setVerboseLogging(enabled: Boolean) {
|
||||||
|
store.edit { prefs -> prefs[verboseLoggingKey] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val verboseLoggingKey = booleanPreferencesKey("verbose_logging")
|
||||||
|
|
||||||
suspend fun lastSeed(): ArtworkSeedValue? {
|
suspend fun lastSeed(): ArtworkSeedValue? {
|
||||||
val prefs = store.data.first()
|
val prefs = store.data.first()
|
||||||
val cacheKey = prefs[lastSeedKey] ?: return null
|
val cacheKey = prefs[lastSeedKey] ?: return null
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import com.subtracks.data.download.EngineDownload
|
|||||||
import com.subtracks.data.download.EngineRequest
|
import com.subtracks.data.download.EngineRequest
|
||||||
import com.subtracks.data.download.EngineStatus
|
import com.subtracks.data.download.EngineStatus
|
||||||
import com.subtracks.data.download.looksLikeAudio
|
import com.subtracks.data.download.looksLikeAudio
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistBio
|
||||||
import com.subtracks.data.model.BulkDownloadAction
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.DownloadError
|
import com.subtracks.data.model.DownloadError
|
||||||
import com.subtracks.data.model.DownloadList
|
import com.subtracks.data.model.DownloadList
|
||||||
@@ -18,7 +20,9 @@ import com.subtracks.data.model.DownloadStatus
|
|||||||
import com.subtracks.data.model.DownloadedSong
|
import com.subtracks.data.model.DownloadedSong
|
||||||
import com.subtracks.data.model.ListDownloadStatus
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
|
import com.subtracks.data.model.artistImageKey
|
||||||
import com.subtracks.data.model.coverArtKey
|
import com.subtracks.data.model.coverArtKey
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -42,6 +46,7 @@ import kotlinx.coroutines.sync.withLock
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
class DownloadRepository(
|
class DownloadRepository(
|
||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
@@ -62,6 +67,8 @@ class DownloadRepository(
|
|||||||
private var started = false
|
private var started = false
|
||||||
|
|
||||||
private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap())
|
private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap())
|
||||||
|
private val attemptedBios = ConcurrentHashMap.newKeySet<Pair<Long, String>>()
|
||||||
|
private val failedArtistImages = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
if (started) return
|
if (started) return
|
||||||
@@ -79,7 +86,10 @@ class DownloadRepository(
|
|||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
sourceRepository.offline.collect { offline ->
|
sourceRepository.offline.collect { offline ->
|
||||||
if (offline) cancelActive()
|
if (offline) {
|
||||||
|
Log.i("download", "offline: cancelling active downloads")
|
||||||
|
cancelActive()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,6 +150,7 @@ class DownloadRepository(
|
|||||||
dir(sourceId).mkdirs()
|
dir(sourceId).mkdirs()
|
||||||
file(sourceId, songId).delete()
|
file(sourceId, songId).delete()
|
||||||
db.downloadDao().upsert(SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued))
|
db.downloadDao().upsert(SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued))
|
||||||
|
Log.i("download", "queued song=$songId (source $sourceId)")
|
||||||
ensurePolling()
|
ensurePolling()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -336,11 +347,15 @@ class DownloadRepository(
|
|||||||
|
|
||||||
private suspend fun removeRows(rows: List<SongDownload>) {
|
private suspend fun removeRows(rows: List<SongDownload>) {
|
||||||
if (rows.isEmpty()) return
|
if (rows.isEmpty()) return
|
||||||
|
Log.i("download", "removed ${rows.size} download(s)")
|
||||||
val engineIds = rows.mapNotNull { it.engineId }
|
val engineIds = rows.mapNotNull { it.engineId }
|
||||||
if (engineIds.isNotEmpty()) engine.cancel(engineIds)
|
if (engineIds.isNotEmpty()) engine.cancel(engineIds)
|
||||||
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
|
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
|
||||||
group.forEach { file(sourceId, it.songId).delete() }
|
group.forEach { file(sourceId, it.songId).delete() }
|
||||||
group.map { it.songId }.chunked(DELETE_CHUNK).forEach { db.downloadDao().deleteSongs(sourceId, it) }
|
group.map { it.songId }.chunked(DELETE_CHUNK).forEach { db.downloadDao().deleteSongs(sourceId, it) }
|
||||||
|
if (group.any { it.status == DownloadStatus.Completed }) {
|
||||||
|
refreshDownloadedArtistArt(sourceId, group.map { it.songId })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sweep(db.downloadDao().all())
|
sweep(db.downloadDao().all())
|
||||||
}
|
}
|
||||||
@@ -363,6 +378,7 @@ class DownloadRepository(
|
|||||||
.mapNotNull { it.engineId }
|
.mapNotNull { it.engineId }
|
||||||
.let(engine::cancel)
|
.let(engine::cancel)
|
||||||
db.downloadDao().deleteSource(sourceId)
|
db.downloadDao().deleteSource(sourceId)
|
||||||
|
db.libraryDao().clearDownloadedArtistArt(sourceId)
|
||||||
dir(sourceId).deleteRecursively()
|
dir(sourceId).deleteRecursively()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,14 +416,18 @@ class DownloadRepository(
|
|||||||
private suspend fun fetchArtwork(
|
private suspend fun fetchArtwork(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
songId: String,
|
songId: String,
|
||||||
|
artistIds: MutableSet<String>,
|
||||||
) {
|
) {
|
||||||
val song = db.libraryDao().songOnce(sourceId, songId)
|
val song = db.libraryDao().songOnce(sourceId, songId)
|
||||||
|
val album = song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it) }
|
||||||
|
val songArtist = song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it) }
|
||||||
|
val albumArtist = album?.artistId?.let { db.libraryDao().artistOnce(sourceId, it) }
|
||||||
|
listOfNotNull(song?.artistId, album?.artistId).forEach(artistIds::add)
|
||||||
|
listOfNotNull(songArtist, albumArtist).forEach { storeArtistImage(sourceId, it) }
|
||||||
val coverArt =
|
val coverArt =
|
||||||
(
|
(
|
||||||
listOfNotNull(
|
listOfNotNull(album?.coverArt) +
|
||||||
song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it)?.coverArt },
|
db.downloadDao().playlistCoversForSong(sourceId, songId)
|
||||||
song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it)?.coverArt },
|
|
||||||
) + db.downloadDao().playlistCoversForSong(sourceId, songId)
|
|
||||||
).distinct()
|
).distinct()
|
||||||
coverArt.forEach { cover ->
|
coverArt.forEach { cover ->
|
||||||
storeArtwork(sourceId, cover, thumbnail = false)
|
storeArtwork(sourceId, cover, thumbnail = false)
|
||||||
@@ -415,6 +435,67 @@ class DownloadRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun storeArtistImage(
|
||||||
|
sourceId: Long,
|
||||||
|
artist: Artist,
|
||||||
|
) {
|
||||||
|
val imageUrl = artist.artistImageUrl
|
||||||
|
if (!imageUrl.isNullOrBlank()) {
|
||||||
|
val cacheKey = artistImageKey(sourceId, artist.id)
|
||||||
|
if (artworkStore.file(sourceId, cacheKey).exists()) return
|
||||||
|
val attemptKey = "$sourceId:${artist.id}:$imageUrl"
|
||||||
|
if (attemptKey !in failedArtistImages) {
|
||||||
|
val bytes =
|
||||||
|
try {
|
||||||
|
artworkFetcher.fetch(imageUrl)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (bytes != null && bytes.isNotEmpty()) {
|
||||||
|
artworkStore.write(sourceId, cacheKey, bytes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
failedArtistImages += attemptKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artist.coverArt?.let {
|
||||||
|
storeArtwork(sourceId, it, thumbnail = false)
|
||||||
|
storeArtwork(sourceId, it, thumbnail = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchArtistBio(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
) {
|
||||||
|
if (db.libraryDao().artistBioOnce(sourceId, artistId) != null) return
|
||||||
|
val key = sourceId to artistId
|
||||||
|
if (!attemptedBios.add(key)) return
|
||||||
|
try {
|
||||||
|
val biography = sourceRepository.artistBiography(sourceId, artistId)
|
||||||
|
db.libraryDao().upsertArtistBio(
|
||||||
|
ArtistBio(sourceId, artistId, normalizeBiography(biography)),
|
||||||
|
)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
} finally {
|
||||||
|
attemptedBios.remove(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchArtistBiosSafely(
|
||||||
|
sourceId: Long,
|
||||||
|
artistIds: Set<String>,
|
||||||
|
) {
|
||||||
|
if (artistIds.isEmpty()) return
|
||||||
|
withTimeoutOrNull(BIO_TIMEOUT_MS) {
|
||||||
|
artistIds.forEach { fetchArtistBio(sourceId, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun storeArtwork(
|
private suspend fun storeArtwork(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
coverArt: String,
|
coverArt: String,
|
||||||
@@ -449,11 +530,15 @@ class DownloadRepository(
|
|||||||
.mapValues { (sourceId, rows) ->
|
.mapValues { (sourceId, rows) ->
|
||||||
if (rows.any { it.hasMissingLibraryRow }) return@mapValues null
|
if (rows.any { it.hasMissingLibraryRow }) return@mapValues null
|
||||||
val covers =
|
val covers =
|
||||||
rows.flatMap { row -> listOfNotNull(row.albumCoverArt, row.artistCoverArt) } +
|
rows.flatMap { row ->
|
||||||
playlistCovers[sourceId].orEmpty()
|
listOfNotNull(row.albumCoverArt, row.artistCoverArt, row.albumArtistCoverArt)
|
||||||
covers
|
} + playlistCovers[sourceId].orEmpty()
|
||||||
.flatMap { cover -> listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) } }
|
val artistImages = rows.flatMap { row -> listOfNotNull(row.artistId, row.albumArtistId) }
|
||||||
.toSet()
|
(
|
||||||
|
covers.flatMap { cover ->
|
||||||
|
listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) }
|
||||||
|
} + artistImages.map { artistImageKey(sourceId, it) }
|
||||||
|
).toSet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,6 +588,7 @@ class DownloadRepository(
|
|||||||
row.engineId?.let { engine.cancel(listOf(it)) }
|
row.engineId?.let { engine.cancel(listOf(it)) }
|
||||||
file(sourceId, songId).delete()
|
file(sourceId, songId).delete()
|
||||||
db.downloadDao().delete(sourceId, songId)
|
db.downloadDao().delete(sourceId, songId)
|
||||||
|
if (row.status == DownloadStatus.Completed) refreshDownloadedArtistArt(sourceId, listOf(songId))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,11 +617,12 @@ class DownloadRepository(
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
songIds: List<String>,
|
songIds: List<String>,
|
||||||
) {
|
) {
|
||||||
|
val artistIds = mutableSetOf<String>()
|
||||||
try {
|
try {
|
||||||
withTimeoutOrNull(ART_TIMEOUT_MS) {
|
withTimeoutOrNull(ART_TIMEOUT_MS) {
|
||||||
songIds.forEach { songId ->
|
songIds.forEach { songId ->
|
||||||
// A cancel between the queue and this pass takes the row away; nothing to warm.
|
// A cancel between the queue and this pass takes the row away; nothing to warm.
|
||||||
if (db.downloadDao().find(sourceId, songId) != null) fetchArtwork(sourceId, songId)
|
if (db.downloadDao().find(sourceId, songId) != null) fetchArtwork(sourceId, songId, artistIds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
@@ -543,6 +630,7 @@ class DownloadRepository(
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Artwork is best effort and must never disturb a download that is already running.
|
// Artwork is best effort and must never disturb a download that is already running.
|
||||||
}
|
}
|
||||||
|
fetchArtistBiosSafely(sourceId, artistIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun apply(
|
private suspend fun apply(
|
||||||
@@ -581,6 +669,19 @@ class DownloadRepository(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (updated != row) db.downloadDao().upsert(updated)
|
if (updated != row) db.downloadDao().upsert(updated)
|
||||||
|
if (updated.status != row.status &&
|
||||||
|
(updated.status == DownloadStatus.Completed || row.status == DownloadStatus.Completed)
|
||||||
|
) {
|
||||||
|
refreshDownloadedArtistArt(row.sourceId, listOf(row.songId))
|
||||||
|
}
|
||||||
|
if (updated.status != row.status) {
|
||||||
|
when (updated.status) {
|
||||||
|
DownloadStatus.Completed -> Log.i("download", "completed ${row.songId} (${updated.total} bytes)")
|
||||||
|
DownloadStatus.Failed -> Log.w("download", "failed ${row.songId}: ${updated.error}")
|
||||||
|
DownloadStatus.Running -> Log.i("download", "started ${row.songId}")
|
||||||
|
DownloadStatus.Queued -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun markFailed(
|
private suspend fun markFailed(
|
||||||
@@ -590,6 +691,20 @@ class DownloadRepository(
|
|||||||
row.engineId?.let { engine.cancel(listOf(it)) }
|
row.engineId?.let { engine.cancel(listOf(it)) }
|
||||||
file(row.sourceId, row.songId).delete()
|
file(row.sourceId, row.songId).delete()
|
||||||
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
|
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
|
||||||
|
if (row.status == DownloadStatus.Completed) {
|
||||||
|
refreshDownloadedArtistArt(row.sourceId, listOf(row.songId))
|
||||||
|
}
|
||||||
|
Log.w("download", "failed ${row.songId}: $error")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshDownloadedArtistArt(
|
||||||
|
sourceId: Long,
|
||||||
|
songIds: Collection<String>,
|
||||||
|
) {
|
||||||
|
songIds.chunked(500).forEach { songs ->
|
||||||
|
val artistIds = db.libraryDao().creditedArtistIdsForSongs(sourceId, songs).distinct()
|
||||||
|
artistIds.chunked(500).forEach { db.libraryDao().recomputeDownloadedArtistArt(sourceId, it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensurePolling() {
|
private fun ensurePolling() {
|
||||||
@@ -658,5 +773,6 @@ class DownloadRepository(
|
|||||||
const val IN_FLIGHT_LIMIT = 8
|
const val IN_FLIGHT_LIMIT = 8
|
||||||
const val DELETE_CHUNK = 900
|
const val DELETE_CHUNK = 900
|
||||||
const val ART_TIMEOUT_MS = 60_000L
|
const val ART_TIMEOUT_MS = 60_000L
|
||||||
|
const val BIO_TIMEOUT_MS = 30_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import androidx.paging.PagingSource
|
|||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.db.albumsDefaultQuery
|
import com.subtracks.data.db.albumsPageQuery
|
||||||
import com.subtracks.data.db.artistsDefaultQuery
|
import com.subtracks.data.db.artistsPageQuery
|
||||||
import com.subtracks.data.db.playlistsDefaultQuery
|
import com.subtracks.data.db.playlistsPageQuery
|
||||||
import com.subtracks.data.db.rediscoverAlbumsQuery
|
import com.subtracks.data.db.rediscoverAlbumsQuery
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistAlbums
|
||||||
|
import com.subtracks.data.model.ArtistBio
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
@@ -22,24 +24,32 @@ import com.subtracks.data.prefs.AlbumSort
|
|||||||
import com.subtracks.data.prefs.ArtistSort
|
import com.subtracks.data.prefs.ArtistSort
|
||||||
import com.subtracks.data.prefs.PlaylistSort
|
import com.subtracks.data.prefs.PlaylistSort
|
||||||
import com.subtracks.data.prefs.StarredFilter
|
import com.subtracks.data.prefs.StarredFilter
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class LibraryRepository(
|
class LibraryRepository(
|
||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val serverActions: ServerActionSink,
|
private val serverActions: ServerActionSink,
|
||||||
|
private val userPreferences: UserPreferences,
|
||||||
private val showMessage: (UiMessage) -> Unit = {},
|
private val showMessage: (UiMessage) -> Unit = {},
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
) {
|
) {
|
||||||
@@ -106,12 +116,18 @@ class LibraryRepository(
|
|||||||
fun searchAlbums(
|
fun searchAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
query: String,
|
query: String,
|
||||||
): Flow<List<Album>> = db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT)
|
): Flow<List<Album>> =
|
||||||
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
|
db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||||
|
}
|
||||||
|
|
||||||
fun searchArtists(
|
fun searchArtists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
query: String,
|
query: String,
|
||||||
): Flow<List<Artist>> = db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT)
|
): Flow<List<Artist>> =
|
||||||
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
|
db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||||
|
}
|
||||||
|
|
||||||
fun searchPlaylists(
|
fun searchPlaylists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -121,7 +137,10 @@ class LibraryRepository(
|
|||||||
fun searchSongs(
|
fun searchSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
query: String,
|
query: String,
|
||||||
): Flow<List<AlbumSongItem>> = db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT)
|
): Flow<List<AlbumSongItem>> =
|
||||||
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
|
db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||||
|
}
|
||||||
|
|
||||||
fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
|
fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
|
||||||
|
|
||||||
@@ -135,9 +154,9 @@ class LibraryRepository(
|
|||||||
suspend fun genreSongOrdinal(
|
suspend fun genreSongOrdinal(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
genre: String,
|
genre: String,
|
||||||
title: String,
|
sortName: String,
|
||||||
id: String,
|
id: String,
|
||||||
): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, title, id)
|
): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, sortName, id)
|
||||||
|
|
||||||
suspend fun downloadedSongOrdinal(
|
suspend fun downloadedSongOrdinal(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -173,69 +192,20 @@ class LibraryRepository(
|
|||||||
search: String,
|
search: String,
|
||||||
downloaded: Boolean,
|
downloaded: Boolean,
|
||||||
): Flow<PagingData<Album>> =
|
): Flow<PagingData<Album>> =
|
||||||
pager(40) {
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
val dao = db.libraryDao()
|
pager(40, enablePlaceholders = true) {
|
||||||
if (!downloaded) {
|
val dao = db.libraryDao()
|
||||||
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
val query =
|
||||||
} else {
|
albumsPageQuery(
|
||||||
val downloadedFilter = 1
|
sourceId = sourceId,
|
||||||
when (sort) {
|
starredFilter = starred.ordinal,
|
||||||
AlbumSort.Name -> {
|
search = search,
|
||||||
if (descending) {
|
sort = sort,
|
||||||
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
descending = descending,
|
||||||
} else {
|
downloaded = downloaded,
|
||||||
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
sortNameMode = mode,
|
||||||
}
|
)
|
||||||
}
|
if (downloaded) dao.downloadedAlbumsPaging(query) else dao.albumsPaging(query)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,53 +217,20 @@ class LibraryRepository(
|
|||||||
search: String,
|
search: String,
|
||||||
downloaded: Boolean,
|
downloaded: Boolean,
|
||||||
): Flow<PagingData<Artist>> =
|
): Flow<PagingData<Artist>> =
|
||||||
pager(60) {
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
val dao = db.libraryDao()
|
pager(60) {
|
||||||
if (!downloaded) {
|
val dao = db.libraryDao()
|
||||||
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
val query =
|
||||||
} else {
|
artistsPageQuery(
|
||||||
val downloadedFilter = 1
|
sourceId = sourceId,
|
||||||
when (sort) {
|
starredFilter = starred.ordinal,
|
||||||
ArtistSort.Name -> {
|
search = search,
|
||||||
if (descending) {
|
sort = sort,
|
||||||
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
descending = descending,
|
||||||
} else {
|
downloaded = downloaded,
|
||||||
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
sortNameMode = mode,
|
||||||
}
|
)
|
||||||
}
|
if (downloaded) dao.downloadedArtistsPaging(query) else dao.artistsPaging(query)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,36 +243,15 @@ class LibraryRepository(
|
|||||||
): Flow<PagingData<Playlist>> =
|
): Flow<PagingData<Playlist>> =
|
||||||
pager(40) {
|
pager(40) {
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
if (!downloaded) {
|
val query =
|
||||||
dao.playlistsPaging(playlistsDefaultQuery(sourceId, search, sort, descending))
|
playlistsPageQuery(
|
||||||
} else {
|
sourceId = sourceId,
|
||||||
val downloadedFilter = 1
|
search = search,
|
||||||
when (sort) {
|
sort = sort,
|
||||||
PlaylistSort.Name -> {
|
descending = descending,
|
||||||
if (descending) {
|
downloaded = downloaded,
|
||||||
dao.playlistsByNameReversed(sourceId, search, downloadedFilter)
|
)
|
||||||
} else {
|
if (downloaded) dao.downloadedPlaylistsPaging(query) else dao.playlistsPaging(query)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun album(
|
fun album(
|
||||||
@@ -353,6 +269,36 @@ class LibraryRepository(
|
|||||||
artistId: String,
|
artistId: String,
|
||||||
): Flow<String?> = db.libraryDao().artistName(sourceId, artistId)
|
): Flow<String?> = db.libraryDao().artistName(sourceId, artistId)
|
||||||
|
|
||||||
|
fun artistBio(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Flow<ArtistBio?> =
|
||||||
|
db
|
||||||
|
.libraryDao()
|
||||||
|
.artistBio(sourceId, artistId)
|
||||||
|
.map { bio -> bio?.copy(biography = normalizeBiography(bio.biography)) }
|
||||||
|
|
||||||
|
suspend fun refreshArtistBio(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Result<Unit> =
|
||||||
|
try {
|
||||||
|
val fetched = normalizeBiography(sourceRepository.artistBiography(sourceId, artistId))
|
||||||
|
val current = db.libraryDao().artistBioOnce(sourceId, artistId)
|
||||||
|
// An empty response can mean the metadata provider is temporarily down, so keep the
|
||||||
|
// cached biography instead of erasing it.
|
||||||
|
val biography = fetched ?: current?.biography
|
||||||
|
if (current == null || current.biography != biography) {
|
||||||
|
db.libraryDao().upsertArtistBio(ArtistBio(sourceId, artistId, biography))
|
||||||
|
}
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (failure: Exception) {
|
||||||
|
Log.w("artist", "failed to refresh bio for $artistId", failure)
|
||||||
|
Result.failure(failure)
|
||||||
|
}
|
||||||
|
|
||||||
fun song(
|
fun song(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
songId: String,
|
songId: String,
|
||||||
@@ -375,12 +321,24 @@ class LibraryRepository(
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
downloaded: Boolean = false,
|
downloaded: Boolean = false,
|
||||||
): Flow<List<Album>> =
|
): Flow<ArtistAlbums> {
|
||||||
if (downloaded) {
|
val albums =
|
||||||
db.libraryDao().albumsForArtistDownloaded(sourceId, artistId)
|
if (downloaded) {
|
||||||
} else {
|
db.libraryDao().albumsForArtistDownloaded(sourceId, artistId)
|
||||||
db.libraryDao().albumsForArtist(sourceId, artistId)
|
} else {
|
||||||
|
db.libraryDao().albumsForArtist(sourceId, artistId)
|
||||||
|
}
|
||||||
|
return combine(albums, db.libraryDao().albumCreditAlbumIds(sourceId, artistId)) { list, creditIds ->
|
||||||
|
val credits = creditIds.toHashSet()
|
||||||
|
val (albumCredits, appearances) = list.partition { it.id in credits }
|
||||||
|
ArtistAlbums(albumCredits = albumCredits, appearances = appearances)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun artistCoverArts(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Flow<List<String>> = db.libraryDao().artistCoverArts(sourceId, artistId)
|
||||||
|
|
||||||
fun albumDiscs(
|
fun albumDiscs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -402,10 +360,7 @@ class LibraryRepository(
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
albumId: String,
|
||||||
songId: String,
|
songId: String,
|
||||||
): Long {
|
): Long = db.queueDao().albumRank(sourceId, albumId, songId) ?: 0L
|
||||||
val index = db.queueDao().albumSongIds(sourceId, albumId).indexOf(songId)
|
|
||||||
return if (index < 0) 0L else index.toLong()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun playlist(
|
fun playlist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -464,14 +419,17 @@ class LibraryRepository(
|
|||||||
}
|
}
|
||||||
val previous = starredValue(sourceId, type, id)
|
val previous = starredValue(sourceId, type, id)
|
||||||
updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null)
|
updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null)
|
||||||
|
val action = if (starred) "star" else "unstar"
|
||||||
return try {
|
return try {
|
||||||
serverActions.setStar(type, id, starred)
|
serverActions.setStar(type, id, starred)
|
||||||
|
Log.i("star", "$action ${type.name.lowercase()} $id")
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (failure: Exception) {
|
} catch (failure: Exception) {
|
||||||
withContext(NonCancellable) { updateStarred(sourceId, type, id, previous) }
|
withContext(NonCancellable) { updateStarred(sourceId, type, id, previous) }
|
||||||
showMessage(UiMessage(R.string.star_update_failed))
|
showMessage(UiMessage(R.string.star_update_failed))
|
||||||
|
Log.w("star", "failed to $action ${type.name.lowercase()} $id", failure)
|
||||||
Result.failure(failure)
|
Result.failure(failure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -503,10 +461,11 @@ class LibraryRepository(
|
|||||||
|
|
||||||
private fun <T : Any> pager(
|
private fun <T : Any> pager(
|
||||||
pageSize: Int,
|
pageSize: Int,
|
||||||
|
enablePlaceholders: Boolean = false,
|
||||||
source: () -> PagingSource<Int, T>,
|
source: () -> PagingSource<Int, T>,
|
||||||
): Flow<PagingData<T>> =
|
): Flow<PagingData<T>> =
|
||||||
Pager(
|
Pager(
|
||||||
config = PagingConfig(pageSize = pageSize, enablePlaceholders = false),
|
config = PagingConfig(pageSize = pageSize, enablePlaceholders = enablePlaceholders),
|
||||||
pagingSourceFactory = source,
|
pagingSourceFactory = source,
|
||||||
).flow
|
).flow
|
||||||
}
|
}
|
||||||
@@ -515,4 +474,12 @@ const val REDISCOVER_WINDOW_MS = 60L * 24 * 60 * 60 * 1000
|
|||||||
|
|
||||||
const val SEARCH_RESULT_LIMIT = 50
|
const val SEARCH_RESULT_LIMIT = 50
|
||||||
|
|
||||||
|
internal fun normalizeBiography(value: String?): String? =
|
||||||
|
value
|
||||||
|
?.replace("\r\n", "\n")
|
||||||
|
?.replace('\r', '\n')
|
||||||
|
?.replace(Regex(" {3,}"), "\n\n")
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS
|
fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.subtracks.data.model.PendingActionKind
|
|||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.data.source.subsonic.SubsonicException
|
import com.subtracks.data.source.subsonic.SubsonicException
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -29,8 +30,13 @@ class PendingServerActionSink(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
var lastReady: Boolean? = null
|
||||||
combine(offlinePreferences, networkAvailable) { isOffline, online -> !isOffline && online }
|
combine(offlinePreferences, networkAvailable) { isOffline, online -> !isOffline && online }
|
||||||
.collect { ready ->
|
.collect { ready ->
|
||||||
|
if (ready && lastReady == false) {
|
||||||
|
Log.i("net", "connection available, replaying pending server actions")
|
||||||
|
}
|
||||||
|
lastReady = ready
|
||||||
// The persisted flag and connectivity are authoritative, unlike the process
|
// The persisted flag and connectivity are authoritative, unlike the process
|
||||||
// flag's default at cold start, so wait for that flag to agree before replaying.
|
// flag's default at cold start, so wait for that flag to agree before replaying.
|
||||||
if (ready) {
|
if (ready) {
|
||||||
@@ -96,12 +102,14 @@ class PendingServerActionSink(
|
|||||||
for (action in dao.pending(sourceId)) {
|
for (action in dao.pending(sourceId)) {
|
||||||
try {
|
try {
|
||||||
deliver(action)
|
deliver(action)
|
||||||
|
Log.i("net", "replayed ${action.kind.name.lowercase()} ${action.targetId}")
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (failure: Exception) {
|
} catch (failure: Exception) {
|
||||||
// Still unreachable: keep this and the remaining actions for the next attempt.
|
// Still unreachable: keep this and the remaining actions for the next attempt.
|
||||||
if (isRetryable(failure)) return
|
if (isRetryable(failure)) return
|
||||||
// The server answered and refused; drop the action rather than block the queue.
|
// The server answered and refused; drop the action rather than block the queue.
|
||||||
|
Log.w("net", "dropped ${action.kind.name.lowercase()} ${action.targetId}", failure)
|
||||||
}
|
}
|
||||||
dao.delete(action.id)
|
dao.delete(action.id)
|
||||||
}
|
}
|
||||||
@@ -139,6 +147,7 @@ class PendingServerActionSink(
|
|||||||
db.pendingActionDao().replace(
|
db.pendingActionDao().replace(
|
||||||
PendingAction(sourceId = sourceId, kind = kind, targetId = targetId, starType = starType, time = time),
|
PendingAction(sourceId = sourceId, kind = kind, targetId = targetId, starType = starType, time = time),
|
||||||
)
|
)
|
||||||
|
Log.i("net", "queued ${kind.name.lowercase()} $targetId")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,8 @@ data class QueueSnapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun locateContext(contextPlay: Long): Pair<QueueEntry, Long>? = locateIn(entries, flatContext(contextPlay) ?: return null)
|
fun locateContext(contextPlay: Long): Pair<QueueEntry, Long>? = locateIn(entries, flatContext(contextPlay) ?: return null)
|
||||||
|
|
||||||
|
fun isAlbumContext(position: Long): Boolean = locate(position)?.first?.kind == QueueKind.Album
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun entryIndexAfter(
|
private fun entryIndexAfter(
|
||||||
@@ -207,6 +209,202 @@ data class Reanchored(
|
|||||||
val changed: Boolean,
|
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(
|
class QueueRepository(
|
||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
) {
|
) {
|
||||||
@@ -214,12 +412,6 @@ class QueueRepository(
|
|||||||
|
|
||||||
private val queueVersion = AtomicLong()
|
private val queueVersion = AtomicLong()
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var cachedFlatIds: List<String>? = null
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var cachedFlatVersion = -1L
|
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var shuffleRemoved: ShuffleState? = null
|
private var shuffleRemoved: ShuffleState? = null
|
||||||
|
|
||||||
@@ -233,6 +425,9 @@ class QueueRepository(
|
|||||||
internal var offsetSeeks = 0L
|
internal var offsetSeeks = 0L
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
internal var resolvedRowCount = 0L
|
||||||
|
private set
|
||||||
|
|
||||||
private val cursorMutex = Mutex()
|
private val cursorMutex = Mutex()
|
||||||
|
|
||||||
fun playlistEntry(
|
fun playlistEntry(
|
||||||
@@ -295,8 +490,8 @@ class QueueRepository(
|
|||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// An explicit song entry in the middle of a queue is a reordered context track, so
|
// A song entry can be a single or a track reordered out of a playlist, artist or
|
||||||
// label it by its album rather than losing the context name.
|
// genre, so label it by its album rather than losing the context name.
|
||||||
QueueKind.Song -> {
|
QueueKind.Song -> {
|
||||||
library
|
library
|
||||||
.song(sourceId, refId)
|
.song(sourceId, refId)
|
||||||
@@ -386,7 +581,6 @@ class QueueRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun invalidateLibraryCache() {
|
fun invalidateLibraryCache() {
|
||||||
cachedFlatIds = null
|
|
||||||
lengthCache.clear()
|
lengthCache.clear()
|
||||||
keyCache.clear()
|
keyCache.clear()
|
||||||
shuffleRemoved = null
|
shuffleRemoved = null
|
||||||
@@ -407,8 +601,8 @@ class QueueRepository(
|
|||||||
snapshot: QueueSnapshot,
|
snapshot: QueueSnapshot,
|
||||||
songId: String,
|
songId: String,
|
||||||
): Long? {
|
): Long? {
|
||||||
val upIndex = idsFor(snapshot.upNext).indexOf(songId)
|
val upIndex = flatIndexOf(snapshot.upNext, songId)
|
||||||
if (upIndex >= 0) return snapshot.anchorPlay + 1 + upIndex
|
if (upIndex != null) return snapshot.anchorPlay + 1 + upIndex
|
||||||
return combinedContextIndexOf(snapshot, songId)
|
return combinedContextIndexOf(snapshot, songId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,9 +610,8 @@ class QueueRepository(
|
|||||||
snapshot: QueueSnapshot,
|
snapshot: QueueSnapshot,
|
||||||
songId: String,
|
songId: String,
|
||||||
): Long? {
|
): Long? {
|
||||||
val flat = flatIds(snapshot).indexOf(songId)
|
val flat = flatIndexOf(snapshot.entries, songId) ?: return null
|
||||||
if (flat < 0) return null
|
return snapshot.playContext(flat)?.let { snapshot.combined(it) }
|
||||||
return snapshot.playContext(flat.toLong())?.let { snapshot.combined(it) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun range(
|
suspend fun range(
|
||||||
@@ -581,9 +774,9 @@ class QueueRepository(
|
|||||||
val toUpNext = snapshot.isUpNext(to)
|
val toUpNext = snapshot.isUpNext(to)
|
||||||
if (fromUpNext && toUpNext) {
|
if (fromUpNext && toUpNext) {
|
||||||
val song = itemAt(snapshot, from) ?: return false
|
val song = itemAt(snapshot, from) ?: return false
|
||||||
val sourceId = snapshot.locate(from)?.first?.sourceId ?: return false
|
val located = snapshot.locate(from) ?: return false
|
||||||
val removed = removeEntry(snapshot.upNext, snapshot.upNextIndex(from))
|
val removed = removeEntry(snapshot.upNext, snapshot.upNextIndex(from))
|
||||||
val moved = ResolvedQueueEntry(songEntry(sourceId, song.song.id), 1L)
|
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L)
|
||||||
writeUpNext(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry })
|
writeUpNext(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -597,7 +790,7 @@ class QueueRepository(
|
|||||||
val located = snapshot.locateContext(playFrom) ?: return false
|
val located = snapshot.locateContext(playFrom) ?: return false
|
||||||
val song = itemAt(snapshot, from) ?: return false
|
val song = itemAt(snapshot, from) ?: return false
|
||||||
val removed = removeEntry(snapshot.entries, playFrom)
|
val removed = removeEntry(snapshot.entries, playFrom)
|
||||||
val moved = ResolvedQueueEntry(songEntry(located.first.sourceId, song.song.id), 1L)
|
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L)
|
||||||
val insertAt = playTo.coerceIn(0, removed.sumOf { it.length })
|
val insertAt = playTo.coerceIn(0, removed.sumOf { it.length })
|
||||||
write(compact(insertEntry(removed, insertAt, moved)).map { it.entry })
|
write(compact(insertEntry(removed, insertAt, moved)).map { it.entry })
|
||||||
adjustAnchorOnMove(playFrom, insertAt)
|
adjustAnchorOnMove(playFrom, insertAt)
|
||||||
@@ -631,7 +824,6 @@ class QueueRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
queueVersion.incrementAndGet()
|
queueVersion.incrementAndGet()
|
||||||
cachedFlatIds = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun writeUpNext(entries: List<QueueEntry>) {
|
private suspend fun writeUpNext(entries: List<QueueEntry>) {
|
||||||
@@ -648,34 +840,40 @@ class QueueRepository(
|
|||||||
queueVersion.incrementAndGet()
|
queueVersion.incrementAndGet()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun flatIds(snapshot: QueueSnapshot): List<String> {
|
private suspend fun flatIndexOf(
|
||||||
val cached = cachedFlatIds
|
entries: List<ResolvedQueueEntry>,
|
||||||
if (cached != null && cachedFlatVersion == snapshot.version && cached.size.toLong() == snapshot.contextSize) return cached
|
songId: String,
|
||||||
val ids = idsFor(snapshot.entries)
|
): Long? {
|
||||||
cachedFlatIds = ids
|
var start = 0L
|
||||||
cachedFlatVersion = snapshot.version
|
for (resolved in entries) {
|
||||||
return ids
|
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> {
|
private suspend fun fallbackRank(
|
||||||
val ids = ArrayList<String>()
|
entry: QueueEntry,
|
||||||
for (entry in resolved) {
|
length: Long,
|
||||||
val ref = entry.entry
|
songId: String,
|
||||||
val entryIds =
|
): Long? {
|
||||||
when (ref.kind) {
|
var offset = entry.offset
|
||||||
QueueKind.Song -> listOf(ref.refId)
|
val end = entry.offset + length
|
||||||
QueueKind.Album -> dao.albumSongIds(ref.sourceId, ref.refId)
|
while (offset < end) {
|
||||||
QueueKind.Playlist -> dao.playlistSongIds(ref.sourceId, ref.refId)
|
val limit = minOf(QUEUE_CHUNK.toLong(), end - offset).toInt()
|
||||||
QueueKind.Artist -> dao.artistSongIds(ref.sourceId, ref.refId)
|
val batch = rows(entry, offset, limit)
|
||||||
QueueKind.Genre -> dao.genreSongIds(ref.sourceId, ref.refId)
|
val index = batch.indexOfFirst { it.song.id == songId }
|
||||||
QueueKind.Downloaded -> dao.downloadedSongIds(ref.sourceId)
|
if (index >= 0) return offset + index
|
||||||
QueueKind.Starred -> dao.starredSongIds(ref.sourceId)
|
if (batch.size < limit) return null
|
||||||
}
|
offset += batch.size
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
return ids
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun contextPlayRange(
|
private suspend fun contextPlayRange(
|
||||||
@@ -686,21 +884,11 @@ class QueueRepository(
|
|||||||
if (first > last) return emptyList()
|
if (first > last) return emptyList()
|
||||||
val items = mutableListOf<QueueWindowItem>()
|
val items = mutableListOf<QueueWindowItem>()
|
||||||
if (snapshot.shuffled) {
|
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) {
|
for (play in first..last) {
|
||||||
val flat = snapshot.flatContext(play) ?: continue
|
val located = snapshot.locateContext(play) ?: continue
|
||||||
val id = ids.getOrNull(flat.toInt()) ?: continue
|
val item = rows(located.first, located.first.offset + located.second, 1).firstOrNull() ?: continue
|
||||||
requested += play to id
|
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
|
return items
|
||||||
}
|
}
|
||||||
var start = 0L
|
var start = 0L
|
||||||
@@ -755,27 +943,20 @@ class QueueRepository(
|
|||||||
): List<SongItem> {
|
): List<SongItem> {
|
||||||
if (entry.kind == QueueKind.Song) {
|
if (entry.kind == QueueKind.Song) {
|
||||||
offsetSeeks++
|
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 ref = QueueRef(entry.kind, entry.sourceId, entry.refId)
|
||||||
val anchored = keyCache[ref]?.let { keyedRows(entry, offset, limit, it) }
|
val anchored = keyCache[ref]?.let { keyedRows(entry, offset, limit, it) }
|
||||||
if (anchored != null) {
|
if (anchored != null) {
|
||||||
keysetSeeks++
|
keysetSeeks++
|
||||||
if (anchored.isNotEmpty()) remember(ref, offset, anchored)
|
if (anchored.isNotEmpty()) remember(ref, offset, anchored)
|
||||||
|
resolvedRowCount += anchored.size
|
||||||
return anchored
|
return anchored
|
||||||
}
|
}
|
||||||
offsetSeeks++
|
offsetSeeks++
|
||||||
val fresh =
|
val fresh = resolverFor(entry.kind).rows(dao, entry, offset, limit)
|
||||||
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()
|
|
||||||
}
|
|
||||||
if (fresh.isNotEmpty()) remember(ref, offset, fresh)
|
if (fresh.isNotEmpty()) remember(ref, offset, fresh)
|
||||||
|
resolvedRowCount += fresh.size
|
||||||
return fresh
|
return fresh
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -808,7 +989,7 @@ class QueueRepository(
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
cursor: KeyCursor,
|
cursor: KeyCursor,
|
||||||
): List<SongItem>? {
|
): List<SongItem>? {
|
||||||
if (!cursor.key.seekable(entry.kind)) return null
|
if (!resolverFor(entry.kind).supportsKeyset(cursor.key)) return null
|
||||||
val from = cursor.ordinal
|
val from = cursor.ordinal
|
||||||
// A backward seek from a far cursor is not cheaper than the offset query: the album
|
// 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
|
// predicate is not sargable (it has to include NULL disc/track), so it scans the partition
|
||||||
@@ -834,43 +1015,14 @@ class QueueRepository(
|
|||||||
key: OrderKey,
|
key: OrderKey,
|
||||||
skip: Long,
|
skip: Long,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
): List<SongItem> =
|
): List<SongItem> = resolverFor(entry.kind).rowsFrom(dao, entry, key, skip, limit) ?: emptyList()
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun rowsBefore(
|
private suspend fun rowsBefore(
|
||||||
entry: QueueEntry,
|
entry: QueueEntry,
|
||||||
key: OrderKey,
|
key: OrderKey,
|
||||||
skip: Long,
|
skip: Long,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
): List<SongItem> =
|
): List<SongItem> = resolverFor(entry.kind).rowsBefore(dao, entry, key, skip, limit) ?: emptyList()
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun lengthOf(
|
private suspend fun lengthOf(
|
||||||
entry: QueueEntry,
|
entry: QueueEntry,
|
||||||
@@ -895,40 +1047,23 @@ class QueueRepository(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private suspend fun QueueEntry.resolvedLength(): Long {
|
private suspend fun QueueEntry.resolvedLength(): Long {
|
||||||
val total =
|
val total = resolverFor(kind).length(dao, this)
|
||||||
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 available = (total - offset).coerceAtLeast(0)
|
val available = (total - offset).coerceAtLeast(0)
|
||||||
return count?.coerceAtMost(available) ?: available
|
return count?.coerceAtMost(available) ?: available
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun movedEntry(
|
||||||
|
source: QueueEntry,
|
||||||
|
songId: String,
|
||||||
|
offsetInEntry: Long,
|
||||||
|
): QueueEntry =
|
||||||
|
if (source.kind == QueueKind.Album) {
|
||||||
|
val offset = source.offset + offsetInEntry
|
||||||
|
source.range(offset, offset)
|
||||||
|
} else {
|
||||||
|
songEntry(source.sourceId, songId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun QueueEntry.range(
|
private fun QueueEntry.range(
|
||||||
start: Long,
|
start: Long,
|
||||||
end: Long,
|
end: Long,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.subtracks.data.model.CoverArtRef
|
|||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.model.SubsonicConfig
|
import com.subtracks.data.model.SubsonicConfig
|
||||||
import com.subtracks.data.model.SubsonicSource
|
import com.subtracks.data.model.SubsonicSource
|
||||||
|
import com.subtracks.data.model.artistImageKey
|
||||||
import com.subtracks.data.model.coverArtKey
|
import com.subtracks.data.model.coverArtKey
|
||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
@@ -17,6 +18,7 @@ import com.subtracks.data.source.MusicSource
|
|||||||
import com.subtracks.data.source.declaredStreamLength
|
import com.subtracks.data.source.declaredStreamLength
|
||||||
import com.subtracks.data.source.streamLengthSuffix
|
import com.subtracks.data.source.streamLengthSuffix
|
||||||
import com.subtracks.data.source.subsonic.SubsonicClient
|
import com.subtracks.data.source.subsonic.SubsonicClient
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -46,6 +48,13 @@ class SourceRepository(
|
|||||||
val source: SubsonicMusicSource,
|
val source: SubsonicMusicSource,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class ResolvedSource(
|
||||||
|
val config: SubsonicConfig?,
|
||||||
|
val quality: StreamQuality,
|
||||||
|
val mode: NetworkMode,
|
||||||
|
val concurrency: Int,
|
||||||
|
)
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var active: ActiveSource? = null
|
private var active: ActiveSource? = null
|
||||||
|
|
||||||
@@ -61,9 +70,13 @@ class SourceRepository(
|
|||||||
private val _offline = MutableStateFlow(false)
|
private val _offline = MutableStateFlow(false)
|
||||||
val offline: StateFlow<Boolean> = _offline
|
val offline: StateFlow<Boolean> = _offline
|
||||||
|
|
||||||
|
private val _activeSource = MutableStateFlow<Long?>(null)
|
||||||
|
val activeSource: StateFlow<Long?> = _activeSource
|
||||||
|
|
||||||
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
||||||
|
|
||||||
fun setOfflineMode(enabled: Boolean) {
|
fun setOfflineMode(enabled: Boolean) {
|
||||||
|
Log.i("net", "offline mode -> $enabled")
|
||||||
scope.launch { prefs.setOfflineMode(enabled) }
|
scope.launch { prefs.setOfflineMode(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +94,7 @@ class SourceRepository(
|
|||||||
// Read the persisted flag before the source config can build a source or probe it, so a
|
// Read the persisted flag before the source config can build a source or probe it, so a
|
||||||
// cold start with offline on never touches the network.
|
// cold start with offline on never touches the network.
|
||||||
_offline.value = prefs.offlineMode().first()
|
_offline.value = prefs.offlineMode().first()
|
||||||
|
var lastMode: NetworkMode? = null
|
||||||
combine(
|
combine(
|
||||||
db.sourcesDao().activeSubsonicConfig(),
|
db.sourcesDao().activeSubsonicConfig(),
|
||||||
networkMode,
|
networkMode,
|
||||||
@@ -88,13 +102,24 @@ class SourceRepository(
|
|||||||
prefs.streamQuality(NetworkMode.Mobile),
|
prefs.streamQuality(NetworkMode.Mobile),
|
||||||
prefs.syncConcurrency(),
|
prefs.syncConcurrency(),
|
||||||
) { config, mode, wifi, mobile, concurrency ->
|
) { config, mode, wifi, mobile, concurrency ->
|
||||||
Triple(config, if (mode == NetworkMode.Wifi) wifi else mobile, concurrency)
|
ResolvedSource(config, if (mode == NetworkMode.Wifi) wifi else mobile, mode, concurrency)
|
||||||
}.collect { (config, quality, concurrency) ->
|
}.collect { (config, quality, mode, concurrency) ->
|
||||||
|
if (mode != lastMode) {
|
||||||
|
Log.i("net", "network mode -> ${mode.key}")
|
||||||
|
lastMode = mode
|
||||||
|
}
|
||||||
val sourceChanged = config?.id != active?.id
|
val sourceChanged = config?.id != active?.id
|
||||||
|
if (sourceChanged) {
|
||||||
|
Log.i("net", "active source -> ${config?.name ?: "none"}")
|
||||||
|
}
|
||||||
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
|
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
|
||||||
|
_activeSource.value = config?.id
|
||||||
_quality.value = quality
|
_quality.value = quality
|
||||||
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
|
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
|
||||||
scope.launch { runCatching { config.toClient().check("ping") } }
|
scope.launch {
|
||||||
|
runCatching { config.toClient().check("ping") }
|
||||||
|
.onSuccess { Log.i("net", "connected (ping ok)") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,6 +148,18 @@ class SourceRepository(
|
|||||||
return CoverArtRef(url = url, cacheKey = cacheKey)
|
return CoverArtRef(url = url, cacheKey = cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun artistImage(
|
||||||
|
artistId: String,
|
||||||
|
url: String?,
|
||||||
|
): CoverArtRef? {
|
||||||
|
val current = active ?: return null
|
||||||
|
if (url.isNullOrBlank()) return null
|
||||||
|
val cacheKey = artistImageKey(current.id, artistId)
|
||||||
|
artworkStore.uri(current.id, cacheKey)?.let { return CoverArtRef(url = it, cacheKey = cacheKey) }
|
||||||
|
if (_offline.value) return null
|
||||||
|
return CoverArtRef(url = url, cacheKey = cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
fun networkCoverArt(
|
fun networkCoverArt(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
coverArt: String,
|
coverArt: String,
|
||||||
@@ -200,8 +237,8 @@ class SourceRepository(
|
|||||||
useTokenAuth: Boolean,
|
useTokenAuth: Boolean,
|
||||||
) {
|
) {
|
||||||
val dao = db.sourcesDao()
|
val dao = db.sourcesDao()
|
||||||
val source = dao.sourceOnce(id) ?: return
|
if (dao.sourceOnce(id) == null) return
|
||||||
dao.upsertSource(source.copy(name = name.trim(), address = normalizeAddress(address)))
|
dao.updateSourceDetails(id, name.trim(), normalizeAddress(address))
|
||||||
dao.upsertSubsonicSource(
|
dao.upsertSubsonicSource(
|
||||||
SubsonicSource(
|
SubsonicSource(
|
||||||
sourceId = id,
|
sourceId = id,
|
||||||
@@ -241,6 +278,15 @@ class SourceRepository(
|
|||||||
return config.toMusicSource(quality.value, prefs.syncConcurrency().first())
|
return config.toMusicSource(quality.value, prefs.syncConcurrency().first())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun artistBiography(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): String? {
|
||||||
|
check(!_offline.value) { "Offline mode is on" }
|
||||||
|
val config = db.sourcesDao().subsonicConfigOnce(sourceId) ?: error("No configured source")
|
||||||
|
return config.toMusicSource(quality.value, prefs.syncConcurrency().first()).artistBiography(artistId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun SubsonicConfig.toClient(): SubsonicClient =
|
private fun SubsonicConfig.toClient(): SubsonicClient =
|
||||||
client(
|
client(
|
||||||
address = address,
|
address = address,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ interface MusicSource {
|
|||||||
time: Long? = null,
|
time: Long? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
suspend fun artistBiography(artistId: String): String? = null
|
||||||
|
|
||||||
fun artists(): Flow<List<Artist>>
|
fun artists(): Flow<List<Artist>>
|
||||||
|
|
||||||
fun albums(): Flow<List<Album>>
|
fun albums(): Flow<List<Album>>
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package com.subtracks.data.source.subsonic
|
|||||||
|
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
|
import com.subtracks.log.Log
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
open class SubsonicException(
|
open class SubsonicException(
|
||||||
val code: Int,
|
val code: Int,
|
||||||
@@ -20,7 +22,6 @@ class SubsonicClient(
|
|||||||
private val password: String,
|
private val password: String,
|
||||||
useTokenAuth: Boolean,
|
useTokenAuth: Boolean,
|
||||||
private val http: OkHttpClient,
|
private val http: OkHttpClient,
|
||||||
private val userAgent: String = "subtracks/android",
|
|
||||||
private val onTokenAuthUnsupported: () -> Unit = {},
|
private val onTokenAuthUnsupported: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -55,30 +56,37 @@ class SubsonicClient(
|
|||||||
fun <T> stream(
|
fun <T> stream(
|
||||||
method: String,
|
method: String,
|
||||||
params: Map<String, String> = emptyMap(),
|
params: Map<String, String> = emptyMap(),
|
||||||
|
timeoutMs: Long? = null,
|
||||||
body: (InputStream) -> T,
|
body: (InputStream) -> T,
|
||||||
): T {
|
): T {
|
||||||
val usedTokenAuth = useTokenAuth
|
val usedTokenAuth = useTokenAuth
|
||||||
return try {
|
return try {
|
||||||
execute(method, params, body)
|
execute(method, params, timeoutMs, body)
|
||||||
} catch (failure: SubsonicException) {
|
} catch (failure: SubsonicException) {
|
||||||
if (failure.code !in TOKEN_AUTH_UNSUPPORTED_CODES || !usedTokenAuth) throw failure
|
if (failure.code !in TOKEN_AUTH_UNSUPPORTED_CODES || !usedTokenAuth) {
|
||||||
|
Log.w("http", "$method failed: server code ${failure.code} \"${failure.message}\"")
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
Log.w("http", "$method: token auth unsupported (server code ${failure.code}), retrying with plaintext")
|
||||||
disableTokenAuth()
|
disableTokenAuth()
|
||||||
execute(method, params, body)
|
execute(method, params, timeoutMs, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T> execute(
|
private fun <T> execute(
|
||||||
method: String,
|
method: String,
|
||||||
params: Map<String, String>,
|
params: Map<String, String>,
|
||||||
|
timeoutMs: Long?,
|
||||||
body: (InputStream) -> T,
|
body: (InputStream) -> T,
|
||||||
): T {
|
): T {
|
||||||
val request =
|
val request =
|
||||||
Request
|
Request
|
||||||
.Builder()
|
.Builder()
|
||||||
.url(uri(method, params))
|
.url(uri(method, params))
|
||||||
.header("User-Agent", userAgent)
|
|
||||||
.build()
|
.build()
|
||||||
http.newCall(request).execute().use { response ->
|
val call = http.newCall(request)
|
||||||
|
timeoutMs?.let { call.timeout().timeout(it, TimeUnit.MILLISECONDS) }
|
||||||
|
call.execute().use { response ->
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
throw SubsonicException(-1, "HTTP ${response.code}", UiMessage(R.string.error_http, listOf(response.code)))
|
throw SubsonicException(-1, "HTTP ${response.code}", UiMessage(R.string.error_http, listOf(response.code)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ import kotlinx.coroutines.flow.flow
|
|||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.xml.sax.Attributes
|
import org.xml.sax.Attributes
|
||||||
@@ -83,6 +85,13 @@ class SubsonicSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun artistBiography(artistId: String): String? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
client.stream("getArtistInfo2", mapOf("id" to artistId), timeoutMs = ARTIST_BIO_TIMEOUT_MS) { input ->
|
||||||
|
SubsonicXml.artistBiography(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun artists(): Flow<List<Artist>> =
|
override fun artists(): Flow<List<Artist>> =
|
||||||
entityBatches(
|
entityBatches(
|
||||||
method = "getArtists",
|
method = "getArtists",
|
||||||
@@ -101,7 +110,7 @@ class SubsonicSource(
|
|||||||
params = page("newest", offset),
|
params = page("newest", offset),
|
||||||
tag = "album",
|
tag = "album",
|
||||||
create = { SubsonicXml.albumDraft(id, it) },
|
create = { SubsonicXml.albumDraft(id, it) },
|
||||||
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
|
onChild = SubsonicXml::albumChild,
|
||||||
).collect { batch ->
|
).collect { batch ->
|
||||||
raw += batch.size
|
raw += batch.size
|
||||||
val accepted = batch.filter { it.id.isNotEmpty() }
|
val accepted = batch.filter { it.id.isNotEmpty() }
|
||||||
@@ -205,7 +214,7 @@ class SubsonicSource(
|
|||||||
),
|
),
|
||||||
tag = "song",
|
tag = "song",
|
||||||
create = { SubsonicXml.songDraft(id, it) },
|
create = { SubsonicXml.songDraft(id, it) },
|
||||||
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) },
|
onChild = SubsonicXml::songChild,
|
||||||
).collect { batch ->
|
).collect { batch ->
|
||||||
raw += batch.size
|
raw += batch.size
|
||||||
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
||||||
@@ -224,6 +233,23 @@ class SubsonicSource(
|
|||||||
private fun albumSongs(): Flow<List<Song>> =
|
private fun albumSongs(): Flow<List<Song>> =
|
||||||
channelFlow {
|
channelFlow {
|
||||||
val permits = Semaphore(maxConcurrentFetches)
|
val permits = Semaphore(maxConcurrentFetches)
|
||||||
|
val lock = Mutex()
|
||||||
|
val pending = ArrayList<Song>(PAGE_SIZE)
|
||||||
|
|
||||||
|
suspend fun accept(songs: List<Song>) {
|
||||||
|
lock
|
||||||
|
.withLock {
|
||||||
|
pending.addAll(songs)
|
||||||
|
if (pending.size < PAGE_SIZE) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val batch = ArrayList(pending.subList(0, PAGE_SIZE))
|
||||||
|
pending.subList(0, PAGE_SIZE).clear()
|
||||||
|
batch
|
||||||
|
}
|
||||||
|
}?.let { send(it) }
|
||||||
|
}
|
||||||
|
|
||||||
var offset = 0
|
var offset = 0
|
||||||
var pages = 0
|
var pages = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -248,10 +274,10 @@ class SubsonicSource(
|
|||||||
params = mapOf("id" to albumId),
|
params = mapOf("id" to albumId),
|
||||||
tag = "song",
|
tag = "song",
|
||||||
create = { SubsonicXml.songDraft(id, it) },
|
create = { SubsonicXml.songDraft(id, it) },
|
||||||
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) },
|
onChild = SubsonicXml::songChild,
|
||||||
).collect { batch ->
|
).collect { batch ->
|
||||||
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
||||||
if (accepted.isNotEmpty()) send(accepted)
|
accept(accepted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,7 +291,14 @@ class SubsonicSource(
|
|||||||
if (pages > maxPages) throw pageCapExceeded()
|
if (pages > maxPages) throw pageCapExceeded()
|
||||||
offset += PAGE_SIZE
|
offset += PAGE_SIZE
|
||||||
}
|
}
|
||||||
}
|
val tail =
|
||||||
|
lock.withLock {
|
||||||
|
val held = pending.toList()
|
||||||
|
pending.clear()
|
||||||
|
held
|
||||||
|
}
|
||||||
|
if (tail.isNotEmpty()) send(tail)
|
||||||
|
}.buffer(1)
|
||||||
|
|
||||||
private fun supportsEmptyQuerySearch(): Boolean {
|
private fun supportsEmptyQuerySearch(): Boolean {
|
||||||
emptyQuerySearchSupported?.let { return it }
|
emptyQuerySearchSupported?.let { return it }
|
||||||
@@ -297,7 +330,7 @@ class SubsonicSource(
|
|||||||
params: Map<String, String> = emptyMap(),
|
params: Map<String, String> = emptyMap(),
|
||||||
tag: String,
|
tag: String,
|
||||||
create: (Attributes) -> T,
|
create: (Attributes) -> T,
|
||||||
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
|
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
|
||||||
): Flow<List<T>> =
|
): Flow<List<T>> =
|
||||||
channelFlow {
|
channelFlow {
|
||||||
val buffer = ArrayList<T>(PAGE_SIZE)
|
val buffer = ArrayList<T>(PAGE_SIZE)
|
||||||
@@ -339,6 +372,7 @@ class SubsonicSource(
|
|||||||
const val PAGE_SIZE = 500
|
const val PAGE_SIZE = 500
|
||||||
const val MAX_PAGES = 20_000
|
const val MAX_PAGES = 20_000
|
||||||
const val THUMBNAIL_SIZE = 384
|
const val THUMBNAIL_SIZE = 384
|
||||||
|
const val ARTIST_BIO_TIMEOUT_MS = 15_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.subtracks.R
|
|||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
@@ -31,6 +32,8 @@ object SubsonicXml {
|
|||||||
albumCount = attrs.longAttr("albumCount") ?: 0L,
|
albumCount = attrs.longAttr("albumCount") ?: 0L,
|
||||||
starred = attrs.dateAttr("starred"),
|
starred = attrs.dateAttr("starred"),
|
||||||
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
||||||
|
artistImageUrl = attrs.attr("artistImageUrl").ifEmpty { null },
|
||||||
|
sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun albumDraft(
|
internal fun albumDraft(
|
||||||
@@ -43,6 +46,8 @@ object SubsonicXml {
|
|||||||
artistId = attrs.attr("artistId").ifEmpty { null },
|
artistId = attrs.attr("artistId").ifEmpty { null },
|
||||||
name = attrs.attr("name"),
|
name = attrs.attr("name"),
|
||||||
albumArtist = attrs.attr("artist").ifEmpty { null },
|
albumArtist = attrs.attr("artist").ifEmpty { null },
|
||||||
|
displayArtist = attrs.attr("displayArtist").ifEmpty { null },
|
||||||
|
sortName = attrs.attr("sortName").ifEmpty { null },
|
||||||
created = attrs.dateAttr("created") ?: 0L,
|
created = attrs.dateAttr("created") ?: 0L,
|
||||||
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
||||||
genre = attrs.attr("genre").ifEmpty { null },
|
genre = attrs.attr("genre").ifEmpty { null },
|
||||||
@@ -68,6 +73,8 @@ object SubsonicXml {
|
|||||||
title = attrs.attr("title"),
|
title = attrs.attr("title"),
|
||||||
album = attrs.attr("album").ifEmpty { null },
|
album = attrs.attr("album").ifEmpty { null },
|
||||||
artist = attrs.attr("artist").ifEmpty { null },
|
artist = attrs.attr("artist").ifEmpty { null },
|
||||||
|
displayArtist = attrs.attr("displayArtist").ifEmpty { null },
|
||||||
|
sortName = attrs.attr("sortName").ifEmpty { null },
|
||||||
duration = attrs.longAttr("duration"),
|
duration = attrs.longAttr("duration"),
|
||||||
track = attrs.longAttr("track"),
|
track = attrs.longAttr("track"),
|
||||||
disc = attrs.longAttr("discNumber"),
|
disc = attrs.longAttr("discNumber"),
|
||||||
@@ -78,6 +85,36 @@ object SubsonicXml {
|
|||||||
played = attrs.dateAttr("played"),
|
played = attrs.dateAttr("played"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
internal fun songChild(
|
||||||
|
entity: SongDraft,
|
||||||
|
name: String,
|
||||||
|
attrs: Attributes,
|
||||||
|
level: Int,
|
||||||
|
) {
|
||||||
|
when (name) {
|
||||||
|
"genres" -> if (level == 1) entity.addGenre(attrs)
|
||||||
|
"replayGain" -> if (level == 1) entity.addReplayGain(attrs)
|
||||||
|
"artists" -> if (level == 1) entity.addArtists(attrs)
|
||||||
|
"albumArtists" -> entity.clearNestedArtists()
|
||||||
|
"artist" -> if (level == 2) entity.addNestedArtist(attrs)
|
||||||
|
else -> if (level == 1) entity.clearNestedArtists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun albumChild(
|
||||||
|
entity: AlbumDraft,
|
||||||
|
name: String,
|
||||||
|
attrs: Attributes,
|
||||||
|
level: Int,
|
||||||
|
) {
|
||||||
|
when (name) {
|
||||||
|
"discTitles" -> if (level == 1) entity.addDiscTitle(attrs)
|
||||||
|
"artists" -> if (level == 1) entity.addArtists(attrs)
|
||||||
|
"artist" -> if (level == 2) entity.addNestedArtist(attrs)
|
||||||
|
else -> if (level == 1) entity.clearArtists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal fun playlist(
|
internal fun playlist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
attrs: Attributes,
|
attrs: Attributes,
|
||||||
@@ -92,6 +129,8 @@ object SubsonicXml {
|
|||||||
created = attrs.dateAttr("created") ?: 0L,
|
created = attrs.dateAttr("created") ?: 0L,
|
||||||
changed = attrs.dateAttr("changed") ?: 0L,
|
changed = attrs.dateAttr("changed") ?: 0L,
|
||||||
duration = attrs.longAttr("duration") ?: 0L,
|
duration = attrs.longAttr("duration") ?: 0L,
|
||||||
|
owner = attrs.attr("owner").ifEmpty { null },
|
||||||
|
shared = attrs.boolAttr("public"),
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun playlistSong(
|
internal fun playlistSong(
|
||||||
@@ -115,7 +154,7 @@ object SubsonicXml {
|
|||||||
input: InputStream,
|
input: InputStream,
|
||||||
entityTag: String,
|
entityTag: String,
|
||||||
create: (Attributes) -> T,
|
create: (Attributes) -> T,
|
||||||
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
|
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
|
||||||
onEnd: (T) -> Unit,
|
onEnd: (T) -> Unit,
|
||||||
) {
|
) {
|
||||||
parse(input, EntityHandler(entityTag, create, onChild, onEnd, stopAfterFirst = false))
|
parse(input, EntityHandler(entityTag, create, onChild, onEnd, stopAfterFirst = false))
|
||||||
@@ -125,10 +164,16 @@ object SubsonicXml {
|
|||||||
input: InputStream,
|
input: InputStream,
|
||||||
entityTag: String,
|
entityTag: String,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val handler = EntityHandler(entityTag, { Unit }, { _, _, _ -> }, { }, stopAfterFirst = true)
|
val handler = EntityHandler(entityTag, { Unit }, { _, _, _, _ -> }, { }, stopAfterFirst = true)
|
||||||
parse(input, handler)
|
parse(input, handler)
|
||||||
return handler.found
|
return handler.found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun artistBiography(input: InputStream): String? {
|
||||||
|
val handler = ArtistBiographyHandler()
|
||||||
|
parse(input, handler)
|
||||||
|
return handler.biography
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class AlbumDraft(
|
internal class AlbumDraft(
|
||||||
@@ -137,6 +182,8 @@ internal class AlbumDraft(
|
|||||||
var artistId: String? = null,
|
var artistId: String? = null,
|
||||||
var name: String = "",
|
var name: String = "",
|
||||||
var albumArtist: String? = null,
|
var albumArtist: String? = null,
|
||||||
|
var displayArtist: String? = null,
|
||||||
|
var sortName: String? = null,
|
||||||
var created: Long = 0L,
|
var created: Long = 0L,
|
||||||
var coverArt: String? = null,
|
var coverArt: String? = null,
|
||||||
var genre: String? = null,
|
var genre: String? = null,
|
||||||
@@ -145,19 +192,41 @@ internal class AlbumDraft(
|
|||||||
var songCount: Long = 0L,
|
var songCount: Long = 0L,
|
||||||
val discTitles: MutableMap<Long, String> = mutableMapOf(),
|
val discTitles: MutableMap<Long, String> = mutableMapOf(),
|
||||||
) {
|
) {
|
||||||
|
private val artists = mutableListOf<ArtistRef>()
|
||||||
|
private var artistContainer = false
|
||||||
|
|
||||||
fun addDiscTitle(attrs: Attributes) {
|
fun addDiscTitle(attrs: Attributes) {
|
||||||
val disc = attrs.longAttr("disc") ?: return
|
val disc = attrs.longAttr("disc") ?: return
|
||||||
val title = attrs.attr("title").takeIf { it.isNotBlank() } ?: return
|
val title = attrs.attr("title").takeIf { it.isNotBlank() } ?: return
|
||||||
discTitles[disc] = title
|
discTitles[disc] = title
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toAlbum(): Album =
|
fun addArtists(attrs: Attributes) {
|
||||||
Album(
|
val ref = attrs.artistRef()
|
||||||
|
if (ref == null) {
|
||||||
|
artistContainer = true
|
||||||
|
} else {
|
||||||
|
artists += ref
|
||||||
|
artistContainer = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addNestedArtist(attrs: Attributes) {
|
||||||
|
if (artistContainer) attrs.artistRef()?.let { artists += it }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearArtists() {
|
||||||
|
artistContainer = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toAlbum(): Album {
|
||||||
|
val refs = artistFallback(artists, artistId, albumArtist)
|
||||||
|
return Album(
|
||||||
sourceId = sourceId,
|
sourceId = sourceId,
|
||||||
id = id,
|
id = id,
|
||||||
artistId = artistId,
|
artistId = artistId,
|
||||||
name = name,
|
name = name,
|
||||||
albumArtist = albumArtist,
|
albumArtist = displayName(albumArtist, displayArtist, refs),
|
||||||
created = created,
|
created = created,
|
||||||
coverArt = coverArt,
|
coverArt = coverArt,
|
||||||
genre = genre,
|
genre = genre,
|
||||||
@@ -165,7 +234,10 @@ internal class AlbumDraft(
|
|||||||
starred = starred,
|
starred = starred,
|
||||||
songCount = songCount,
|
songCount = songCount,
|
||||||
discTitles = discTitles.toMap(),
|
discTitles = discTitles.toMap(),
|
||||||
|
artists = refs,
|
||||||
|
sortName = sortName?.takeIf { it.isNotBlank() } ?: name,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class SongDraft(
|
internal class SongDraft(
|
||||||
@@ -176,6 +248,8 @@ internal class SongDraft(
|
|||||||
private val title: String = "",
|
private val title: String = "",
|
||||||
private val album: String? = null,
|
private val album: String? = null,
|
||||||
private val artist: String? = null,
|
private val artist: String? = null,
|
||||||
|
private val displayArtist: String? = null,
|
||||||
|
private val sortName: String? = null,
|
||||||
private val duration: Long? = null,
|
private val duration: Long? = null,
|
||||||
private val track: Long? = null,
|
private val track: Long? = null,
|
||||||
private val disc: Long? = null,
|
private val disc: Long? = null,
|
||||||
@@ -184,8 +258,15 @@ internal class SongDraft(
|
|||||||
private val created: Long = 0L,
|
private val created: Long = 0L,
|
||||||
private val playCount: Long = 0L,
|
private val playCount: Long = 0L,
|
||||||
private val played: Long? = null,
|
private val played: Long? = null,
|
||||||
|
private var trackGain: Float? = null,
|
||||||
|
private var albumGain: Float? = null,
|
||||||
|
private var trackPeak: Float? = null,
|
||||||
|
private var albumPeak: Float? = null,
|
||||||
private val genres: MutableList<String> = mutableListOf(),
|
private val genres: MutableList<String> = mutableListOf(),
|
||||||
) {
|
) {
|
||||||
|
private val artists = mutableListOf<ArtistRef>()
|
||||||
|
private var nestedArtists: MutableList<ArtistRef>? = null
|
||||||
|
|
||||||
fun addGenre(attrs: Attributes) {
|
fun addGenre(attrs: Attributes) {
|
||||||
attrs
|
attrs
|
||||||
.attr("name")
|
.attr("name")
|
||||||
@@ -194,15 +275,41 @@ internal class SongDraft(
|
|||||||
?.let { genres += it }
|
?.let { genres += it }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toSong(): Song =
|
fun addReplayGain(attrs: Attributes) {
|
||||||
Song(
|
attrs.floatAttr("trackGain")?.let { trackGain = it }
|
||||||
|
attrs.floatAttr("albumGain")?.let { albumGain = it }
|
||||||
|
attrs.floatAttr("trackPeak")?.let { trackPeak = it }
|
||||||
|
attrs.floatAttr("albumPeak")?.let { albumPeak = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addArtists(attrs: Attributes) {
|
||||||
|
val ref = attrs.artistRef()
|
||||||
|
if (ref == null) {
|
||||||
|
nestedArtists = artists
|
||||||
|
} else {
|
||||||
|
artists += ref
|
||||||
|
nestedArtists = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearNestedArtists() {
|
||||||
|
nestedArtists = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addNestedArtist(attrs: Attributes) {
|
||||||
|
nestedArtists?.let { list -> attrs.artistRef()?.let { list += it } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toSong(): Song {
|
||||||
|
val refs = artistFallback(artists, artistId, artist)
|
||||||
|
return Song(
|
||||||
sourceId = sourceId,
|
sourceId = sourceId,
|
||||||
id = id,
|
id = id,
|
||||||
albumId = albumId,
|
albumId = albumId,
|
||||||
artistId = artistId,
|
artistId = artistId,
|
||||||
title = title,
|
title = title,
|
||||||
album = album,
|
album = album,
|
||||||
artist = artist,
|
artist = displayName(artist, displayArtist, refs),
|
||||||
duration = duration,
|
duration = duration,
|
||||||
track = track,
|
track = track,
|
||||||
disc = disc,
|
disc = disc,
|
||||||
@@ -211,14 +318,59 @@ internal class SongDraft(
|
|||||||
created = created,
|
created = created,
|
||||||
playCount = playCount,
|
playCount = playCount,
|
||||||
played = played,
|
played = played,
|
||||||
|
trackGain = trackGain,
|
||||||
|
albumGain = albumGain,
|
||||||
|
trackPeak = trackPeak,
|
||||||
|
albumPeak = albumPeak,
|
||||||
genres = genres.toList(),
|
genres = genres.toList(),
|
||||||
|
artists = refs,
|
||||||
|
sortName = sortName?.takeIf { it.isNotBlank() } ?: title,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Attributes.artistRef(): ArtistRef? {
|
||||||
|
val id = attr("id")
|
||||||
|
val name = attr("name")
|
||||||
|
if (id.isEmpty() && name.isEmpty()) return null
|
||||||
|
return ArtistRef(id, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun artistFallback(
|
||||||
|
parsed: List<ArtistRef>,
|
||||||
|
artistId: String?,
|
||||||
|
name: String?,
|
||||||
|
): List<ArtistRef> {
|
||||||
|
val refs = parsed.filter { it.id.isNotBlank() }.distinctBy { it.id }
|
||||||
|
if (refs.isNotEmpty()) return refs
|
||||||
|
if (artistId.isNullOrBlank()) return emptyList()
|
||||||
|
return listOf(ArtistRef(artistId, name.orEmpty()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val ARTIST_JOINER = " • "
|
||||||
|
|
||||||
|
private fun displayName(
|
||||||
|
legacy: String?,
|
||||||
|
openSubsonic: String?,
|
||||||
|
refs: List<ArtistRef>,
|
||||||
|
): String? =
|
||||||
|
legacy?.takeIf { it.isNotBlank() }
|
||||||
|
?: openSubsonic?.takeIf { it.isNotBlank() }
|
||||||
|
?: refs
|
||||||
|
.map { it.name }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
.joinToString(ARTIST_JOINER)
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
|
internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
|
||||||
|
|
||||||
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
|
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
|
||||||
|
|
||||||
|
internal fun Attributes.boolAttr(name: String): Boolean = attr(name).let { it == "1" || it.equals("true", ignoreCase = true) }
|
||||||
|
|
||||||
|
internal fun Attributes.floatAttr(name: String): Float? = attr(name).takeIf { it.isNotEmpty() }?.toFloatOrNull()?.takeIf { it.isFinite() }
|
||||||
|
|
||||||
internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
|
internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
|
||||||
|
|
||||||
private const val ROOT_TAG = "subsonic-response"
|
private const val ROOT_TAG = "subsonic-response"
|
||||||
@@ -280,10 +432,44 @@ private open class SubsonicResponseHandler : DefaultHandler() {
|
|||||||
) = Unit
|
) = Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class ArtistBiographyHandler : SubsonicResponseHandler() {
|
||||||
|
private var biographyDepth = -1
|
||||||
|
private val text = StringBuilder()
|
||||||
|
|
||||||
|
var biography: String? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun characters(
|
||||||
|
ch: CharArray,
|
||||||
|
start: Int,
|
||||||
|
length: Int,
|
||||||
|
) {
|
||||||
|
if (biographyDepth >= 0) text.append(ch, start, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onElementStart(
|
||||||
|
name: String,
|
||||||
|
attributes: Attributes,
|
||||||
|
depth: Int,
|
||||||
|
) {
|
||||||
|
if (name == "biography" && biographyDepth < 0) biographyDepth = depth
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onElementEnd(
|
||||||
|
name: String,
|
||||||
|
depth: Int,
|
||||||
|
) {
|
||||||
|
if (name == "biography" && depth == biographyDepth) {
|
||||||
|
biography = text.toString().trim().takeIf { it.isNotEmpty() }
|
||||||
|
biographyDepth = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class EntityHandler<T>(
|
private class EntityHandler<T>(
|
||||||
private val entityTag: String,
|
private val entityTag: String,
|
||||||
private val create: (Attributes) -> T,
|
private val create: (Attributes) -> T,
|
||||||
private val onChild: (T, String, Attributes) -> Unit,
|
private val onChild: (T, String, Attributes, Int) -> Unit,
|
||||||
private val onEnd: (T) -> Unit,
|
private val onEnd: (T) -> Unit,
|
||||||
private val stopAfterFirst: Boolean,
|
private val stopAfterFirst: Boolean,
|
||||||
) : SubsonicResponseHandler() {
|
) : SubsonicResponseHandler() {
|
||||||
@@ -304,8 +490,8 @@ private class EntityHandler<T>(
|
|||||||
current = create(attributes)
|
current = create(attributes)
|
||||||
entityDepth = depth
|
entityDepth = depth
|
||||||
}
|
}
|
||||||
} else if (depth == entityDepth + 1) {
|
} else if (depth > entityDepth) {
|
||||||
onChild(active, name, attributes)
|
onChild(active, name, attributes, depth - entityDepth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
|
import com.subtracks.data.net.NetworkMode
|
||||||
|
import com.subtracks.data.prefs.SyncMode
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
internal const val AUTO_SYNC_MAX_POLL_MS = 15L * 60 * 1000
|
||||||
|
|
||||||
|
internal fun shouldAutoSync(
|
||||||
|
mode: SyncMode,
|
||||||
|
network: NetworkMode,
|
||||||
|
offline: Boolean,
|
||||||
|
hasSource: Boolean,
|
||||||
|
lastSyncAt: Long,
|
||||||
|
now: Long,
|
||||||
|
intervalMs: Long,
|
||||||
|
): Boolean {
|
||||||
|
val allowed =
|
||||||
|
when (mode) {
|
||||||
|
SyncMode.Off -> false
|
||||||
|
SyncMode.WifiOnly -> network == NetworkMode.Wifi
|
||||||
|
SyncMode.Always -> true
|
||||||
|
}
|
||||||
|
return allowed && !offline && hasSource && (lastSyncAt > now || now - lastSyncAt >= intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
class AutoSync(
|
||||||
|
private val preferences: UserPreferences,
|
||||||
|
private val networkMode: Flow<NetworkMode>,
|
||||||
|
private val hasSource: suspend () -> Boolean,
|
||||||
|
private val requestSync: () -> Unit,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val now: () -> Long = System::currentTimeMillis,
|
||||||
|
private val maxPollMs: Long = AUTO_SYNC_MAX_POLL_MS,
|
||||||
|
) {
|
||||||
|
@Volatile
|
||||||
|
private var lastNetwork: NetworkMode? = null
|
||||||
|
private var loop: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch { networkMode.collect { lastNetwork = it } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setForeground(active: Boolean) {
|
||||||
|
if (active) start() else stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun start() {
|
||||||
|
if (loop?.isActive == true) return
|
||||||
|
loop =
|
||||||
|
scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
val intervalMs =
|
||||||
|
try {
|
||||||
|
checkAndRequest()
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (failure: Exception) {
|
||||||
|
Log.w("sync", "auto-sync check failed", failure)
|
||||||
|
maxPollMs
|
||||||
|
}
|
||||||
|
delay(intervalMs.coerceAtMost(maxPollMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stop() {
|
||||||
|
loop?.cancel()
|
||||||
|
loop = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun checkAndRequest(): Long {
|
||||||
|
val mode = preferences.syncMode().first()
|
||||||
|
val network = lastNetwork ?: networkMode.first()
|
||||||
|
val offline = preferences.offlineMode().first()
|
||||||
|
val hasSource = hasSource()
|
||||||
|
val lastSyncAt = preferences.lastSyncAt().first()
|
||||||
|
val intervalMs = preferences.syncIntervalMinutes().first() * 60_000L
|
||||||
|
val time = now()
|
||||||
|
val due =
|
||||||
|
shouldAutoSync(
|
||||||
|
mode = mode,
|
||||||
|
network = network,
|
||||||
|
offline = offline,
|
||||||
|
hasSource = hasSource,
|
||||||
|
lastSyncAt = lastSyncAt,
|
||||||
|
now = time,
|
||||||
|
intervalMs = intervalMs,
|
||||||
|
)
|
||||||
|
Log.d(
|
||||||
|
"sync",
|
||||||
|
"auto-sync due=$due (mode=$mode, network=$network, offline=$offline, hasSource=$hasSource, " +
|
||||||
|
"sinceLast=${time - lastSyncAt}ms, interval=${intervalMs}ms)",
|
||||||
|
)
|
||||||
|
if (due && currentCoroutineContext().isActive) requestSync()
|
||||||
|
return intervalMs
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,31 @@
|
|||||||
package com.subtracks.data.sync
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
import com.subtracks.data.source.subsonic.SubsonicException
|
import com.subtracks.data.source.subsonic.SubsonicException
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
sealed interface SyncStatus {
|
sealed interface SyncStatus {
|
||||||
data object Idle : SyncStatus
|
data object Idle : SyncStatus
|
||||||
|
|
||||||
data object Running : SyncStatus
|
data class Running(
|
||||||
|
val silent: Boolean,
|
||||||
|
) : SyncStatus
|
||||||
|
|
||||||
data object Success : SyncStatus
|
data object Success : SyncStatus
|
||||||
|
|
||||||
@@ -34,41 +39,65 @@ class SyncManager(
|
|||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val queueRepository: QueueRepository,
|
private val queueRepository: QueueRepository,
|
||||||
private val serverActions: ServerActionSink,
|
private val serverActions: ServerActionSink,
|
||||||
|
private val preferences: UserPreferences,
|
||||||
private val showMessage: (UiMessage) -> Unit = {},
|
private val showMessage: (UiMessage) -> Unit = {},
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||||
) {
|
) {
|
||||||
private val requests = Channel<Unit>(Channel.CONFLATED)
|
private val requests = Channel<Boolean>(Channel.CONFLATED)
|
||||||
private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)
|
private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)
|
||||||
val status: StateFlow<SyncStatus> = _status
|
val status: StateFlow<SyncStatus> = _status
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
for (ignored in requests) runSync()
|
for (silent in requests) {
|
||||||
|
try {
|
||||||
|
runSync(silent)
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
if (!currentCoroutineContext().isActive) throw cancellation
|
||||||
|
_status.value = SyncStatus.Idle
|
||||||
|
Log.w("sync", "sync interrupted", cancellation)
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
_status.value = SyncStatus.Idle
|
||||||
|
Log.w("sync", "unexpected failure", failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestSync() {
|
fun requestSync(silent: Boolean = false) {
|
||||||
requests.trySend(Unit)
|
requests.trySend(silent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun runSync() {
|
private suspend fun runSync(silent: Boolean) {
|
||||||
if (sourceRepository.offline.value) {
|
if (sourceRepository.offline.value) {
|
||||||
val offline = UiMessage(R.string.sync_offline)
|
if (!silent) {
|
||||||
showMessage(offline)
|
val offline = UiMessage(R.string.sync_offline)
|
||||||
_status.value = SyncStatus.Failed(offline)
|
showMessage(offline)
|
||||||
|
_status.value = SyncStatus.Failed(offline)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_status.value = SyncStatus.Running
|
_status.value = SyncStatus.Running(silent)
|
||||||
|
runCatching { preferences.setLastSyncAt(System.currentTimeMillis()) }
|
||||||
|
.onFailure { Log.w("sync", "could not record the sync attempt", it) }
|
||||||
|
val startedAt = System.nanoTime()
|
||||||
val result =
|
val result =
|
||||||
try {
|
try {
|
||||||
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
|
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
|
||||||
|
Log.i("sync", "started (source ${source.id})")
|
||||||
serverActions.flush()
|
serverActions.flush()
|
||||||
SyncService(db, source).sync()
|
val summary = SyncService(db, source).sync()
|
||||||
|
Log.i(
|
||||||
|
"sync",
|
||||||
|
"finished in ${elapsedMs(startedAt)}ms: " +
|
||||||
|
"${summary.artists} artists, ${summary.albums} albums, ${summary.songs} songs, " +
|
||||||
|
"${summary.playlists} playlists, ${summary.playlistSongs} playlist tracks, ${summary.pruned} pruned",
|
||||||
|
)
|
||||||
SyncStatus.Success
|
SyncStatus.Success
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (failure: Exception) {
|
} catch (failure: Exception) {
|
||||||
Log.w(TAG, "Sync failed", failure)
|
Log.w("sync", "failed after ${elapsedMs(startedAt)}ms", failure)
|
||||||
val message =
|
val message =
|
||||||
when (failure) {
|
when (failure) {
|
||||||
is NoServerException -> {
|
is NoServerException -> {
|
||||||
@@ -80,16 +109,14 @@ class SyncManager(
|
|||||||
?: UiMessage(R.string.sync_failed_detail, listOf(failure.message ?: ""))
|
?: UiMessage(R.string.sync_failed_detail, listOf(failure.message ?: ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
showMessage(message)
|
if (!silent) showMessage(message)
|
||||||
SyncStatus.Failed(message)
|
SyncStatus.Failed(message)
|
||||||
}
|
}
|
||||||
queueRepository.invalidateLibraryCache()
|
queueRepository.invalidateLibraryCache()
|
||||||
_status.value = result
|
_status.value = result
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private fun elapsedMs(startNanos: Long): Long = (System.nanoTime() - startNanos) / 1_000_000
|
||||||
const val TAG = "SubtracksSync"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private class NoServerException : IllegalStateException()
|
private class NoServerException : IllegalStateException()
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.subtracks.R
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class SyncNotifier(
|
||||||
|
private val context: Context,
|
||||||
|
private val status: Flow<SyncStatus>,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||||
|
) {
|
||||||
|
private val manager = NotificationManagerCompat.from(context)
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
createChannel()
|
||||||
|
scope.launch {
|
||||||
|
status.collect { syncStatus ->
|
||||||
|
if (syncStatus is SyncStatus.Running && manager.areNotificationsEnabled()) {
|
||||||
|
manager.notify(ID, build())
|
||||||
|
} else {
|
||||||
|
manager.cancel(ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun build(): Notification =
|
||||||
|
NotificationCompat
|
||||||
|
.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_name)
|
||||||
|
.setContentTitle(context.getString(R.string.sync_notification_title))
|
||||||
|
.setContentText(context.getString(R.string.sync_notification_text))
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setProgress(0, 0, true)
|
||||||
|
.setContentIntent(launchIntent())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun launchIntent(): PendingIntent? =
|
||||||
|
context.packageManager
|
||||||
|
.getLaunchIntentForPackage(context.packageName)
|
||||||
|
?.let { PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE) }
|
||||||
|
|
||||||
|
private fun createChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
context.getString(R.string.sync_notification_channel),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply { setShowBadge(false) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ID = 2
|
||||||
|
private const val CHANNEL_ID = "sync"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,16 @@ import androidx.room3.useWriterConnection
|
|||||||
import androidx.sqlite.SQLiteStatement
|
import androidx.sqlite.SQLiteStatement
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.AlbumArtistRef
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
|
import com.subtracks.data.model.ArtistRefsCodec
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.DiscKey
|
import com.subtracks.data.model.DiscKey
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.model.SongArtistRef
|
||||||
import com.subtracks.data.model.SongGenre
|
import com.subtracks.data.model.SongGenre
|
||||||
import com.subtracks.data.source.MusicSource
|
import com.subtracks.data.source.MusicSource
|
||||||
import java.util.Arrays
|
import java.util.Arrays
|
||||||
@@ -20,66 +24,120 @@ class SyncService(
|
|||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
private val source: MusicSource,
|
private val source: MusicSource,
|
||||||
) {
|
) {
|
||||||
suspend fun sync() {
|
private var artistsSynced = 0
|
||||||
syncArtists()
|
private var albumsSynced = 0
|
||||||
syncAlbums()
|
private var songsSynced = 0
|
||||||
syncSongs()
|
private var playlistsSynced = 0
|
||||||
aggregatePlayData()
|
private var playlistSongsSynced = 0
|
||||||
|
private var pruned = 0
|
||||||
|
|
||||||
|
suspend fun sync(): SyncSummary {
|
||||||
|
val sources = db.sourcesDao()
|
||||||
|
val wasStale = sources.creditsStale(source.id) ?: true
|
||||||
|
val artistsChanged = syncArtists()
|
||||||
|
val albumsChanged = syncAlbums()
|
||||||
|
val songsChanged = syncSongs()
|
||||||
|
optimize()
|
||||||
|
if (artistsChanged || albumsChanged || songsChanged || wasStale) {
|
||||||
|
recomputeAggregates()
|
||||||
|
sources.setCreditsStale(source.id, false)
|
||||||
|
}
|
||||||
syncPlaylists()
|
syncPlaylists()
|
||||||
syncPlaylistSongs()
|
syncPlaylistSongs()
|
||||||
|
return SyncSummary(
|
||||||
|
artists = artistsSynced,
|
||||||
|
albums = albumsSynced,
|
||||||
|
songs = songsSynced,
|
||||||
|
playlists = playlistsSynced,
|
||||||
|
playlistSongs = playlistSongsSynced,
|
||||||
|
pruned = pruned,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncArtists() {
|
private suspend fun syncArtists(): Boolean {
|
||||||
val library = db.libraryDao()
|
val library = db.libraryDao()
|
||||||
val seen = HashedIds()
|
val seen = HashedIds()
|
||||||
|
var changed = false
|
||||||
source.artists().collect { batch ->
|
source.artists().collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
write("artists", artistColumns, primaryKey, batch) { it.values() }
|
changed =
|
||||||
|
write("artists", primaryKey, batch, preserved = setOf("albumCount"), markStale = true) { it.columns() } ||
|
||||||
|
changed
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
|
artistsSynced += batch.size
|
||||||
}
|
}
|
||||||
pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteArtists(source.id, it) })
|
val prunedBefore = pruned
|
||||||
|
pruned += pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteArtists(source.id, it) })
|
||||||
|
return changed || pruned > prunedBefore
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncAlbums() {
|
private suspend fun syncAlbums(): Boolean {
|
||||||
val library = db.libraryDao()
|
val library = db.libraryDao()
|
||||||
val seenAlbums = HashedIds()
|
val seenAlbums = HashedIds()
|
||||||
val seenDiscs = HashedIds()
|
val seenDiscs = HashedIds()
|
||||||
|
var changed = false
|
||||||
source.albums().collect { batch ->
|
source.albums().collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
val discs =
|
val discs =
|
||||||
batch.flatMap { album ->
|
batch.flatMap { album ->
|
||||||
album.discTitles.map { (disc, title) -> Disc(album.sourceId, album.id, disc, title) }
|
album.discTitles.map { (disc, title) -> Disc(album.sourceId, album.id, disc, title) }
|
||||||
}
|
}
|
||||||
writeAlbumsAndDiscs(batch, discs)
|
changed = writeAlbumsAndDiscs(batch, discs) || changed
|
||||||
batch.forEach { seenAlbums.add(idHash(it.id)) }
|
batch.forEach { seenAlbums.add(idHash(it.id)) }
|
||||||
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
|
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
|
||||||
|
albumsSynced += batch.size
|
||||||
}
|
}
|
||||||
pruneStaleIds(seenAlbums, { library.albumIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteAlbums(source.id, it) })
|
val prunedBefore = pruned
|
||||||
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
pruned +=
|
||||||
deleteStaleDiscs(it)
|
pruneStaleIds(
|
||||||
}
|
seenAlbums,
|
||||||
|
{ library.albumIdsAfter(source.id, it, PRUNE_PAGE) },
|
||||||
|
{ library.deleteAlbums(source.id, it) },
|
||||||
|
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
|
||||||
|
)
|
||||||
|
pruned +=
|
||||||
|
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
||||||
|
deleteStaleDiscs(it)
|
||||||
|
}
|
||||||
|
return changed || pruned > prunedBefore
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncSongs() {
|
private suspend fun syncSongs(): Boolean {
|
||||||
val library = db.libraryDao()
|
val library = db.libraryDao()
|
||||||
val seen = HashedIds()
|
val seen = HashedIds()
|
||||||
|
var changed = false
|
||||||
source.songs().collect { batch ->
|
source.songs().collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
writeSongs(batch)
|
changed = writeSongs(batch) || changed
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
|
songsSynced += batch.size
|
||||||
}
|
}
|
||||||
pruneStaleIds(seen, { library.songIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteSongs(source.id, it) })
|
val prunedBefore = pruned
|
||||||
|
pruned +=
|
||||||
|
pruneStaleIds(
|
||||||
|
seen,
|
||||||
|
{ library.songIdsAfter(source.id, it, PRUNE_PAGE) },
|
||||||
|
{ library.deleteSongs(source.id, it) },
|
||||||
|
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
|
||||||
|
)
|
||||||
|
return changed || pruned > prunedBefore
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun writeSongs(batch: List<Song>) {
|
private suspend fun writeSongs(batch: List<Song>): Boolean {
|
||||||
val names = batch.associate { it.id to it.genreNames() }
|
val names = batch.associate { it.id to it.genreNames() }
|
||||||
val genres =
|
val genres =
|
||||||
batch.flatMap { song ->
|
batch.flatMap { song ->
|
||||||
names.getValue(song.id).mapIndexed { index, genre -> SongGenre(song.sourceId, song.id, index.toLong(), genre) }
|
names.getValue(song.id).mapIndexed { index, genre -> SongGenre(song.sourceId, song.id, index.toLong(), genre) }
|
||||||
}
|
}
|
||||||
db.useWriterConnection { connection ->
|
val artists =
|
||||||
|
batch.flatMap { song ->
|
||||||
|
song.artistPositions().map { (position, ref) -> SongArtistRef(song.sourceId, song.id, position, ref.id, ref.name) }
|
||||||
|
}
|
||||||
|
return db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
connection.upsertChanged("songs", songColumns, primaryKey, batch) { it.values() }
|
val before = connection.totalChanges()
|
||||||
|
val resolved = connection.resolveSongSortNames(batch)
|
||||||
|
connection.upsertChanged("songs", primaryKey, resolved) { it.columns() }
|
||||||
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||||
batch.forEach { song ->
|
batch.forEach { song ->
|
||||||
statement.bind(1, song.sourceId)
|
statement.bind(1, song.sourceId)
|
||||||
@@ -90,13 +148,107 @@ class SyncService(
|
|||||||
statement.clearBindings()
|
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() {
|
private suspend fun recomputeAggregates() {
|
||||||
db.libraryDao().recomputePlayData(source.id)
|
db.libraryDao().recomputeAggregates(source.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.totalChanges(): Long {
|
||||||
|
var value = 0L
|
||||||
|
usePrepared("SELECT total_changes()") { statement ->
|
||||||
|
statement.step()
|
||||||
|
value = statement.getLong(0)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.markCreditsStale(sourceId: Long) {
|
||||||
|
usePrepared("UPDATE sources SET creditsStale = 1 WHERE id = ? AND creditsStale = 0") { statement ->
|
||||||
|
statement.bindLong(1, sourceId)
|
||||||
|
statement.step()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.resolveSongSortNames(batch: List<Song>): List<Song> {
|
||||||
|
if (batch.isEmpty()) return batch
|
||||||
|
val sortNames =
|
||||||
|
artistSortNames(
|
||||||
|
batch.first().sourceId,
|
||||||
|
batch
|
||||||
|
.flatMap { listOfNotNull(it.artistId) + it.artistRefs.map { ref -> ref.id } }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.toSet(),
|
||||||
|
)
|
||||||
|
return batch.map { song ->
|
||||||
|
val primary = song.artistId ?: song.artists.firstOrNull()?.id
|
||||||
|
val sortName =
|
||||||
|
primary?.let(sortNames::get)?.takeIf { it.isNotBlank() }
|
||||||
|
?: song.artists.firstNotNullOfOrNull { ref -> sortNames[ref.id]?.takeIf { it.isNotBlank() } }
|
||||||
|
?: song.artistSort
|
||||||
|
if (sortName == song.artistSort) song else song.copy(artistSort = sortName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.resolveAlbumSortNames(batch: List<Album>): List<Album> {
|
||||||
|
if (batch.isEmpty()) return batch
|
||||||
|
val sortNames =
|
||||||
|
artistSortNames(
|
||||||
|
batch.first().sourceId,
|
||||||
|
batch
|
||||||
|
.flatMap { listOfNotNull(it.artistId) + it.artistRefs.map { ref -> ref.id } }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.toSet(),
|
||||||
|
)
|
||||||
|
return batch.map { album ->
|
||||||
|
val primary = album.artistId ?: album.artists.firstOrNull()?.id
|
||||||
|
val sortName =
|
||||||
|
primary?.let(sortNames::get)?.takeIf { it.isNotBlank() }
|
||||||
|
?: album.artists.firstNotNullOfOrNull { ref -> sortNames[ref.id]?.takeIf { it.isNotBlank() } }
|
||||||
|
?: album.albumArtistSort
|
||||||
|
if (sortName == album.albumArtistSort) album else album.copy(albumArtistSort = sortName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.artistSortNames(
|
||||||
|
sourceId: Long,
|
||||||
|
ids: Collection<String>,
|
||||||
|
): Map<String, String> {
|
||||||
|
if (ids.isEmpty()) return emptyMap()
|
||||||
|
val sortNames = HashMap<String, String>(ids.size)
|
||||||
|
ids.toList().chunked(SQL_VARIABLES).forEach { chunk ->
|
||||||
|
val placeholders = chunk.joinToString(",") { "?" }
|
||||||
|
usePrepared("SELECT id, sortName FROM artists WHERE sourceId = ? AND id IN ($placeholders)") { statement ->
|
||||||
|
statement.bindLong(1, sourceId)
|
||||||
|
chunk.forEachIndexed { index, id -> statement.bindText(index + 2, id) }
|
||||||
|
while (statement.step()) sortNames[statement.getText(0)] = statement.getText(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sortNames
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without sqlite_stat1 the artist and relation queries can fall back to whole-table scans.
|
||||||
|
private suspend fun optimize() {
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.usePrepared("PRAGMA optimize") { it.step() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncPlaylists() {
|
private suspend fun syncPlaylists() {
|
||||||
@@ -104,10 +256,11 @@ class SyncService(
|
|||||||
val seen = HashedIds()
|
val seen = HashedIds()
|
||||||
source.playlists().collect { batch ->
|
source.playlists().collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
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)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
|
playlistsSynced += batch.size
|
||||||
}
|
}
|
||||||
pruneStaleIds(seen, { library.playlistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deletePlaylists(source.id, it) })
|
pruned += pruneStaleIds(seen, { library.playlistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deletePlaylists(source.id, it) })
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncPlaylistSongs() {
|
private suspend fun syncPlaylistSongs() {
|
||||||
@@ -116,8 +269,9 @@ class SyncService(
|
|||||||
val counts = HashMap<String, Long>()
|
val counts = HashMap<String, Long>()
|
||||||
source.playlistSongs(playlistIds).collect { batch ->
|
source.playlistSongs(playlistIds).collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
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 }
|
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
|
||||||
|
playlistSongsSynced += batch.size
|
||||||
}
|
}
|
||||||
playlistIds.forEach { library.deletePlaylistSongsFrom(source.id, it, counts[it] ?: 0L) }
|
playlistIds.forEach { library.deletePlaylistSongsFrom(source.id, it, counts[it] ?: 0L) }
|
||||||
library.deleteOrphanPlaylistSongs(source.id)
|
library.deleteOrphanPlaylistSongs(source.id)
|
||||||
@@ -126,26 +280,50 @@ class SyncService(
|
|||||||
// One commit per batch, so Room notifies paged observers once per batch rather than per row.
|
// One commit per batch, so Room notifies paged observers once per batch rather than per row.
|
||||||
private suspend fun <T> write(
|
private suspend fun <T> write(
|
||||||
table: String,
|
table: String,
|
||||||
columns: List<String>,
|
|
||||||
key: List<String>,
|
key: List<String>,
|
||||||
rows: List<T>,
|
rows: List<T>,
|
||||||
values: (T) -> List<Any?>,
|
preserved: Set<String> = emptySet(),
|
||||||
) {
|
markStale: Boolean = false,
|
||||||
|
columns: (T) -> List<Pair<String, Any?>>,
|
||||||
|
): Boolean =
|
||||||
db.useWriterConnection { connection ->
|
db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
connection.upsertChanged(table, columns, key, rows, values)
|
val before = connection.totalChanges()
|
||||||
|
connection.upsertChanged(table, key, rows, preserved, columns)
|
||||||
|
(connection.totalChanges() != before).also { changed ->
|
||||||
|
if (changed && markStale) connection.markCreditsStale(source.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun writeAlbumsAndDiscs(
|
private suspend fun writeAlbumsAndDiscs(
|
||||||
albums: List<Album>,
|
albums: List<Album>,
|
||||||
discs: List<Disc>,
|
discs: List<Disc>,
|
||||||
) {
|
): Boolean {
|
||||||
db.useWriterConnection { connection ->
|
val artists =
|
||||||
|
albums.flatMap { album ->
|
||||||
|
album.artistPositions().map { (position, ref) -> AlbumArtistRef(album.sourceId, album.id, position, ref.id, ref.name) }
|
||||||
|
}
|
||||||
|
return db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
connection.upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() }
|
val before = connection.totalChanges()
|
||||||
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
|
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,
|
seen: HashedIds,
|
||||||
pageOf: suspend (afterId: String) -> List<String>,
|
pageOf: suspend (afterId: String) -> List<String>,
|
||||||
delete: suspend (Collection<String>) -> Unit,
|
delete: suspend (Collection<String>) -> Unit,
|
||||||
) {
|
markStale: (suspend () -> Unit)? = null,
|
||||||
|
): Int {
|
||||||
seen.freeze()
|
seen.freeze()
|
||||||
var afterId = ""
|
var afterId = ""
|
||||||
|
var pruned = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
val ids = pageOf(afterId)
|
val ids = pageOf(afterId)
|
||||||
if (ids.isEmpty()) return
|
if (ids.isEmpty()) return pruned
|
||||||
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
||||||
|
if (stale.isNotEmpty()) markStale?.invoke()
|
||||||
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
||||||
if (ids.size < PRUNE_PAGE) return
|
pruned += stale.size
|
||||||
|
if (ids.size < PRUNE_PAGE) return pruned
|
||||||
afterId = ids.last()
|
afterId = ids.last()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,23 +372,35 @@ class SyncService(
|
|||||||
seen: HashedIds,
|
seen: HashedIds,
|
||||||
pageOf: suspend (afterAlbumId: String, afterDisc: Long) -> List<DiscKey>,
|
pageOf: suspend (afterAlbumId: String, afterDisc: Long) -> List<DiscKey>,
|
||||||
delete: suspend (Collection<DiscKey>) -> Unit,
|
delete: suspend (Collection<DiscKey>) -> Unit,
|
||||||
) {
|
): Int {
|
||||||
seen.freeze()
|
seen.freeze()
|
||||||
var afterAlbumId = ""
|
var afterAlbumId = ""
|
||||||
var afterDisc = 0L
|
var afterDisc = 0L
|
||||||
|
var pruned = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
val keys = pageOf(afterAlbumId, afterDisc)
|
val keys = pageOf(afterAlbumId, afterDisc)
|
||||||
if (keys.isEmpty()) return
|
if (keys.isEmpty()) return pruned
|
||||||
val stale = keys.filterNot { seen.contains(discHash(it.albumId, it.disc)) }
|
val stale = keys.filterNot { seen.contains(discHash(it.albumId, it.disc)) }
|
||||||
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
||||||
if (keys.size < PRUNE_PAGE) return
|
pruned += stale.size
|
||||||
|
if (keys.size < PRUNE_PAGE) return pruned
|
||||||
afterAlbumId = keys.last().albumId
|
afterAlbumId = keys.last().albumId
|
||||||
afterDisc = keys.last().disc
|
afterDisc = keys.last().disc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class SyncSummary(
|
||||||
|
val artists: Int,
|
||||||
|
val albums: Int,
|
||||||
|
val songs: Int,
|
||||||
|
val playlists: Int,
|
||||||
|
val playlistSongs: Int,
|
||||||
|
val pruned: Int,
|
||||||
|
)
|
||||||
|
|
||||||
private const val DELETE_CHUNK = 500
|
private const val DELETE_CHUNK = 500
|
||||||
|
private const val SQL_VARIABLES = 500
|
||||||
private const val PRUNE_PAGE = 500
|
private const val PRUNE_PAGE = 500
|
||||||
private const val INITIAL_HASH_CAPACITY = 256
|
private const val INITIAL_HASH_CAPACITY = 256
|
||||||
private const val FNV_OFFSET = -3750763034362895579L
|
private const val FNV_OFFSET = -3750763034362895579L
|
||||||
@@ -243,47 +437,8 @@ private fun discHash(
|
|||||||
|
|
||||||
private val primaryKey = listOf("sourceId", "id")
|
private val primaryKey = listOf("sourceId", "id")
|
||||||
|
|
||||||
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt")
|
|
||||||
|
|
||||||
private val albumColumns =
|
|
||||||
listOf(
|
|
||||||
"sourceId",
|
|
||||||
"id",
|
|
||||||
"artistId",
|
|
||||||
"name",
|
|
||||||
"albumArtist",
|
|
||||||
"created",
|
|
||||||
"coverArt",
|
|
||||||
"genre",
|
|
||||||
"year",
|
|
||||||
"starred",
|
|
||||||
"songCount",
|
|
||||||
)
|
|
||||||
|
|
||||||
private val discKey = listOf("sourceId", "albumId", "disc")
|
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> =
|
private fun Song.genreNames(): List<String> =
|
||||||
genres
|
genres
|
||||||
.flatMap { it.genreParts() }
|
.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.
|
// 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 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 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 val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
||||||
|
|
||||||
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt)
|
private fun Artist.columns(): List<Pair<String, Any?>> =
|
||||||
|
listOf(
|
||||||
private fun Album.values() = listOf<Any?>(sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount)
|
"sourceId" to sourceId,
|
||||||
|
"id" to id,
|
||||||
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
|
"name" to name,
|
||||||
|
"albumCount" to albumCount,
|
||||||
private fun Song.values() =
|
"starred" to starred,
|
||||||
listOf<Any?>(
|
"coverArt" to coverArt,
|
||||||
sourceId,
|
"artistImageUrl" to artistImageUrl,
|
||||||
id,
|
"sortName" to sortName,
|
||||||
albumId,
|
|
||||||
artistId,
|
|
||||||
title,
|
|
||||||
album,
|
|
||||||
artist,
|
|
||||||
duration,
|
|
||||||
track,
|
|
||||||
disc,
|
|
||||||
starred,
|
|
||||||
created,
|
|
||||||
playCount,
|
|
||||||
played,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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(
|
private suspend fun <T> PooledConnection.upsertChanged(
|
||||||
table: String,
|
table: String,
|
||||||
columns: List<String>,
|
|
||||||
key: List<String>,
|
key: List<String>,
|
||||||
rows: List<T>,
|
rows: List<T>,
|
||||||
values: (T) -> List<Any?>,
|
preserved: Set<String> = emptySet(),
|
||||||
|
columns: (T) -> List<Pair<String, Any?>>,
|
||||||
) {
|
) {
|
||||||
if (rows.isEmpty()) return
|
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 =
|
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 " +
|
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
|
||||||
updates.joinToString { "$it = excluded.$it" } +
|
updates.joinToString { "$it = excluded.$it" } +
|
||||||
" WHERE " +
|
" WHERE " +
|
||||||
updates.joinToString(" OR ") { "$table.$it COLLATE BINARY IS NOT excluded.$it" }
|
updates.joinToString(" OR ") { "$table.$it COLLATE BINARY IS NOT excluded.$it" }
|
||||||
usePrepared(sql) { statement ->
|
usePrepared(sql) { statement ->
|
||||||
rows.forEach { row ->
|
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.step()
|
||||||
statement.reset()
|
statement.reset()
|
||||||
statement.clearBindings()
|
statement.clearBindings()
|
||||||
@@ -367,6 +614,7 @@ private fun SQLiteStatement.bind(
|
|||||||
is Int -> bindLong(index, value.toLong())
|
is Int -> bindLong(index, value.toLong())
|
||||||
is Boolean -> bindLong(index, if (value) 1L else 0L)
|
is Boolean -> bindLong(index, if (value) 1L else 0L)
|
||||||
is Double -> bindDouble(index, value)
|
is Double -> bindDouble(index, value)
|
||||||
|
is Float -> bindDouble(index, value.toDouble())
|
||||||
is String -> bindText(index, value)
|
is String -> bindText(index, value)
|
||||||
else -> error("Cannot bind ${value::class}")
|
else -> error("Cannot bind ${value::class}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ import com.subtracks.data.repo.PendingServerActionSink
|
|||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
|
import com.subtracks.data.sync.AutoSync
|
||||||
import com.subtracks.data.sync.SyncManager
|
import com.subtracks.data.sync.SyncManager
|
||||||
|
import com.subtracks.data.sync.SyncNotifier
|
||||||
|
import com.subtracks.log.Log
|
||||||
import com.subtracks.playback.MediaSessionConnection
|
import com.subtracks.playback.MediaSessionConnection
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import com.subtracks.playback.PlayerConnection
|
import com.subtracks.playback.PlayerConnection
|
||||||
@@ -48,6 +51,8 @@ import com.subtracks.ui.settings.SettingsViewModel
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koin.core.module.dsl.viewModel
|
import org.koin.core.module.dsl.viewModel
|
||||||
import org.koin.core.qualifier.named
|
import org.koin.core.qualifier.named
|
||||||
@@ -68,6 +73,7 @@ fun appModule(
|
|||||||
single { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
|
single { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
|
||||||
single(named("io")) { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
single(named("io")) { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||||
single(named("playback")) { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) }
|
single(named("playback")) { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) }
|
||||||
|
single(createdAtStart = true) { get<UserPreferences>().verboseLogging().onEach(Log::setVerbose).launchIn(get()) }
|
||||||
single {
|
single {
|
||||||
SourceRepository(
|
SourceRepository(
|
||||||
get(),
|
get(),
|
||||||
@@ -90,9 +96,18 @@ fun appModule(
|
|||||||
scope = get(),
|
scope = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) }
|
single { LibraryRepository(get(), get(), get(), get(), toast, scope = get(named("io"))) }
|
||||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||||
single { SyncManager(get(), get(), get(), get(), showMessage = toast, scope = get()) }
|
single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) }
|
||||||
|
single {
|
||||||
|
AutoSync(
|
||||||
|
preferences = get(),
|
||||||
|
networkMode = networkMode(context.applicationContext),
|
||||||
|
hasSource = { get<SourceRepository>().activeSourceIdOnce() != null },
|
||||||
|
requestSync = { get<SyncManager>().requestSync(silent = true) },
|
||||||
|
scope = get(),
|
||||||
|
)
|
||||||
|
}
|
||||||
single { QueueRepository(get()) }
|
single { QueueRepository(get()) }
|
||||||
single { ArtworkStore(downloadsRoot(context)) }
|
single { ArtworkStore(downloadsRoot(context)) }
|
||||||
single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) }
|
single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) }
|
||||||
@@ -111,6 +126,7 @@ fun appModule(
|
|||||||
).also { it.start() }
|
).also { it.start() }
|
||||||
}
|
}
|
||||||
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } }
|
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } }
|
||||||
|
single(createdAtStart = true) { SyncNotifier(context.applicationContext, get<SyncManager>().status, scope = get()).also { it.start() } }
|
||||||
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
|
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
|
||||||
single { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
|
single { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
|
||||||
single(createdAtStart = true) {
|
single(createdAtStart = true) {
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package com.subtracks.log
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.subtracks.BuildConfig
|
||||||
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.db.SqlExecution
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
import android.util.Log as AndroidLog
|
||||||
|
|
||||||
|
object Log {
|
||||||
|
private const val SLOW_QUERY_MS = 100.0
|
||||||
|
private const val MAX_SQL_CHARS = 500
|
||||||
|
private val WHITESPACE = Regex("\\s+")
|
||||||
|
|
||||||
|
private var fileStore: LogFileStore? = null
|
||||||
|
private var appContext: Context? = null
|
||||||
|
private var consoleLevel = LogLevel.DEBUG
|
||||||
|
private var defaultFileLevel = LogLevel.INFO
|
||||||
|
private var crashHandlerInstalled = false
|
||||||
|
|
||||||
|
fun init(context: Context) {
|
||||||
|
val app = context.applicationContext
|
||||||
|
appContext = app
|
||||||
|
consoleLevel = if (BuildConfig.DEBUG) LogLevel.VERBOSE else LogLevel.INFO
|
||||||
|
defaultFileLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.INFO
|
||||||
|
fileStore?.shutdown()
|
||||||
|
fileStore = LogFileStore(File(app.filesDir, "logs"), defaultFileLevel)
|
||||||
|
installCrashHandler()
|
||||||
|
i("app", "started ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) on Android ${Build.VERSION.SDK_INT} ${Build.MODEL}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setVerbose(enabled: Boolean) {
|
||||||
|
fileStore?.setMinLevel(if (enabled) LogLevel.VERBOSE else defaultFileLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun v(
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
) = log(LogLevel.VERBOSE, tag, message, null)
|
||||||
|
|
||||||
|
fun d(
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
) = log(LogLevel.DEBUG, tag, message, null)
|
||||||
|
|
||||||
|
fun i(
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
) = log(LogLevel.INFO, tag, message, null)
|
||||||
|
|
||||||
|
fun w(
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable? = null,
|
||||||
|
) = log(LogLevel.WARN, tag, message, throwable)
|
||||||
|
|
||||||
|
fun e(
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable? = null,
|
||||||
|
) = log(LogLevel.ERROR, tag, message, throwable)
|
||||||
|
|
||||||
|
fun exportZip(): File? {
|
||||||
|
val store = fileStore ?: return null
|
||||||
|
val context = appContext ?: return null
|
||||||
|
store.flush()
|
||||||
|
return store.exportZip(File(context.cacheDir, "log_dumps"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun share(
|
||||||
|
context: Context,
|
||||||
|
file: File,
|
||||||
|
) {
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
val send =
|
||||||
|
Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "application/zip"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.settings_about_share_logs_subject))
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
context.startActivity(
|
||||||
|
Intent.createChooser(send, context.getString(R.string.settings_about_actions_share_logs)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interceptor(): Interceptor =
|
||||||
|
Interceptor { chain ->
|
||||||
|
val request = chain.request()
|
||||||
|
v("http", "${request.method} ${request.url}")
|
||||||
|
try {
|
||||||
|
val response = chain.proceed(request)
|
||||||
|
if (response.code >= 400) {
|
||||||
|
w("http", "${request.method} ${request.url} -> ${response.code}")
|
||||||
|
}
|
||||||
|
response
|
||||||
|
} catch (failure: IOException) {
|
||||||
|
if (chain.call().isCanceled()) {
|
||||||
|
v("http", "${request.method} ${request.url} cancelled")
|
||||||
|
} else {
|
||||||
|
w("http", "${request.method} ${request.url} failed", failure)
|
||||||
|
}
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sql(execution: SqlExecution) {
|
||||||
|
val failure = execution.error
|
||||||
|
if (failure != null) {
|
||||||
|
if (isUpsertConflict(execution.sql, failure)) {
|
||||||
|
v("sql", "upsert fallback conflict: ${shortSql(execution.sql)}")
|
||||||
|
} else {
|
||||||
|
e("sql", "failed: ${shortSql(execution.sql)}", failure)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isSlowQuery(execution, SLOW_QUERY_MS)) return
|
||||||
|
val rows = if (isReadStatement(execution.sql)) " rows=${execution.rows}" else ""
|
||||||
|
w("sql", "slow ${execution.durationMillis.roundToLong()}ms$rows: ${shortSql(execution.sql)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun log(
|
||||||
|
level: LogLevel,
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable?,
|
||||||
|
) {
|
||||||
|
val toConsole = level.ordinal >= consoleLevel.ordinal
|
||||||
|
val store = fileStore
|
||||||
|
if (!toConsole && store?.willLog(level) != true) return
|
||||||
|
val redacted = redactLogs(message)
|
||||||
|
if (toConsole) {
|
||||||
|
// Logging is best effort: a logcat write must never disturb the caller.
|
||||||
|
runCatching {
|
||||||
|
val text =
|
||||||
|
if (throwable != null) {
|
||||||
|
"$redacted\n${redactLogs(AndroidLog.getStackTraceString(throwable))}"
|
||||||
|
} else {
|
||||||
|
redacted
|
||||||
|
}
|
||||||
|
AndroidLog.println(priority(level), tag.take(23), text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store?.write(level, tag, redacted, throwable)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun priority(level: LogLevel): Int =
|
||||||
|
when (level) {
|
||||||
|
LogLevel.VERBOSE -> AndroidLog.VERBOSE
|
||||||
|
LogLevel.DEBUG -> AndroidLog.DEBUG
|
||||||
|
LogLevel.INFO -> AndroidLog.INFO
|
||||||
|
LogLevel.WARN -> AndroidLog.WARN
|
||||||
|
LogLevel.ERROR -> AndroidLog.ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installCrashHandler() {
|
||||||
|
if (crashHandlerInstalled) return
|
||||||
|
crashHandlerInstalled = true
|
||||||
|
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
|
runCatching {
|
||||||
|
fileStore?.writeImmediately(LogLevel.ERROR, "crash", "uncaught exception on ${thread.name}", throwable)
|
||||||
|
}
|
||||||
|
previous?.uncaughtException(thread, throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shortSql(sql: String): String = sql.replace(WHITESPACE, " ").take(MAX_SQL_CHARS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DATA_KEYWORDS = setOf("SELECT", "INSERT", "UPDATE", "DELETE", "WITH", "REPLACE")
|
||||||
|
private val READ_KEYWORDS = setOf("SELECT", "WITH")
|
||||||
|
private const val SQL_INTERNAL = "room_table_modification_log"
|
||||||
|
|
||||||
|
internal fun isDataStatement(sql: String): Boolean {
|
||||||
|
val normalized = sql.trimStart()
|
||||||
|
if (normalized.contains(SQL_INTERNAL)) return false
|
||||||
|
return normalized.takeWhile { !it.isWhitespace() }.uppercase() in DATA_KEYWORDS
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isReadStatement(sql: String): Boolean = sql.trimStart().takeWhile { !it.isWhitespace() }.uppercase() in READ_KEYWORDS
|
||||||
|
|
||||||
|
internal fun isSlowQuery(
|
||||||
|
execution: SqlExecution,
|
||||||
|
thresholdMs: Double,
|
||||||
|
): Boolean = execution.error == null && execution.durationMillis >= thresholdMs && isDataStatement(execution.sql)
|
||||||
|
|
||||||
|
// Room's EntityUpsertAdapter implements @Upsert as an INSERT and treats a uniqueness conflict as the
|
||||||
|
// signal to UPDATE instead (EntityUpsertAdapter.checkUniquenessException). Only downgrade a failed
|
||||||
|
// INSERT whose error says the conflict was a unique/primary key; anything else stays an ERROR so a
|
||||||
|
// genuine failure isn't hidden in release.
|
||||||
|
private const val CONSTRAINT_UNIQUE_MESSAGE = "unique constraint failed"
|
||||||
|
private const val CONSTRAINT_PRIMARY_KEY_MESSAGE = "primary key constraint failed"
|
||||||
|
|
||||||
|
internal fun isUpsertConflict(
|
||||||
|
sql: String,
|
||||||
|
error: Throwable,
|
||||||
|
): Boolean {
|
||||||
|
if (!sql.trimStart().uppercase().startsWith("INSERT")) return false
|
||||||
|
val message = error.message?.lowercase() ?: return false
|
||||||
|
return message.contains(CONSTRAINT_UNIQUE_MESSAGE) || message.contains(CONSTRAINT_PRIMARY_KEY_MESSAGE)
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package com.subtracks.log
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
enum class LogLevel { VERBOSE, DEBUG, INFO, WARN, ERROR }
|
||||||
|
|
||||||
|
class LogFileStore(
|
||||||
|
private val rootDir: File,
|
||||||
|
@Volatile private var minLevel: LogLevel,
|
||||||
|
private val maxFileBytes: Long = DEFAULT_MAX_FILE_BYTES,
|
||||||
|
private val maxFiles: Int = DEFAULT_MAX_FILES,
|
||||||
|
) {
|
||||||
|
private val executor =
|
||||||
|
Executors.newSingleThreadExecutor { runnable ->
|
||||||
|
Thread(runnable, "log-writer").apply { isDaemon = true }
|
||||||
|
}
|
||||||
|
private val lock = Any()
|
||||||
|
private val pending = AtomicInteger()
|
||||||
|
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||||
|
private val lineFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
|
||||||
|
private var current = currentFile()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Log.init runs in Application.onCreate, so clean up leftovers on the writer thread.
|
||||||
|
runCatching { executor.execute { synchronized(lock) { prune() } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun willLog(level: LogLevel): Boolean = level.ordinal >= minLevel.ordinal
|
||||||
|
|
||||||
|
fun write(
|
||||||
|
level: LogLevel,
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable?,
|
||||||
|
) {
|
||||||
|
if (level.ordinal < minLevel.ordinal) return
|
||||||
|
// Drop under pressure rather than grow the queue without bound (verbose logs every request).
|
||||||
|
if (pending.get() >= MAX_PENDING) return
|
||||||
|
pending.incrementAndGet()
|
||||||
|
runCatching {
|
||||||
|
executor.execute {
|
||||||
|
try {
|
||||||
|
synchronized(lock) { append(level, tag, message, throwable) }
|
||||||
|
} finally {
|
||||||
|
pending.decrementAndGet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { pending.decrementAndGet() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeImmediately(
|
||||||
|
level: LogLevel,
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable?,
|
||||||
|
) {
|
||||||
|
if (level.ordinal < minLevel.ordinal) return
|
||||||
|
runCatching { synchronized(lock) { append(level, tag, message, throwable) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flush() {
|
||||||
|
runCatching { executor.submit {}.get() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMinLevel(level: LogLevel) {
|
||||||
|
minLevel = level
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shutdown() {
|
||||||
|
executor.shutdownNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun files(): List<File> =
|
||||||
|
rootDir
|
||||||
|
.listFiles { file -> file.isFile && file.name.endsWith(LOG_SUFFIX) }
|
||||||
|
?.sortedByDescending { it.lastModified() }
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
fun exportZip(destDir: File): File? =
|
||||||
|
synchronized(lock) {
|
||||||
|
val logs = files()
|
||||||
|
if (logs.isEmpty()) return null
|
||||||
|
destDir.mkdirs()
|
||||||
|
destDir.listFiles()?.forEach { if (it.isFile) it.delete() }
|
||||||
|
val zip = File(destDir, "subtracks-logs-${System.currentTimeMillis()}.zip")
|
||||||
|
ZipOutputStream(zip.outputStream().buffered()).use { out ->
|
||||||
|
logs.forEach { log ->
|
||||||
|
if (!log.isFile) return@forEach
|
||||||
|
out.putNextEntry(ZipEntry(log.name))
|
||||||
|
log.inputStream().use { it.copyTo(out) }
|
||||||
|
out.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zip
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun append(
|
||||||
|
level: LogLevel,
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable?,
|
||||||
|
) {
|
||||||
|
val text =
|
||||||
|
buildString {
|
||||||
|
append(lineFormat.format(Date()))
|
||||||
|
append(' ')
|
||||||
|
append(LEVEL_CHARS[level.ordinal])
|
||||||
|
append(' ')
|
||||||
|
append(tag)
|
||||||
|
append(' ')
|
||||||
|
append(message)
|
||||||
|
throwable?.let {
|
||||||
|
append('\n')
|
||||||
|
append(it.stackTraceToString().trimEnd())
|
||||||
|
}
|
||||||
|
append('\n')
|
||||||
|
}
|
||||||
|
rootDir.mkdirs()
|
||||||
|
var rolled = false
|
||||||
|
val today = currentFile()
|
||||||
|
if (current.name != today.name) {
|
||||||
|
current = today
|
||||||
|
rolled = true
|
||||||
|
}
|
||||||
|
if (current.length() >= maxFileBytes) {
|
||||||
|
roll()
|
||||||
|
rolled = true
|
||||||
|
}
|
||||||
|
current.appendText(redactLogs(text))
|
||||||
|
if (rolled) prune()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rollSequence = 0
|
||||||
|
|
||||||
|
private fun roll() {
|
||||||
|
if (current.exists()) {
|
||||||
|
current.renameTo(uniqueRolledFile())
|
||||||
|
}
|
||||||
|
current = currentFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun uniqueRolledFile(): File {
|
||||||
|
val day = dateFormat.format(Date())
|
||||||
|
while (true) {
|
||||||
|
val candidate = File(rootDir, "$day-${rollSequence++}$LOG_SUFFIX")
|
||||||
|
if (!candidate.exists()) return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prune() {
|
||||||
|
// Never prune the file we are writing to, and never keep fewer than one rolled file slot.
|
||||||
|
val rolled = files().filterNot { it == current }
|
||||||
|
if (rolled.size > maxFiles - 1) rolled.drop(maxFiles - 1).forEach { it.delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentFile(): File = File(rootDir, dateFormat.format(Date()) + LOG_SUFFIX)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_MAX_FILE_BYTES = 1L * 1024 * 1024
|
||||||
|
const val DEFAULT_MAX_FILES = 7
|
||||||
|
private const val MAX_PENDING = 4096
|
||||||
|
private const val LOG_SUFFIX = ".log"
|
||||||
|
private val LEVEL_CHARS = charArrayOf('V', 'D', 'I', 'W', 'E')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val URL_USERINFO = Regex("([a-zA-Z][a-zA-Z0-9+.\\-]*://)[^/@\\s]+@")
|
||||||
|
private val URL_PARAM = Regex("([?&](?:u|p|s|t)=)[^&\\s]+")
|
||||||
|
|
||||||
|
internal fun redactLogs(input: String): String = input.replace(URL_USERINFO, "$1REDACTED@").replace(URL_PARAM, "$1REDACTED")
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.ForwardingPlayer
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports a track whose duration is known from its metadata as finite rather than live.
|
||||||
|
*
|
||||||
|
* Media3 marks a progressive stream live when the data source reports no length and the extractor
|
||||||
|
* no duration. The app declares a length only for a transcode of a track that has one in a
|
||||||
|
* length-estimable format, so a chunked response stays live for an Opus/Ogg/Vorbis transcode, for
|
||||||
|
* a transcode of a track whose library duration is missing, and for a direct play the server does
|
||||||
|
* not give a Content-Length. A live item makes the media session publish no playback position, so
|
||||||
|
* the notification loses its progress even though the item carries a real duration. The stream is
|
||||||
|
* still not seekable; this only restores the position the session reports.
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
internal class KnownDurationPlayer(
|
||||||
|
player: Player,
|
||||||
|
) : ForwardingPlayer(player) {
|
||||||
|
override fun isCurrentMediaItemLive(): Boolean = if (declaredDurationMs() != null) false else super.isCurrentMediaItemLive()
|
||||||
|
|
||||||
|
private fun declaredDurationMs(): Long? =
|
||||||
|
currentMediaItem
|
||||||
|
?.mediaMetadata
|
||||||
|
?.durationMs
|
||||||
|
?.takeIf { it > 0 }
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.Format
|
import androidx.media3.common.Format
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
@@ -13,9 +12,12 @@ import androidx.media3.common.util.UnstableApi
|
|||||||
import androidx.media3.session.MediaController
|
import androidx.media3.session.MediaController
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
|
import com.subtracks.data.model.ArtistRefsCodec
|
||||||
import com.subtracks.data.model.AudioEncoding
|
import com.subtracks.data.model.AudioEncoding
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
|
||||||
class Media3PlayerHandle(
|
class Media3PlayerHandle(
|
||||||
private val controller: MediaController,
|
private val controller: MediaController,
|
||||||
@@ -112,14 +114,19 @@ class Media3PlayerHandle(
|
|||||||
) = controller.replaceMediaItem(index, toMediaItem(item))
|
) = controller.replaceMediaItem(index, toMediaItem(item))
|
||||||
|
|
||||||
override fun seekToIndex(index: Int) {
|
override fun seekToIndex(index: Int) {
|
||||||
Log.d(TAG, "seekToIndex($index) playerDuration=${controller.duration} state=${controller.playbackState}")
|
Log.d(
|
||||||
|
"playback",
|
||||||
|
"seekToIndex($index) position=${controller.currentPosition} duration=${duration(
|
||||||
|
controller.duration,
|
||||||
|
)} state=${controller.playbackState}",
|
||||||
|
)
|
||||||
controller.seekTo(index, 0)
|
controller.seekTo(index, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun seekTo(positionMs: Long) {
|
override fun seekTo(positionMs: Long) {
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
"playback",
|
||||||
"seekTo(${positionMs}ms) from=${controller.currentPosition} playerDuration=${controller.duration} " +
|
"seekTo(${positionMs}ms) from=${controller.currentPosition} duration=${duration(controller.duration)} " +
|
||||||
"itemDuration=${currentItem?.durationMs} state=${controller.playbackState}",
|
"itemDuration=${currentItem?.durationMs} state=${controller.playbackState}",
|
||||||
)
|
)
|
||||||
controller.seekTo(positionMs)
|
controller.seekTo(positionMs)
|
||||||
@@ -136,7 +143,7 @@ class Media3PlayerHandle(
|
|||||||
mediaItem: MediaItem?,
|
mediaItem: MediaItem?,
|
||||||
reason: Int,
|
reason: Int,
|
||||||
) {
|
) {
|
||||||
Log.d(TAG, "item=${mediaItem?.mediaId}")
|
Log.i("playback", "${describe(mediaItem)} transition=${transitionReason(reason)}")
|
||||||
listener.onTransition()
|
listener.onTransition()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,8 +154,9 @@ class Media3PlayerHandle(
|
|||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
"playback",
|
||||||
"Playback error ${error.errorCodeName} at ${controller.currentPosition}/${controller.duration}",
|
"error ${error.errorCodeName} ${describe(controller.currentMediaItem)} " +
|
||||||
|
"position=${controller.currentPosition} duration=${duration(controller.duration)}",
|
||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
listener.onError(playbackErrorMessage(error))
|
listener.onError(playbackErrorMessage(error))
|
||||||
@@ -156,15 +164,54 @@ class Media3PlayerHandle(
|
|||||||
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
"playback",
|
||||||
"state=$playbackState position=${controller.currentPosition} " +
|
"state=$playbackState position=${controller.currentPosition} " +
|
||||||
"playerDuration=${controller.duration} itemDuration=${currentItem?.durationMs}",
|
"duration=${duration(controller.duration)} itemDuration=${currentItem?.durationMs}",
|
||||||
)
|
)
|
||||||
|
if (playbackState == Player.STATE_ENDED) {
|
||||||
|
Log.i("playback", "ended ${describe(controller.currentMediaItem)}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun describe(mediaItem: MediaItem?): String {
|
||||||
|
if (mediaItem == null) return "item=none"
|
||||||
|
val metadata = mediaItem.mediaMetadata
|
||||||
|
val artist =
|
||||||
|
metadata.artist
|
||||||
|
?.toString()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { " artist=\"$it\"" }
|
||||||
|
.orEmpty()
|
||||||
|
val album =
|
||||||
|
metadata.albumTitle
|
||||||
|
?.toString()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { " album=\"$it\"" }
|
||||||
|
.orEmpty()
|
||||||
|
val source =
|
||||||
|
when (mediaItem.localConfiguration?.uri?.scheme) {
|
||||||
|
"file", "content" -> "file"
|
||||||
|
"http", "https" -> "http"
|
||||||
|
null -> "none"
|
||||||
|
else -> "other"
|
||||||
|
}
|
||||||
|
return "item id=${mediaItem.mediaId} title=\"${metadata.title?.toString().orEmpty()}\"$artist$album source=$source"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun duration(value: Long): String = if (value == C.TIME_UNSET) "unset" else "${value}ms"
|
||||||
|
|
||||||
|
private fun transitionReason(reason: Int): String =
|
||||||
|
when (reason) {
|
||||||
|
Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT -> "repeat"
|
||||||
|
Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> "auto"
|
||||||
|
Player.MEDIA_ITEM_TRANSITION_REASON_SEEK -> "seek"
|
||||||
|
Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> "playlist"
|
||||||
|
else -> reason.toString()
|
||||||
|
}
|
||||||
|
|
||||||
private fun toMediaItem(item: QueueItem): MediaItem {
|
private fun toMediaItem(item: QueueItem): MediaItem {
|
||||||
val quality = sourceRepository.quality.value
|
val quality = sourceRepository.quality.value
|
||||||
return queueMediaItem(
|
return queueMediaItem(
|
||||||
@@ -184,20 +231,51 @@ class Media3PlayerHandle(
|
|||||||
.orEmpty(),
|
.orEmpty(),
|
||||||
artist = mediaItem.mediaMetadata.artist?.toString(),
|
artist = mediaItem.mediaMetadata.artist?.toString(),
|
||||||
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
||||||
|
artists = mediaItem.artistRefs(),
|
||||||
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
||||||
durationMs =
|
durationMs =
|
||||||
mediaItem.mediaMetadata.extras
|
mediaItem.mediaMetadata.extras
|
||||||
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
||||||
?.getLong(EXTRA_DURATION_MS),
|
?.getLong(EXTRA_DURATION_MS),
|
||||||
|
replayGain = mediaItem.replayGain(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TAG = "SubtracksPlayback"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun MediaItem.artistRefs(): List<ArtistRef> =
|
||||||
|
mediaMetadata.extras
|
||||||
|
?.getString(EXTRA_ARTISTS)
|
||||||
|
?.let(ArtistRefsCodec::decode)
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
private const val EXTRA_COVER_ART_ID = "coverArtId"
|
private const val EXTRA_COVER_ART_ID = "coverArtId"
|
||||||
|
private const val EXTRA_ARTISTS = "artists"
|
||||||
private const val EXTRA_DURATION_MS = "durationMs"
|
private const val EXTRA_DURATION_MS = "durationMs"
|
||||||
|
private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain"
|
||||||
|
private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
|
||||||
|
private const val EXTRA_REPLAY_GAIN_TRACK_PEAK = "replayGainTrackPeak"
|
||||||
|
private const val EXTRA_REPLAY_GAIN_ALBUM_PEAK = "replayGainAlbumPeak"
|
||||||
|
private const val EXTRA_ALBUM_CONTEXT = "albumContext"
|
||||||
|
|
||||||
|
internal fun MediaItem?.replayGain(): ReplayGain {
|
||||||
|
val extras = this?.mediaMetadata?.extras ?: return ReplayGain()
|
||||||
|
return ReplayGain(
|
||||||
|
trackGain = extras.gain(EXTRA_REPLAY_GAIN_TRACK_GAIN),
|
||||||
|
albumGain = extras.gain(EXTRA_REPLAY_GAIN_ALBUM_GAIN),
|
||||||
|
trackPeak = extras.gain(EXTRA_REPLAY_GAIN_TRACK_PEAK),
|
||||||
|
albumPeak = extras.gain(EXTRA_REPLAY_GAIN_ALBUM_PEAK),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun MediaItem?.albumContext(): Boolean = this?.mediaMetadata?.extras?.getBoolean(EXTRA_ALBUM_CONTEXT) ?: false
|
||||||
|
|
||||||
|
private fun Bundle.putGain(
|
||||||
|
key: String,
|
||||||
|
value: Float?,
|
||||||
|
) {
|
||||||
|
if (value != null) putFloat(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Bundle.gain(key: String): Float? = if (containsKey(key)) getFloat(key) else null
|
||||||
|
|
||||||
internal fun queueMediaItem(
|
internal fun queueMediaItem(
|
||||||
item: QueueItem,
|
item: QueueItem,
|
||||||
@@ -218,7 +296,13 @@ internal fun queueMediaItem(
|
|||||||
.setExtras(
|
.setExtras(
|
||||||
Bundle().apply {
|
Bundle().apply {
|
||||||
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
||||||
|
putString(EXTRA_ARTISTS, ArtistRefsCodec.encode(item.artists))
|
||||||
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
||||||
|
putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain)
|
||||||
|
putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain)
|
||||||
|
putGain(EXTRA_REPLAY_GAIN_TRACK_PEAK, item.replayGain.trackPeak)
|
||||||
|
putGain(EXTRA_REPLAY_GAIN_ALBUM_PEAK, item.replayGain.albumPeak)
|
||||||
|
putBoolean(EXTRA_ALBUM_CONTEXT, item.albumContext)
|
||||||
},
|
},
|
||||||
).build()
|
).build()
|
||||||
val builder =
|
val builder =
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.AudioEncoding
|
import com.subtracks.data.model.AudioEncoding
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.QueueEntry
|
import com.subtracks.data.model.QueueEntry
|
||||||
@@ -11,6 +12,7 @@ import com.subtracks.data.repo.QueueSnapshot
|
|||||||
import com.subtracks.data.repo.QueueWindowItem
|
import com.subtracks.data.repo.QueueWindowItem
|
||||||
import com.subtracks.data.repo.Shuffle
|
import com.subtracks.data.repo.Shuffle
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -33,6 +35,9 @@ data class QueueItem(
|
|||||||
val album: String?,
|
val album: String?,
|
||||||
val coverArtId: String?,
|
val coverArtId: String?,
|
||||||
val durationMs: Long? = null,
|
val durationMs: Long? = null,
|
||||||
|
val replayGain: ReplayGain = ReplayGain(),
|
||||||
|
val albumContext: Boolean = false,
|
||||||
|
val artists: List<ArtistRef> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class QueueContext(
|
data class QueueContext(
|
||||||
@@ -127,7 +132,9 @@ class PlaybackController(
|
|||||||
private var positionJob: Job? = null
|
private var positionJob: Job? = null
|
||||||
private var windowJob: Job? = null
|
private var windowJob: Job? = null
|
||||||
private var bufferingJob: Job? = null
|
private var bufferingJob: Job? = null
|
||||||
|
private var stallJob: Job? = null
|
||||||
private var showBuffering = false
|
private var showBuffering = false
|
||||||
|
private var stalled = false
|
||||||
private val startLock = Mutex()
|
private val startLock = Mutex()
|
||||||
|
|
||||||
private var snapshot: QueueSnapshot? = null
|
private var snapshot: QueueSnapshot? = null
|
||||||
@@ -258,6 +265,15 @@ class PlaybackController(
|
|||||||
artistId: String,
|
artistId: String,
|
||||||
) = shuffleContext(queueRepository.artistEntry(sourceId, artistId))
|
) = shuffleContext(queueRepository.artistEntry(sourceId, artistId))
|
||||||
|
|
||||||
|
fun shuffleGenre(
|
||||||
|
sourceId: Long,
|
||||||
|
genre: String,
|
||||||
|
) = shuffleContext(queueRepository.genreEntry(sourceId, genre))
|
||||||
|
|
||||||
|
fun shuffleStarred(sourceId: Long) = shuffleContext(queueRepository.starredEntry(sourceId))
|
||||||
|
|
||||||
|
fun shuffleDownloaded(sourceId: Long) = shuffleContext(queueRepository.downloadedEntry(sourceId))
|
||||||
|
|
||||||
private fun playContext(
|
private fun playContext(
|
||||||
entry: QueueEntry,
|
entry: QueueEntry,
|
||||||
start: Long,
|
start: Long,
|
||||||
@@ -380,7 +396,7 @@ class PlaybackController(
|
|||||||
val current = currentPosition() ?: return@withLock
|
val current = currentPosition() ?: return@withLock
|
||||||
val playing = player.playWhenReady
|
val playing = player.playWhenReady
|
||||||
val wasUpNext = snapshot.isUpNext(current)
|
val wasUpNext = snapshot.isUpNext(current)
|
||||||
val removed = if (position == current) null else queueRepository.itemAt(snapshot, position)?.toQueueItem()
|
val removed = if (position == current) null else queueItemAt(snapshot, position)
|
||||||
queueRepository.removeAt(snapshot, position)
|
queueRepository.removeAt(snapshot, position)
|
||||||
var updated = readSnapshot()
|
var updated = readSnapshot()
|
||||||
this.snapshot = updated
|
this.snapshot = updated
|
||||||
@@ -532,13 +548,13 @@ class PlaybackController(
|
|||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
fromInWindow && toInWindow -> {
|
fromInWindow && toInWindow && (snapshot.upNextSize == 0L || snapshot.isUpNext(from)) -> {
|
||||||
updating = true
|
updating = true
|
||||||
player.move((from - windowStart).toInt(), (to - windowStart).toInt())
|
player.move((from - windowStart).toInt(), (to - windowStart).toInt())
|
||||||
updating = false
|
updating = false
|
||||||
}
|
}
|
||||||
|
|
||||||
!fromInWindow && !toInWindow -> {
|
!fromInWindow && !toInWindow && (snapshot.upNextSize == 0L || snapshot.isUpNext(from)) -> {
|
||||||
// The moved track never enters the window, so the playlist is unchanged; only
|
// The moved track never enters the window, so the playlist is unchanged; only
|
||||||
// the window's positions shift, and a move that spans it shifts them by one.
|
// the window's positions shift, and a move that spans it shifts them by one.
|
||||||
when {
|
when {
|
||||||
@@ -680,12 +696,29 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
when {
|
nextPosition(snapshot, current)?.let { jumpTo(it) }
|
||||||
current < snapshot.size - 1 -> jumpTo(current + 1)
|
|
||||||
repeatMode != RepeatMode.Off -> jumpTo(0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun nextPosition(
|
||||||
|
snapshot: QueueSnapshot,
|
||||||
|
current: Long,
|
||||||
|
): Long? =
|
||||||
|
when {
|
||||||
|
current < snapshot.size - 1 -> current + 1
|
||||||
|
repeatMode != RepeatMode.Off -> 0L
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun previousPosition(
|
||||||
|
snapshot: QueueSnapshot,
|
||||||
|
current: Long,
|
||||||
|
): Long? =
|
||||||
|
when {
|
||||||
|
current > 0 -> current - 1
|
||||||
|
repeatMode == RepeatMode.All -> snapshot.size - 1
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun consumeUpNext(position: Long) =
|
private suspend fun consumeUpNext(position: Long) =
|
||||||
startLock.withLock {
|
startLock.withLock {
|
||||||
val snapshot = snapshot ?: return@withLock
|
val snapshot = snapshot ?: return@withLock
|
||||||
@@ -771,19 +804,12 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
when {
|
val target = previousPosition(snapshot, current)
|
||||||
current > 0 -> {
|
if (target != null) {
|
||||||
jumpTo(current - 1)
|
jumpTo(target)
|
||||||
}
|
} else {
|
||||||
|
player.seekTo(0)
|
||||||
repeatMode == RepeatMode.All -> {
|
refresh()
|
||||||
jumpTo(snapshot.size - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
player.seekTo(0)
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,10 +827,7 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
when {
|
previousPosition(snapshot, current)?.let { jumpTo(it) }
|
||||||
current > 0 -> jumpTo(current - 1)
|
|
||||||
repeatMode == RepeatMode.All -> jumpTo(snapshot.size - 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addToQueue(
|
fun addToQueue(
|
||||||
@@ -921,12 +944,16 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
val snapshot = current ?: return null
|
val snapshot = current ?: return null
|
||||||
val at = position ?: return null
|
val at = position ?: return null
|
||||||
if (!isOffline) return queueRepository.itemAt(snapshot, at + 1)?.toQueueItem()
|
if (!isOffline) {
|
||||||
|
return nextPosition(snapshot, at)
|
||||||
|
?.takeIf { it != at }
|
||||||
|
?.let { queueItemAt(snapshot, it) }
|
||||||
|
}
|
||||||
downloads.awaitLoaded()
|
downloads.awaitLoaded()
|
||||||
val target =
|
val target =
|
||||||
downloadedAtOrAfter(snapshot, at + 1)
|
downloadedAtOrAfter(snapshot, at + 1)
|
||||||
?: if (repeatMode != RepeatMode.Off) downloadedAtOrAfter(snapshot, 0L) else null
|
?: if (repeatMode != RepeatMode.Off) downloadedAtOrAfter(snapshot, 0L) else null
|
||||||
return target?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
|
return target?.takeIf { it != at }?.let { queueItemAt(snapshot, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun previousItem(): QueueItem? {
|
suspend fun previousItem(): QueueItem? {
|
||||||
@@ -940,12 +967,16 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
val snapshot = current ?: return null
|
val snapshot = current ?: return null
|
||||||
val at = position ?: return null
|
val at = position ?: return null
|
||||||
if (!isOffline) return if (at <= 0) null else queueRepository.itemAt(snapshot, at - 1)?.toQueueItem()
|
if (!isOffline) {
|
||||||
|
return previousPosition(snapshot, at)
|
||||||
|
?.takeIf { it != at }
|
||||||
|
?.let { queueItemAt(snapshot, it) }
|
||||||
|
}
|
||||||
downloads.awaitLoaded()
|
downloads.awaitLoaded()
|
||||||
val target =
|
val target =
|
||||||
downloadedAtOrBefore(snapshot, at - 1)
|
downloadedAtOrBefore(snapshot, at - 1)
|
||||||
?: if (repeatMode == RepeatMode.All) downloadedAtOrBefore(snapshot, snapshot.size - 1) else null
|
?: if (repeatMode == RepeatMode.All) downloadedAtOrBefore(snapshot, snapshot.size - 1) else null
|
||||||
return target?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
|
return target?.takeIf { it != at }?.let { queueItemAt(snapshot, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun play(
|
private fun play(
|
||||||
@@ -1047,6 +1078,9 @@ class PlaybackController(
|
|||||||
windowJob?.cancel()
|
windowJob?.cancel()
|
||||||
bufferingJob?.cancel()
|
bufferingJob?.cancel()
|
||||||
bufferingJob = null
|
bufferingJob = null
|
||||||
|
stallJob?.cancel()
|
||||||
|
stallJob = null
|
||||||
|
stalled = false
|
||||||
showBuffering = false
|
showBuffering = false
|
||||||
snapshot = null
|
snapshot = null
|
||||||
queueSourceId = null
|
queueSourceId = null
|
||||||
@@ -1101,7 +1135,7 @@ class PlaybackController(
|
|||||||
lastPosition = actual
|
lastPosition = actual
|
||||||
if (actual != requested) queueRepository.setCursor(actual)
|
if (actual != requested) queueRepository.setCursor(actual)
|
||||||
updating = true
|
updating = true
|
||||||
player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs)
|
player.setWindow(window.map { it.playerItem(snapshot) }, startIndex, startPositionMs)
|
||||||
updating = false
|
updating = false
|
||||||
if (autoplay) {
|
if (autoplay) {
|
||||||
player.prepare()
|
player.prepare()
|
||||||
@@ -1141,7 +1175,7 @@ class PlaybackController(
|
|||||||
window.forEachIndexed { index, entry ->
|
window.forEachIndexed { index, entry ->
|
||||||
val local = downloads.localUri(entry.item.song.id) != null
|
val local = downloads.localUri(entry.item.song.id) != null
|
||||||
if (local != (uris[index]?.startsWith(FILE_SCHEME) == true)) {
|
if (local != (uris[index]?.startsWith(FILE_SCHEME) == true)) {
|
||||||
player.replaceItem(index, entry.item.toQueueItem())
|
player.replaceItem(index, entry.playerItem(snapshot))
|
||||||
replaced = true
|
replaced = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1256,8 +1290,8 @@ class PlaybackController(
|
|||||||
updating = true
|
updating = true
|
||||||
try {
|
try {
|
||||||
while (windowStart > desiredStart) {
|
while (windowStart > desiredStart) {
|
||||||
val item = queueRepository.itemAt(snapshot, windowStart - 1) ?: break
|
val item = queueItemAt(snapshot, windowStart - 1) ?: break
|
||||||
player.addFirst(item.toQueueItem())
|
player.addFirst(item)
|
||||||
windowStart--
|
windowStart--
|
||||||
}
|
}
|
||||||
while (windowStart < desiredStart) {
|
while (windowStart < desiredStart) {
|
||||||
@@ -1265,8 +1299,8 @@ class PlaybackController(
|
|||||||
windowStart++
|
windowStart++
|
||||||
}
|
}
|
||||||
while (windowEnd < desiredEnd) {
|
while (windowEnd < desiredEnd) {
|
||||||
val item = queueRepository.itemAt(snapshot, windowEnd + 1) ?: break
|
val item = queueItemAt(snapshot, windowEnd + 1) ?: break
|
||||||
player.addLast(item.toQueueItem())
|
player.addLast(item)
|
||||||
windowEnd++
|
windowEnd++
|
||||||
}
|
}
|
||||||
while (windowEnd > desiredEnd) {
|
while (windowEnd > desiredEnd) {
|
||||||
@@ -1284,6 +1318,7 @@ class PlaybackController(
|
|||||||
position: Long,
|
position: Long,
|
||||||
) {
|
) {
|
||||||
val player = player ?: return
|
val player = player ?: return
|
||||||
|
val snapshot = this.snapshot ?: return
|
||||||
downloads.awaitLoaded()
|
downloads.awaitLoaded()
|
||||||
val ordered =
|
val ordered =
|
||||||
if (offline) window.filter { downloads.localUri(it.item.song.id) != null } else window
|
if (offline) window.filter { downloads.localUri(it.item.song.id) != null } else window
|
||||||
@@ -1297,7 +1332,7 @@ class PlaybackController(
|
|||||||
val current = ordered[index]
|
val current = ordered[index]
|
||||||
val wantsLocal = downloads.localUri(current.item.song.id) != null
|
val wantsLocal = downloads.localUri(current.item.song.id) != null
|
||||||
val isLocal = player.itemUris().getOrNull(player.currentIndex)?.startsWith(FILE_SCHEME) == true
|
val isLocal = player.itemUris().getOrNull(player.currentIndex)?.startsWith(FILE_SCHEME) == true
|
||||||
if (wantsLocal != isLocal) player.replaceItem(player.currentIndex, current.item.toQueueItem())
|
if (wantsLocal != isLocal) player.replaceItem(player.currentIndex, current.playerItem(snapshot))
|
||||||
windowPositions = LongArray(ordered.size) { ordered[it].position }
|
windowPositions = LongArray(ordered.size) { ordered[it].position }
|
||||||
windowFiltered = offline
|
windowFiltered = offline
|
||||||
windowStart = ordered.first().position
|
windowStart = ordered.first().position
|
||||||
@@ -1307,8 +1342,8 @@ class PlaybackController(
|
|||||||
try {
|
try {
|
||||||
repeat(player.currentIndex) { player.removeFirst() }
|
repeat(player.currentIndex) { player.removeFirst() }
|
||||||
while (player.itemCount > 1) player.removeLast()
|
while (player.itemCount > 1) player.removeLast()
|
||||||
for (i in index - 1 downTo 0) player.addFirst(ordered[i].item.toQueueItem())
|
for (i in index - 1 downTo 0) player.addFirst(ordered[i].playerItem(snapshot))
|
||||||
for (i in index + 1 until ordered.size) player.addLast(ordered[i].item.toQueueItem())
|
for (i in index + 1 until ordered.size) player.addLast(ordered[i].playerItem(snapshot))
|
||||||
} finally {
|
} finally {
|
||||||
updating = false
|
updating = false
|
||||||
}
|
}
|
||||||
@@ -1450,6 +1485,17 @@ class PlaybackController(
|
|||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun QueueWindowItem.playerItem(snapshot: QueueSnapshot): QueueItem =
|
||||||
|
item.toQueueItem(albumContext = snapshot.isAlbumContext(position))
|
||||||
|
|
||||||
|
private suspend fun queueItemAt(
|
||||||
|
snapshot: QueueSnapshot,
|
||||||
|
position: Long,
|
||||||
|
): QueueItem? =
|
||||||
|
queueRepository
|
||||||
|
.itemAt(snapshot, position)
|
||||||
|
?.toQueueItem(albumContext = snapshot.isAlbumContext(position))
|
||||||
|
|
||||||
private fun contextAt(position: Long?): QueueContext? {
|
private fun contextAt(position: Long?): QueueContext? {
|
||||||
val snapshot = snapshot ?: return null
|
val snapshot = snapshot ?: return null
|
||||||
val play = position?.let { snapshot.anchorContextPlay(it) } ?: return null
|
val play = position?.let { snapshot.anchorContextPlay(it) } ?: return null
|
||||||
@@ -1460,21 +1506,42 @@ class PlaybackController(
|
|||||||
suspend fun sourceTitle(context: QueueContext?): String? = context?.let { queueRepository.sourceName(it.kind, it.sourceId, it.refId) }
|
suspend fun sourceTitle(context: QueueContext?): String? = context?.let { queueRepository.sourceName(it.kind, it.sourceId, it.refId) }
|
||||||
|
|
||||||
private fun updateBuffering() {
|
private fun updateBuffering() {
|
||||||
|
val player = player
|
||||||
if (player?.isBuffering != true) {
|
if (player?.isBuffering != true) {
|
||||||
bufferingJob?.cancel()
|
bufferingJob?.cancel()
|
||||||
bufferingJob = null
|
bufferingJob = null
|
||||||
|
stallJob?.cancel()
|
||||||
|
stallJob = null
|
||||||
|
stalled = false
|
||||||
showBuffering = false
|
showBuffering = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (showBuffering || bufferingJob?.isActive == true) return
|
if (!showBuffering && bufferingJob?.isActive != true) {
|
||||||
bufferingJob =
|
bufferingJob =
|
||||||
scope.launch {
|
scope.launch {
|
||||||
delay(BUFFERING_INDICATOR_DELAY_MS)
|
delay(BUFFERING_INDICATOR_DELAY_MS)
|
||||||
if (player?.isBuffering == true) {
|
if (player.isBuffering) {
|
||||||
showBuffering = true
|
showBuffering = true
|
||||||
refresh()
|
refresh()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A stall is buffering that persists long enough to be a real problem, not a track change.
|
||||||
|
if (stallJob?.isActive != true && !stalled) {
|
||||||
|
stallJob =
|
||||||
|
scope.launch {
|
||||||
|
delay(STALL_WATCHDOG_MS)
|
||||||
|
if (player === this@PlaybackController.player && player.isBuffering && player.playWhenReady) {
|
||||||
|
stalled = true
|
||||||
|
val item = player.currentItem
|
||||||
|
Log.w(
|
||||||
|
"playback",
|
||||||
|
"stalled: buffering for ${STALL_WATCHDOG_MS / 1000}s at ${player.currentPositionMs}ms " +
|
||||||
|
"item id=${item?.id} title=\"${item?.title}\"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refresh(position: Long? = currentPosition()) {
|
private fun refresh(position: Long? = currentPosition()) {
|
||||||
@@ -1541,6 +1608,7 @@ class PlaybackController(
|
|||||||
const val POSITION_TICK_MS = 500L
|
const val POSITION_TICK_MS = 500L
|
||||||
const val POSITION_SAVE_INTERVAL_MS = 15_000L
|
const val POSITION_SAVE_INTERVAL_MS = 15_000L
|
||||||
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
|
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
|
||||||
|
const val STALL_WATCHDOG_MS = 15_000L
|
||||||
const val QUEUE_WINDOW_RADIUS = 25L
|
const val QUEUE_WINDOW_RADIUS = 25L
|
||||||
const val WINDOW_SHIFT_DELAY_MS = 400L
|
const val WINDOW_SHIFT_DELAY_MS = 400L
|
||||||
const val RESTART_THRESHOLD_MS = 3_000L
|
const val RESTART_THRESHOLD_MS = 3_000L
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.media3.common.AudioAttributes
|
import androidx.media3.common.AudioAttributes
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
@@ -13,8 +12,13 @@ import androidx.media3.common.MediaItem
|
|||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.ExperimentalApi
|
import androidx.media3.common.util.ExperimentalApi
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.database.StandaloneDatabaseProvider
|
||||||
import androidx.media3.datasource.DataSource
|
import androidx.media3.datasource.DataSource
|
||||||
import androidx.media3.datasource.DefaultDataSource
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
|
import androidx.media3.datasource.cache.Cache
|
||||||
|
import androidx.media3.datasource.cache.CacheDataSource
|
||||||
|
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||||
|
import androidx.media3.datasource.cache.SimpleCache
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
@@ -29,17 +33,22 @@ import com.google.common.util.concurrent.Futures
|
|||||||
import com.google.common.util.concurrent.ListenableFuture
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
import com.subtracks.MainActivity
|
import com.subtracks.MainActivity
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
|
import com.subtracks.UserAgent
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koin.core.context.GlobalContext
|
import org.koin.core.context.GlobalContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
class PlaybackService : MediaSessionService() {
|
class PlaybackService : MediaSessionService() {
|
||||||
@@ -53,6 +62,9 @@ class PlaybackService : MediaSessionService() {
|
|||||||
private var starred = false
|
private var starred = false
|
||||||
private var starJob: Job? = null
|
private var starJob: Job? = null
|
||||||
|
|
||||||
|
private val gainProcessor: ReplayGainProcessor = ReplayGainProcessor { replayGain.applyItem(it) }
|
||||||
|
private val replayGain: ReplayGainController = ReplayGainController { gainProcessor.setLinearGain(it) }
|
||||||
|
|
||||||
private val starCallback =
|
private val starCallback =
|
||||||
object : MediaSession.Callback {
|
object : MediaSession.Callback {
|
||||||
override fun onConnect(
|
override fun onConnect(
|
||||||
@@ -90,9 +102,18 @@ class PlaybackService : MediaSessionService() {
|
|||||||
val sources = GlobalContext.get().get<SourceRepository>()
|
val sources = GlobalContext.get().get<SourceRepository>()
|
||||||
sourceRepository = sources
|
sourceRepository = sources
|
||||||
library = GlobalContext.get().get<LibraryRepository>()
|
library = GlobalContext.get().get<LibraryRepository>()
|
||||||
|
val preferences = GlobalContext.get().get<UserPreferences>()
|
||||||
|
val cache = StreamCache.open(this)
|
||||||
|
scope.launch {
|
||||||
|
preferences
|
||||||
|
.replayGainMode()
|
||||||
|
.combine(preferences.replayGainPreampDb()) { mode, preamp -> ReplayGainSettings(mode, preamp) }
|
||||||
|
.collect(replayGain::setSettings)
|
||||||
|
}
|
||||||
val exoPlayer =
|
val exoPlayer =
|
||||||
ExoPlayer
|
ExoPlayer
|
||||||
.Builder(this)
|
.Builder(this)
|
||||||
|
.setRenderersFactory(ReplayGainRenderersFactory(this, gainProcessor))
|
||||||
.setAudioAttributes(
|
.setAudioAttributes(
|
||||||
AudioAttributes
|
AudioAttributes
|
||||||
.Builder()
|
.Builder()
|
||||||
@@ -104,7 +125,13 @@ class PlaybackService : MediaSessionService() {
|
|||||||
.setMediaSourceFactory(
|
.setMediaSourceFactory(
|
||||||
DefaultMediaSourceFactory(this)
|
DefaultMediaSourceFactory(this)
|
||||||
.setEnableClippingInMediaPeriod(true)
|
.setEnableClippingInMediaPeriod(true)
|
||||||
.setDataSourceFactory(mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()))),
|
.setDataSourceFactory(
|
||||||
|
mediaDataSourceFactory(
|
||||||
|
this,
|
||||||
|
cache,
|
||||||
|
OkHttpDataSource.Factory(streamingClient()).setUserAgent(UserAgent.value),
|
||||||
|
),
|
||||||
|
),
|
||||||
).build()
|
).build()
|
||||||
exoPlayer.addListener(
|
exoPlayer.addListener(
|
||||||
object : Player.Listener {
|
object : Player.Listener {
|
||||||
@@ -116,6 +143,7 @@ class PlaybackService : MediaSessionService() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
val player = KnownDurationPlayer(exoPlayer)
|
||||||
val loader =
|
val loader =
|
||||||
CoverArtBitmapLoader(
|
CoverArtBitmapLoader(
|
||||||
applicationContext,
|
applicationContext,
|
||||||
@@ -125,7 +153,7 @@ class PlaybackService : MediaSessionService() {
|
|||||||
artworkLoader = loader
|
artworkLoader = loader
|
||||||
session =
|
session =
|
||||||
MediaSession
|
MediaSession
|
||||||
.Builder(this, exoPlayer)
|
.Builder(this, player)
|
||||||
.setSessionActivity(
|
.setSessionActivity(
|
||||||
PendingIntent.getActivity(
|
PendingIntent.getActivity(
|
||||||
this,
|
this,
|
||||||
@@ -183,12 +211,13 @@ class PlaybackService : MediaSessionService() {
|
|||||||
private fun streamingClient(): OkHttpClient =
|
private fun streamingClient(): OkHttpClient =
|
||||||
OkHttpClient
|
OkHttpClient
|
||||||
.Builder()
|
.Builder()
|
||||||
|
.addInterceptor(Log.interceptor())
|
||||||
.addInterceptor { chain ->
|
.addInterceptor { chain ->
|
||||||
val started = SystemClock.elapsedRealtime()
|
val started = SystemClock.elapsedRealtime()
|
||||||
val response = chain.proceed(chain.request())
|
val response = chain.proceed(chain.request())
|
||||||
Log.d(
|
Log.d(
|
||||||
STREAM_TAG,
|
"stream",
|
||||||
"stream range=${chain.request().header("Range")} code=${response.code} " +
|
"range=${chain.request().header("Range")} code=${response.code} " +
|
||||||
"contentRange=${response.header("Content-Range")} " +
|
"contentRange=${response.header("Content-Range")} " +
|
||||||
"length=${response.body.contentLength()} in ${SystemClock.elapsedRealtime() - started}ms",
|
"length=${response.body.contentLength()} in ${SystemClock.elapsedRealtime() - started}ms",
|
||||||
)
|
)
|
||||||
@@ -208,13 +237,50 @@ class PlaybackService : MediaSessionService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val STREAM_TAG = "SubtracksPlayback"
|
|
||||||
val STAR_COMMAND = SessionCommand("com.subtracks.STAR", Bundle.EMPTY)
|
val STAR_COMMAND = SessionCommand("com.subtracks.STAR", Bundle.EMPTY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process-scoped so it is never released while a loader thread is still committing to it, and so
|
||||||
|
// a recreated service does not try to lock the directory a second time.
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
private object StreamCache {
|
||||||
|
@Volatile
|
||||||
|
private var instance: SimpleCache? = null
|
||||||
|
|
||||||
|
fun open(context: Context): SimpleCache? =
|
||||||
|
instance ?: synchronized(this) {
|
||||||
|
instance ?: runCatching {
|
||||||
|
SimpleCache(
|
||||||
|
File(context.cacheDir, STREAM_CACHE_DIR),
|
||||||
|
LeastRecentlyUsedCacheEvictor(STREAM_CACHE_BYTES),
|
||||||
|
StandaloneDatabaseProvider(context),
|
||||||
|
)
|
||||||
|
}.onFailure { Log.w("playback", "stream cache unavailable: ${it.message}") }
|
||||||
|
.getOrNull()
|
||||||
|
?.also { instance = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val STREAM_CACHE_DIR = "stream-cache"
|
||||||
|
private const val STREAM_CACHE_BYTES = 64L * 1024 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
internal fun mediaDataSourceFactory(
|
internal fun mediaDataSourceFactory(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
cache: Cache?,
|
||||||
upstream: DataSource.Factory,
|
upstream: DataSource.Factory,
|
||||||
): DataSource.Factory = DefaultDataSource.Factory(context, KnownLengthDataSourceFactory(upstream))
|
): DataSource.Factory {
|
||||||
|
val known = KnownLengthDataSourceFactory(upstream)
|
||||||
|
val source: DataSource.Factory =
|
||||||
|
if (cache == null) {
|
||||||
|
known
|
||||||
|
} else {
|
||||||
|
CacheDataSource
|
||||||
|
.Factory()
|
||||||
|
.setCache(cache)
|
||||||
|
.setUpstreamDataSourceFactory(known)
|
||||||
|
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||||
|
}
|
||||||
|
return DefaultDataSource.Factory(context, source)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.SongItem
|
import com.subtracks.data.model.SongItem
|
||||||
|
|
||||||
fun SongItem.toQueueItem() =
|
fun SongItem.toQueueItem(albumContext: Boolean = false) =
|
||||||
QueueItem(
|
QueueItem(
|
||||||
id = song.id,
|
id = song.id,
|
||||||
title = song.title,
|
title = song.title,
|
||||||
artist = song.artist,
|
artist = song.artist,
|
||||||
|
artists = song.artistRefs,
|
||||||
album = song.album,
|
album = song.album,
|
||||||
coverArtId = coverArt,
|
coverArtId = coverArt,
|
||||||
durationMs = song.duration?.let { it * 1000 },
|
durationMs = song.duration?.let { it * 1000 },
|
||||||
|
replayGain = song.replayGain(),
|
||||||
|
albumContext = albumContext,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
|
import kotlin.math.log10
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.pow
|
||||||
|
|
||||||
|
data class ReplayGain(
|
||||||
|
val trackGain: Float? = null,
|
||||||
|
val albumGain: Float? = null,
|
||||||
|
val trackPeak: Float? = null,
|
||||||
|
val albumPeak: Float? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class ReplayGainSettings(
|
||||||
|
val mode: ReplayGainMode,
|
||||||
|
val preampDb: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun Song.replayGain(): ReplayGain =
|
||||||
|
ReplayGain(
|
||||||
|
trackGain = trackGain,
|
||||||
|
albumGain = albumGain,
|
||||||
|
trackPeak = trackPeak,
|
||||||
|
albumPeak = albumPeak,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun replayGainLinearGain(
|
||||||
|
gain: ReplayGain,
|
||||||
|
settings: ReplayGainSettings,
|
||||||
|
albumContext: Boolean = false,
|
||||||
|
): Float {
|
||||||
|
if (settings.mode == ReplayGainMode.Off) return 1f
|
||||||
|
// gonic sends a peak of 0 when it only has album gain, so a non-positive or non-finite peak means
|
||||||
|
// "no peak data" and is ignored rather than treated as silence. A boost cannot be checked for
|
||||||
|
// headroom without a peak, so a peakless track is only ever attenuated.
|
||||||
|
val trackPeak = gain.trackPeak?.takeIf { it.isFinite() && it > 0f }
|
||||||
|
val albumPeak = gain.albumPeak?.takeIf { it.isFinite() && it > 0f }
|
||||||
|
val album = settings.mode == ReplayGainMode.Album || (settings.mode == ReplayGainMode.Auto && albumContext)
|
||||||
|
val (requestedDb, peak) =
|
||||||
|
if (album) {
|
||||||
|
(gain.albumGain ?: gain.trackGain) to (albumPeak ?: trackPeak)
|
||||||
|
} else {
|
||||||
|
(gain.trackGain ?: gain.albumGain) to (trackPeak ?: albumPeak)
|
||||||
|
}
|
||||||
|
val gainDb = requestedDb?.takeIf { it.isFinite() } ?: return 1f
|
||||||
|
val requested = gainDb + settings.preampDb
|
||||||
|
val maxGainDb = peak?.let(::maxSafeGainDb) ?: 0f
|
||||||
|
val applied = min(requested, maxGainDb)
|
||||||
|
return 10f.pow(applied / 20f).takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maxSafeGainDb(peak: Float): Float = -20f * log10(peak)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
|
|
||||||
|
internal class ReplayGainController(
|
||||||
|
private val apply: (Float) -> Unit,
|
||||||
|
) {
|
||||||
|
private var track = ReplayGain()
|
||||||
|
private var albumContext = false
|
||||||
|
private var settings = ReplayGainSettings(ReplayGainMode.Auto, 0f)
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun applyItem(item: MediaItem) {
|
||||||
|
track = item.replayGain()
|
||||||
|
albumContext = item.albumContext()
|
||||||
|
applyGain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun setSettings(value: ReplayGainSettings) {
|
||||||
|
settings = value
|
||||||
|
applyGain()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyGain() {
|
||||||
|
apply(replayGainLinearGain(track, settings, albumContext))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.Timeline
|
||||||
|
import androidx.media3.common.audio.AudioProcessor.AudioFormat
|
||||||
|
import androidx.media3.common.audio.AudioProcessor.StreamMetadata
|
||||||
|
import androidx.media3.common.audio.BaseAudioProcessor
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
internal class ReplayGainProcessor(
|
||||||
|
private val onItem: (MediaItem) -> Unit = {},
|
||||||
|
) : BaseAudioProcessor() {
|
||||||
|
@Volatile
|
||||||
|
private var linearGain = 1f
|
||||||
|
|
||||||
|
fun setLinearGain(value: Float) {
|
||||||
|
linearGain = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConfigure(inputAudioFormat: AudioFormat): AudioFormat = inputAudioFormat
|
||||||
|
|
||||||
|
// DefaultAudioSink flushes the processors on every configuration, which happens at each media
|
||||||
|
// item transition even when the format is unchanged, and hands over the timeline and the period
|
||||||
|
// being flushed (androidx/media#418). That is early enough to set the new item's gain before any
|
||||||
|
// of its buffers are processed, unlike Player.Listener.onMediaItemTransition, which the app
|
||||||
|
// thread receives afterwards.
|
||||||
|
override fun onFlush(streamMetadata: StreamMetadata) {
|
||||||
|
streamMetadata.currentItem()?.let(onItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun queueInput(inputBuffer: ByteBuffer) {
|
||||||
|
val output = replaceOutputBuffer(inputBuffer.remaining())
|
||||||
|
val gain = linearGain.takeIf { it.isFinite() } ?: 1f
|
||||||
|
when (inputAudioFormat.encoding) {
|
||||||
|
C.ENCODING_PCM_16BIT -> {
|
||||||
|
while (inputBuffer.remaining() >= 2) {
|
||||||
|
output.putShort((inputBuffer.short * gain).roundToInt().coerceIn(MIN_SAMPLE, MAX_SAMPLE).toShort())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
C.ENCODING_PCM_FLOAT -> {
|
||||||
|
while (inputBuffer.remaining() >= 4) output.putFloat(inputBuffer.float * gain)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
if (inputBuffer.hasRemaining()) output.put(inputBuffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inputBuffer.position(inputBuffer.limit())
|
||||||
|
output.flip()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MIN_SAMPLE = -32768
|
||||||
|
const val MAX_SAMPLE = 32767
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
internal fun StreamMetadata.currentItem(): MediaItem? {
|
||||||
|
val uid = periodUid ?: return null
|
||||||
|
val index = timeline.getIndexOfPeriod(uid)
|
||||||
|
if (index == C.INDEX_UNSET) return null
|
||||||
|
val period = timeline.getPeriod(index, Timeline.Period())
|
||||||
|
return timeline.getWindow(period.windowIndex, Timeline.Window()).mediaItem
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
|
import androidx.media3.exoplayer.audio.AudioSink
|
||||||
|
import androidx.media3.exoplayer.audio.DefaultAudioSink
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
internal class ReplayGainRenderersFactory(
|
||||||
|
context: Context,
|
||||||
|
private val processor: ReplayGainProcessor,
|
||||||
|
) : DefaultRenderersFactory(context) {
|
||||||
|
// Mirrors the stock DefaultRenderersFactory.buildAudioSink. setAudioProcessors keeps the default
|
||||||
|
// chain and prepends ours; DefaultAudioSink omits the whole chain when float output is enabled,
|
||||||
|
// and AudioSink.setOffloadMode bypasses it entirely.
|
||||||
|
public override fun buildAudioSink(
|
||||||
|
context: Context,
|
||||||
|
enableFloatOutput: Boolean,
|
||||||
|
enableAudioOutputPlaybackParameters: Boolean,
|
||||||
|
): AudioSink {
|
||||||
|
if (enableFloatOutput) {
|
||||||
|
Log.w(FLOAT_OUTPUT_TAG, "float output is on, so replay gain is not applied to high-resolution tracks")
|
||||||
|
}
|
||||||
|
return DefaultAudioSink
|
||||||
|
.Builder(context)
|
||||||
|
.setEnableFloatOutput(enableFloatOutput)
|
||||||
|
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParameters)
|
||||||
|
.setAudioProcessors(arrayOf(processor))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val FLOAT_OUTPUT_TAG = "replaygain"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -58,6 +59,7 @@ class ScrobblePolicy(
|
|||||||
item?.id != songId ||
|
item?.id != songId ||
|
||||||
(item != null && item.id == lastItemId && positionMs < previousPosition && positionMs <= RESTART_POSITION_MS)
|
(item != null && item.id == lastItemId && positionMs < previousPosition && positionMs <= RESTART_POSITION_MS)
|
||||||
if (restarted) {
|
if (restarted) {
|
||||||
|
val alreadyAnnounced = nowPlayingSent && item?.id == songId
|
||||||
songId = item?.id
|
songId = item?.id
|
||||||
lastItemId = null
|
lastItemId = null
|
||||||
playedMs = 0
|
playedMs = 0
|
||||||
@@ -67,7 +69,7 @@ class ScrobblePolicy(
|
|||||||
if (item == null || !isPlaying) return null
|
if (item == null || !isPlaying) return null
|
||||||
nowPlayingSent = true
|
nowPlayingSent = true
|
||||||
startedAt = now()
|
startedAt = now()
|
||||||
return Scrobble.NowPlaying(item.id)
|
return if (alreadyAnnounced) null else Scrobble.NowPlaying(item.id)
|
||||||
}
|
}
|
||||||
lastPositionMs = positionMs
|
lastPositionMs = positionMs
|
||||||
lastItemId = item?.id
|
lastItemId = item?.id
|
||||||
@@ -128,22 +130,28 @@ class Scrobbler(
|
|||||||
private suspend fun submit(event: Scrobble) {
|
private suspend fun submit(event: Scrobble) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is Scrobble.NowPlaying -> {
|
is Scrobble.NowPlaying -> {
|
||||||
attempt { sink.nowPlaying(event.songId) }
|
Log.i("scrobble", "now playing ${event.songId}")
|
||||||
|
attempt("now playing ${event.songId}") { sink.nowPlaying(event.songId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
is Scrobble.Submission -> {
|
is Scrobble.Submission -> {
|
||||||
attempt { recordPlay(event.songId, event.time / 1000) }
|
Log.i("scrobble", "submitting ${event.songId}")
|
||||||
attempt { sink.scrobble(event.songId, event.time) }
|
attempt("record play ${event.songId}") { recordPlay(event.songId, event.time / 1000) }
|
||||||
|
attempt("scrobble ${event.songId}") { sink.scrobble(event.songId, event.time) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun attempt(block: suspend () -> Unit) {
|
private suspend fun attempt(
|
||||||
|
label: String,
|
||||||
|
block: suspend () -> Unit,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
block()
|
block()
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (_: Exception) {
|
} catch (failure: Exception) {
|
||||||
|
Log.w("scrobble", "$label failed", failure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,14 +61,20 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
|||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.AlbumInfoDialog
|
||||||
|
import com.subtracks.ui.components.ArtistChooserSheet
|
||||||
|
import com.subtracks.ui.components.ArtistInfoDialog
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.ItemContextMenu
|
import com.subtracks.ui.components.ItemContextMenu
|
||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
|
import com.subtracks.ui.components.MenuTarget
|
||||||
|
import com.subtracks.ui.components.PlaylistInfoDialog
|
||||||
import com.subtracks.ui.components.SongInfoDialog
|
import com.subtracks.ui.components.SongInfoDialog
|
||||||
import com.subtracks.ui.components.statusBarScrim
|
import com.subtracks.ui.components.statusBarScrim
|
||||||
import com.subtracks.ui.downloads.DownloadsRoute
|
import com.subtracks.ui.downloads.DownloadsRoute
|
||||||
@@ -230,7 +236,8 @@ private fun MainNavigation() {
|
|||||||
var settleJob by remember { mutableStateOf<Job?>(null) }
|
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val contextMenuHost = remember { ContextMenuHost() }
|
val contextMenuHost = remember { ContextMenuHost() }
|
||||||
var infoSong by remember { mutableStateOf<Song?>(null) }
|
var infoTarget by remember { mutableStateOf<MenuTarget?>(null) }
|
||||||
|
var artistChooser by remember { mutableStateOf<List<ArtistRef>?>(null) }
|
||||||
|
|
||||||
val activeSourceId by libraryRepository.activeSourceId.collectAsStateWithLifecycle(initialValue = null)
|
val activeSourceId by libraryRepository.activeSourceId.collectAsStateWithLifecycle(initialValue = null)
|
||||||
var previousSourceId by rememberSaveable { mutableStateOf<Long?>(null) }
|
var previousSourceId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||||
@@ -312,7 +319,8 @@ private fun MainNavigation() {
|
|||||||
nowPlayingLauncher.openNowPlaying.collect {
|
nowPlayingLauncher.openNowPlaying.collect {
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
contextMenuHost.dismissTransients()
|
contextMenuHost.dismissTransients()
|
||||||
infoSong = null
|
infoTarget = null
|
||||||
|
artistChooser = null
|
||||||
showingQueue = false
|
showingQueue = false
|
||||||
if (navController.currentDestination?.route in DETAIL_ROUTES) {
|
if (navController.currentDestination?.route in DETAIL_ROUTES) {
|
||||||
navController.popBackStack(Routes.LIBRARY, inclusive = false)
|
navController.popBackStack(Routes.LIBRARY, inclusive = false)
|
||||||
@@ -375,7 +383,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
navigateDetail(
|
||||||
|
Routes.ARTIST_DETAIL,
|
||||||
|
"artistId",
|
||||||
|
artist.id,
|
||||||
|
Routes.artist(
|
||||||
|
artist.id,
|
||||||
|
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onPlaylistClick = { playlist ->
|
onPlaylistClick = { playlist ->
|
||||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||||
@@ -418,7 +434,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
navigateDetail(
|
||||||
|
Routes.ARTIST_DETAIL,
|
||||||
|
"artistId",
|
||||||
|
artist.id,
|
||||||
|
Routes.artist(
|
||||||
|
artist.id,
|
||||||
|
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onPlaylistClick = { playlist ->
|
onPlaylistClick = { playlist ->
|
||||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||||
@@ -514,7 +538,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
navigateDetail(
|
||||||
|
Routes.ARTIST_DETAIL,
|
||||||
|
"artistId",
|
||||||
|
artist.id,
|
||||||
|
Routes.artist(
|
||||||
|
artist.id,
|
||||||
|
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||||
onViewArtist = { artistId ->
|
onViewArtist = { artistId ->
|
||||||
@@ -538,7 +570,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
navigateDetail(
|
||||||
|
Routes.ARTIST_DETAIL,
|
||||||
|
"artistId",
|
||||||
|
artist.id,
|
||||||
|
Routes.artist(
|
||||||
|
artist.id,
|
||||||
|
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||||
onViewArtist = { artistId ->
|
onViewArtist = { artistId ->
|
||||||
@@ -562,7 +602,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
navigateDetail(
|
||||||
|
Routes.ARTIST_DETAIL,
|
||||||
|
"artistId",
|
||||||
|
artist.id,
|
||||||
|
Routes.artist(
|
||||||
|
artist.id,
|
||||||
|
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||||
onViewArtist = { artistId ->
|
onViewArtist = { artistId ->
|
||||||
@@ -588,7 +636,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
navigateDetail(
|
||||||
|
Routes.ARTIST_DETAIL,
|
||||||
|
"artistId",
|
||||||
|
artist.id,
|
||||||
|
Routes.artist(
|
||||||
|
artist.id,
|
||||||
|
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onPlaylistClick = { playlist ->
|
onPlaylistClick = { playlist ->
|
||||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||||
@@ -725,23 +781,69 @@ private fun MainNavigation() {
|
|||||||
target = target,
|
target = target,
|
||||||
actions = contextMenuHost.actions,
|
actions = contextMenuHost.actions,
|
||||||
onDismiss = { contextMenuHost.dismiss() },
|
onDismiss = { contextMenuHost.dismiss() },
|
||||||
onInfo = { infoSong = it },
|
onInfo = { infoTarget = it },
|
||||||
|
onChooseArtists = { artistChooser = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
infoSong?.let { song ->
|
artistChooser?.let { artists ->
|
||||||
val genres by
|
ArtistChooserSheet(
|
||||||
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
artists = artists,
|
||||||
.collectAsStateWithLifecycle(initialValue = emptyList())
|
onArtistClick = { artist ->
|
||||||
SongInfoDialog(
|
artistChooser = null
|
||||||
song = song,
|
navigateDetail(
|
||||||
download = downloads[song.id],
|
Routes.ARTIST_DETAIL,
|
||||||
localFile = downloadRepository.localFile(song.id),
|
"artistId",
|
||||||
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
artist.id,
|
||||||
onDismiss = { infoSong = null },
|
Routes.artist(artist.id),
|
||||||
genres = genres,
|
)
|
||||||
|
},
|
||||||
|
onDismiss = { artistChooser = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
when (val target = infoTarget) {
|
||||||
|
is MenuTarget.Song -> {
|
||||||
|
val song = target.song
|
||||||
|
val genres by
|
||||||
|
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
|
SongInfoDialog(
|
||||||
|
song = song,
|
||||||
|
download = downloads[song.id],
|
||||||
|
localFile = downloadRepository.localFile(song.id),
|
||||||
|
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
||||||
|
onDismiss = { infoTarget = null },
|
||||||
|
genres = genres,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MenuTarget.Album -> {
|
||||||
|
AlbumInfoDialog(
|
||||||
|
album = target.album,
|
||||||
|
download = target.download,
|
||||||
|
onDismiss = { infoTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MenuTarget.Artist -> {
|
||||||
|
ArtistInfoDialog(
|
||||||
|
artist = target.artist,
|
||||||
|
onDismiss = { infoTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MenuTarget.Playlist -> {
|
||||||
|
PlaylistInfoDialog(
|
||||||
|
playlist = target.playlist,
|
||||||
|
onDismiss = { infoTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Outline
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
data class ArtistArt(
|
||||||
|
val image: CoverArtRef? = null,
|
||||||
|
val thumbnail: CoverArtRef? = null,
|
||||||
|
val covers: List<CoverArtRef> = emptyList(),
|
||||||
|
val fallback: ArtistArt? = null,
|
||||||
|
) {
|
||||||
|
val primary: CoverArtRef? get() = image ?: covers.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SourceRepository.artistArt(
|
||||||
|
artist: Artist,
|
||||||
|
forIcon: Boolean = true,
|
||||||
|
): ArtistArt =
|
||||||
|
resolveArtistArt(
|
||||||
|
artist = artist,
|
||||||
|
coverArt = this::coverArt,
|
||||||
|
artistImage = this::artistImage,
|
||||||
|
offline = offline.value,
|
||||||
|
forIcon = forIcon,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun resolveArtistArt(
|
||||||
|
artist: Artist,
|
||||||
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
artistImage: (String, String?) -> CoverArtRef?,
|
||||||
|
offline: Boolean,
|
||||||
|
forIcon: Boolean,
|
||||||
|
): ArtistArt {
|
||||||
|
val coverIds = if (offline) artist.downloadedCoverArts else artist.coverArts
|
||||||
|
val grid =
|
||||||
|
ArtistArt(
|
||||||
|
thumbnail = coverIds.singleOrNull()?.let { coverArt(it, true) },
|
||||||
|
covers = coverIds.take(4).mapNotNull { coverArt(it, forIcon || coverIds.size > 1) },
|
||||||
|
)
|
||||||
|
val own =
|
||||||
|
artist.coverArt?.let { coverId ->
|
||||||
|
coverArt(coverId, forIcon)?.let { image ->
|
||||||
|
ArtistArt(
|
||||||
|
image = image,
|
||||||
|
thumbnail = if (forIcon) image else coverArt(coverId, true),
|
||||||
|
fallback = grid,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return artist.artistImageUrl
|
||||||
|
?.let { artistImage(artist.id, it) }
|
||||||
|
?.let { ArtistArt(image = it, thumbnail = it, fallback = own ?: grid) }
|
||||||
|
?: own
|
||||||
|
?: grid
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ArtistIcon(
|
||||||
|
artist: Artist,
|
||||||
|
art: ArtistArt,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var shown by remember(artist.id, art) { mutableStateOf(art) }
|
||||||
|
val showFallback = {
|
||||||
|
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
shown.image != null -> {
|
||||||
|
CoverArt(
|
||||||
|
ref = shown.image,
|
||||||
|
name = artist.name,
|
||||||
|
modifier = modifier,
|
||||||
|
onError = showFallback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
shown.covers.size >= 2 -> {
|
||||||
|
ArtistArtGrid(covers = shown.covers, name = artist.name, modifier = modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
CoverArt(ref = shown.covers.firstOrNull(), name = artist.name, modifier = modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ArtistHeroArt(
|
||||||
|
art: ArtistArt,
|
||||||
|
name: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var shown by remember(art) { mutableStateOf(art) }
|
||||||
|
val showFallback = {
|
||||||
|
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
shown.image != null -> {
|
||||||
|
CoverArt(
|
||||||
|
ref = shown.image,
|
||||||
|
name = name,
|
||||||
|
thumbnailRef = shown.thumbnail,
|
||||||
|
showPlaceholder = false,
|
||||||
|
modifier = modifier,
|
||||||
|
onError = showFallback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
shown.covers.isNotEmpty() -> {
|
||||||
|
ArtistArtGrid(
|
||||||
|
covers = shown.covers,
|
||||||
|
name = name,
|
||||||
|
singleThumbnail = shown.thumbnail,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
CoverArt(ref = null, name = name, modifier = modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ArtistArtGrid(
|
||||||
|
covers: List<CoverArtRef>,
|
||||||
|
name: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
singleThumbnail: CoverArtRef? = null,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
covers.size >= 4 -> {
|
||||||
|
Column(modifier) {
|
||||||
|
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
covers.take(2).forEach { ref ->
|
||||||
|
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
covers.drop(2).take(2).forEach { ref ->
|
||||||
|
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
covers.size == 3 -> {
|
||||||
|
Box(modifier) {
|
||||||
|
covers.forEachIndexed { index, ref ->
|
||||||
|
CoverArt(
|
||||||
|
ref = ref,
|
||||||
|
name = name,
|
||||||
|
modifier = Modifier.matchParentSize().clip(YRegionShape(index)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
covers.size == 2 -> {
|
||||||
|
Row(modifier) {
|
||||||
|
covers.forEach { ref ->
|
||||||
|
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
covers.size == 1 -> {
|
||||||
|
CoverArt(
|
||||||
|
ref = covers.first(),
|
||||||
|
name = name,
|
||||||
|
thumbnailRef = singleThumbnail,
|
||||||
|
showPlaceholder = false,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class YRegionShape(
|
||||||
|
private val region: Int,
|
||||||
|
) : Shape {
|
||||||
|
override fun createOutline(
|
||||||
|
size: Size,
|
||||||
|
layoutDirection: LayoutDirection,
|
||||||
|
density: Density,
|
||||||
|
): Outline {
|
||||||
|
val center = Offset(size.width / 2f, size.height / 2f)
|
||||||
|
val left = rayPoint(size, 150.0)
|
||||||
|
val right = rayPoint(size, 30.0)
|
||||||
|
val top = Offset(size.width / 2f, 0f)
|
||||||
|
val path = Path()
|
||||||
|
when (region) {
|
||||||
|
0 -> {
|
||||||
|
path.moveTo(center.x, center.y)
|
||||||
|
path.lineTo(right.x, right.y)
|
||||||
|
addClockwise(path, size, right, left)
|
||||||
|
}
|
||||||
|
|
||||||
|
1 -> {
|
||||||
|
path.moveTo(center.x, center.y)
|
||||||
|
path.lineTo(left.x, left.y)
|
||||||
|
addClockwise(path, size, left, top)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
path.moveTo(center.x, center.y)
|
||||||
|
path.lineTo(top.x, top.y)
|
||||||
|
addClockwise(path, size, top, right)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path.lineTo(center.x, center.y)
|
||||||
|
path.close()
|
||||||
|
return Outline.Generic(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rayPoint(
|
||||||
|
size: Size,
|
||||||
|
angleDegrees: Double,
|
||||||
|
): Offset {
|
||||||
|
val cx = size.width / 2.0
|
||||||
|
val cy = size.height / 2.0
|
||||||
|
val radians = angleDegrees * PI / 180.0
|
||||||
|
val dx = cos(radians)
|
||||||
|
val dy = sin(radians)
|
||||||
|
val tx =
|
||||||
|
when {
|
||||||
|
dx > 0 -> (size.width - cx) / dx
|
||||||
|
dx < 0 -> -cx / dx
|
||||||
|
else -> Double.POSITIVE_INFINITY
|
||||||
|
}
|
||||||
|
val ty =
|
||||||
|
when {
|
||||||
|
dy > 0 -> (size.height - cy) / dy
|
||||||
|
dy < 0 -> -cy / dy
|
||||||
|
else -> Double.POSITIVE_INFINITY
|
||||||
|
}
|
||||||
|
val t = min(tx, ty)
|
||||||
|
return Offset((cx + dx * t).toFloat(), (cy + dy * t).toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addClockwise(
|
||||||
|
path: Path,
|
||||||
|
size: Size,
|
||||||
|
from: Offset,
|
||||||
|
to: Offset,
|
||||||
|
) {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val corners =
|
||||||
|
listOf(
|
||||||
|
Offset(0f, 0f),
|
||||||
|
Offset(w, 0f),
|
||||||
|
Offset(w, h),
|
||||||
|
Offset(0f, h),
|
||||||
|
)
|
||||||
|
val fromParam = perimeterParam(from, size)
|
||||||
|
val toParam = perimeterParam(to, size)
|
||||||
|
if (fromParam <= toParam) {
|
||||||
|
corners
|
||||||
|
.filter { perimeterParam(it, size) > fromParam && perimeterParam(it, size) < toParam }
|
||||||
|
.forEach { path.lineTo(it.x, it.y) }
|
||||||
|
} else {
|
||||||
|
corners
|
||||||
|
.filter { perimeterParam(it, size) > fromParam }
|
||||||
|
.forEach { path.lineTo(it.x, it.y) }
|
||||||
|
corners
|
||||||
|
.filter { perimeterParam(it, size) < toParam }
|
||||||
|
.forEach { path.lineTo(it.x, it.y) }
|
||||||
|
}
|
||||||
|
path.lineTo(to.x, to.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun perimeterParam(
|
||||||
|
point: Offset,
|
||||||
|
size: Size,
|
||||||
|
): Float {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
return when {
|
||||||
|
point.y <= 0f -> point.x
|
||||||
|
point.x >= w -> w + point.y
|
||||||
|
point.y >= h -> w + h + (w - point.x)
|
||||||
|
else -> 2f * w + h + (h - point.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Person
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
|
|
||||||
|
const val ARTIST_CHOOSER_TAG = "artistChooser"
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ArtistChooserSheet(
|
||||||
|
artists: List<ArtistRef>,
|
||||||
|
onArtistClick: (ArtistRef) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState,
|
||||||
|
dragHandle = null,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
modifier = Modifier.testTag(ARTIST_CHOOSER_TAG),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.artist_chooser_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 400.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
artists.forEach { artist ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(artist.name.ifBlank { stringResource(R.string.resources_filter_artist) }) },
|
||||||
|
leadingContent = { Icon(Icons.Rounded.Person, contentDescription = null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable { onArtistClick(artist) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,11 +85,16 @@ fun CoverArt(
|
|||||||
square: Boolean = true,
|
square: Boolean = true,
|
||||||
elevation: Dp = 0.dp,
|
elevation: Dp = 0.dp,
|
||||||
colorFilter: ColorFilter? = null,
|
colorFilter: ColorFilter? = null,
|
||||||
|
onError: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalPlatformContext.current
|
val context = LocalPlatformContext.current
|
||||||
var failed by remember(ref, thumbnailRef) { mutableStateOf(false) }
|
val refKey = ref?.cacheKey
|
||||||
var thumbnailLoaded by remember(ref, thumbnailRef) { mutableStateOf(false) }
|
val thumbnailKey = thumbnailRef?.cacheKey
|
||||||
var thumbnailRatio by remember(ref, thumbnailRef) { mutableStateOf(thumbnailRef?.cacheKey?.let(ArtworkRatioCache::get)) }
|
val hasThumbnailFallback = thumbnailRef != null && thumbnailRef != ref
|
||||||
|
var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||||
|
var thumbnailLoaded by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||||
|
var thumbnailFailed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||||
|
var thumbnailRatio by remember(refKey, thumbnailKey) { mutableStateOf(thumbnailKey?.let(ArtworkRatioCache::get)) }
|
||||||
val letterScheme =
|
val letterScheme =
|
||||||
if (showPlaceholder || (failed && !thumbnailLoaded)) {
|
if (showPlaceholder || (failed && !thumbnailLoaded)) {
|
||||||
remember(name) { artworkColorsFromSeed(placeholderSeed(name)).scheme }
|
remember(name) { artworkColorsFromSeed(placeholderSeed(name)).scheme }
|
||||||
@@ -102,7 +107,7 @@ fun CoverArt(
|
|||||||
.background(letterScheme?.primaryContainer ?: MaterialTheme.colorScheme.surfaceVariant)
|
.background(letterScheme?.primaryContainer ?: MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
|
||||||
if (square) {
|
if (square) {
|
||||||
val model = remember(ref, thumbnailRef) { ref?.let { imageRequest(context, it, crossfade = true) } }
|
val model = remember(refKey, thumbnailKey) { ref?.let { imageRequest(context, it, crossfade = true) } }
|
||||||
Box(modifier.then(frameModifier)) {
|
Box(modifier.then(frameModifier)) {
|
||||||
CoverArtContent(
|
CoverArtContent(
|
||||||
ref = ref,
|
ref = ref,
|
||||||
@@ -113,6 +118,10 @@ fun CoverArt(
|
|||||||
thumbnailLoaded = thumbnailLoaded,
|
thumbnailLoaded = thumbnailLoaded,
|
||||||
onThumbnailLoaded = { thumbnailLoaded = true },
|
onThumbnailLoaded = { thumbnailLoaded = true },
|
||||||
onThumbnailRatio = { thumbnailRatio = it },
|
onThumbnailRatio = { thumbnailRatio = it },
|
||||||
|
onThumbnailError = {
|
||||||
|
thumbnailFailed = true
|
||||||
|
if (failed) onError?.invoke()
|
||||||
|
},
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
colorFilter = colorFilter,
|
colorFilter = colorFilter,
|
||||||
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
||||||
@@ -129,7 +138,10 @@ fun CoverArt(
|
|||||||
.ratioOrNull()
|
.ratioOrNull()
|
||||||
?.let { ArtworkRatioCache.put(ref.cacheKey, it) }
|
?.let { ArtworkRatioCache.put(ref.cacheKey, it) }
|
||||||
},
|
},
|
||||||
onError = { failed = true },
|
onError = {
|
||||||
|
failed = true
|
||||||
|
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -147,7 +159,7 @@ fun CoverArt(
|
|||||||
val painter =
|
val painter =
|
||||||
rememberAsyncImagePainter(
|
rememberAsyncImagePainter(
|
||||||
model =
|
model =
|
||||||
remember(ref, thumbnailRef, target) {
|
remember(refKey, thumbnailKey, target) {
|
||||||
ref?.let {
|
ref?.let {
|
||||||
imageRequest(
|
imageRequest(
|
||||||
context,
|
context,
|
||||||
@@ -158,7 +170,10 @@ fun CoverArt(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError = { failed = true },
|
onError = {
|
||||||
|
failed = true
|
||||||
|
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
|
||||||
|
},
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
filterQuality = FilterQuality.High,
|
filterQuality = FilterQuality.High,
|
||||||
)
|
)
|
||||||
@@ -177,6 +192,10 @@ fun CoverArt(
|
|||||||
thumbnailLoaded = thumbnailLoaded,
|
thumbnailLoaded = thumbnailLoaded,
|
||||||
onThumbnailLoaded = { thumbnailLoaded = true },
|
onThumbnailLoaded = { thumbnailLoaded = true },
|
||||||
onThumbnailRatio = { thumbnailRatio = it },
|
onThumbnailRatio = { thumbnailRatio = it },
|
||||||
|
onThumbnailError = {
|
||||||
|
thumbnailFailed = true
|
||||||
|
if (failed) onError?.invoke()
|
||||||
|
},
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
colorFilter = colorFilter,
|
colorFilter = colorFilter,
|
||||||
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
||||||
@@ -206,6 +225,7 @@ private fun BoxScope.CoverArtContent(
|
|||||||
thumbnailLoaded: Boolean,
|
thumbnailLoaded: Boolean,
|
||||||
onThumbnailLoaded: () -> Unit,
|
onThumbnailLoaded: () -> Unit,
|
||||||
onThumbnailRatio: (Float) -> Unit,
|
onThumbnailRatio: (Float) -> Unit,
|
||||||
|
onThumbnailError: () -> Unit,
|
||||||
contentScale: ContentScale,
|
contentScale: ContentScale,
|
||||||
colorFilter: ColorFilter?,
|
colorFilter: ColorFilter?,
|
||||||
placeholderContentColor: Color?,
|
placeholderContentColor: Color?,
|
||||||
@@ -231,7 +251,7 @@ private fun BoxScope.CoverArtContent(
|
|||||||
}
|
}
|
||||||
if (thumbnailRef != null && thumbnailRef != ref) {
|
if (thumbnailRef != null && thumbnailRef != ref) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = remember(thumbnailRef) { imageRequest(context, thumbnailRef, crossfade = false) },
|
model = remember(thumbnailRef.cacheKey) { imageRequest(context, thumbnailRef, crossfade = false) },
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
colorFilter = colorFilter,
|
colorFilter = colorFilter,
|
||||||
@@ -244,6 +264,7 @@ private fun BoxScope.CoverArtContent(
|
|||||||
ArtworkRatioCache.put(thumbnailRef.cacheKey, it)
|
ArtworkRatioCache.put(thumbnailRef.cacheKey, it)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onError = { onThumbnailError() },
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,21 @@ import androidx.compose.material3.AlertDialog
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.DownloadList
|
import com.subtracks.data.model.DownloadList
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class PendingDownloadDelete(
|
data class PendingDownloadDelete(
|
||||||
val name: String,
|
val name: String,
|
||||||
@@ -18,6 +29,70 @@ data class PendingDownloadDelete(
|
|||||||
val bytes: Long,
|
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
|
@Composable
|
||||||
fun DeleteDownloadsDialog(
|
fun DeleteDownloadsDialog(
|
||||||
name: String,
|
name: String,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.statusBars
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -47,11 +48,17 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -77,6 +84,7 @@ import com.subtracks.ui.theme.ArtworkTheme
|
|||||||
import com.subtracks.ui.theme.HeroGradient
|
import com.subtracks.ui.theme.HeroGradient
|
||||||
import com.subtracks.ui.theme.baseArtworkColors
|
import com.subtracks.ui.theme.baseArtworkColors
|
||||||
import com.subtracks.ui.theme.heroBarColor
|
import com.subtracks.ui.theme.heroBarColor
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private const val FADE_DISTANCE_DP = 64
|
private const val FADE_DISTANCE_DP = 64
|
||||||
|
|
||||||
@@ -96,12 +104,14 @@ fun HeroDetailScaffold(
|
|||||||
header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit,
|
header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit,
|
||||||
content: LazyListScope.(rowModifier: Modifier) -> Unit,
|
content: LazyListScope.(rowModifier: Modifier) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
contentReady: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val effectiveArtwork = artwork ?: baseArtworkColors()
|
val effectiveArtwork = artwork ?: baseArtworkColors()
|
||||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||||
ArtworkTheme(effectiveArtwork) {
|
ArtworkTheme(effectiveArtwork) {
|
||||||
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberRestorableLazyListState(contentReady)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val screenHeightPx = with(density) { maxHeight.toPx() }
|
val screenHeightPx = with(density) { maxHeight.toPx() }
|
||||||
@@ -111,9 +121,9 @@ fun HeroDetailScaffold(
|
|||||||
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
||||||
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
|
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
|
||||||
|
|
||||||
var headerHeightPx by remember { mutableFloatStateOf(0f) }
|
var headerHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
var rowHeightPx by remember { mutableFloatStateOf(0f) }
|
var rowHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
var controlsTopPx by remember { mutableFloatStateOf(0f) }
|
var controlsTopPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
val scrollPx by remember {
|
val scrollPx by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val index = listState.firstVisibleItemIndex
|
val index = listState.firstVisibleItemIndex
|
||||||
@@ -195,13 +205,39 @@ fun HeroDetailScaffold(
|
|||||||
.clickable(
|
.clickable(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null,
|
indication = null,
|
||||||
) {},
|
onClickLabel = stringResource(R.string.scroll_to_top),
|
||||||
|
) { scope.launch { listState.animateScrollToItem(0) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberRestorableLazyListState(contentReady: Boolean): LazyListState {
|
||||||
|
val state = rememberLazyListState()
|
||||||
|
var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) }
|
||||||
|
var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) }
|
||||||
|
var pending by remember { mutableStateOf(index > 0 || offset > 0) }
|
||||||
|
LaunchedEffect(contentReady) {
|
||||||
|
if (contentReady && pending) {
|
||||||
|
val target = index.coerceAtMost((state.layoutInfo.totalItemsCount - 1).coerceAtLeast(0))
|
||||||
|
state.scrollToItem(target, offset)
|
||||||
|
pending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }
|
||||||
|
.collect { (currentIndex, currentOffset) ->
|
||||||
|
if (!pending) {
|
||||||
|
index = currentIndex
|
||||||
|
offset = currentOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HeroSubtitle(
|
private fun HeroSubtitle(
|
||||||
text: String,
|
text: String,
|
||||||
@@ -248,11 +284,12 @@ fun HeroHeader(
|
|||||||
onDownloadAction: (BulkDownloadAction) -> Unit,
|
onDownloadAction: (BulkDownloadAction) -> Unit,
|
||||||
onMore: () -> Unit,
|
onMore: () -> Unit,
|
||||||
topInset: Dp,
|
topInset: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
controlsModifier: Modifier = Modifier,
|
controlsModifier: Modifier = Modifier,
|
||||||
thumbnailRef: CoverArtRef? = null,
|
thumbnailRef: CoverArtRef? = null,
|
||||||
comment: String? = null,
|
comment: String? = null,
|
||||||
onSubtitleClick: (() -> Unit)? = null,
|
onSubtitleClick: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
nameMaxLines: Int = 2,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -274,7 +311,7 @@ fun HeroHeader(
|
|||||||
text = name,
|
text = name,
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
maxLines = 2,
|
maxLines = nameMaxLines,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
val subtitleIsTarget = onSubtitleClick != null && subtitle.isNotEmpty() && comment.isNullOrBlank()
|
val subtitleIsTarget = onSubtitleClick != null && subtitle.isNotEmpty() && comment.isNullOrBlank()
|
||||||
@@ -286,7 +323,11 @@ fun HeroHeader(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.heightIn(min = 32.dp)
|
.heightIn(min = 32.dp)
|
||||||
.clickable(onClickLabel = stringResource(R.string.open_artist)) { onSubtitleClick() },
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClickLabel = stringResource(R.string.open_artist),
|
||||||
|
) { onSubtitleClick() },
|
||||||
contentAlignment = Alignment.TopCenter,
|
contentAlignment = Alignment.TopCenter,
|
||||||
) {
|
) {
|
||||||
HeroSubtitle(subtitle, Modifier.padding(top = 4.dp))
|
HeroSubtitle(subtitle, Modifier.padding(top = 4.dp))
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
|
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
|
||||||
import androidx.compose.material.icons.rounded.Album
|
import androidx.compose.material.icons.rounded.Album
|
||||||
import androidx.compose.material.icons.rounded.Cancel
|
import androidx.compose.material.icons.rounded.Cancel
|
||||||
@@ -47,6 +51,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.BulkDownloadAction
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.DownloadList
|
import com.subtracks.data.model.DownloadList
|
||||||
@@ -77,7 +82,7 @@ sealed interface MenuTarget {
|
|||||||
) : MenuTarget {
|
) : MenuTarget {
|
||||||
override val title: String get() = song.title
|
override val title: String get() = song.title
|
||||||
|
|
||||||
override val subtitle: String? get() = song.artist
|
override val subtitle: String? get() = song.artistSubtitle()
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Album(
|
data class Album(
|
||||||
@@ -194,7 +199,8 @@ fun ItemContextMenu(
|
|||||||
target: MenuTarget,
|
target: MenuTarget,
|
||||||
actions: ItemActions,
|
actions: ItemActions,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onInfo: ((SongModel) -> Unit)? = null,
|
onInfo: ((MenuTarget) -> Unit)? = null,
|
||||||
|
onChooseArtists: ((List<ArtistRef>) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -207,6 +213,11 @@ fun ItemContextMenu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun replaceWith(after: () -> Unit) {
|
||||||
|
onDismiss()
|
||||||
|
after()
|
||||||
|
}
|
||||||
|
|
||||||
val queueRef = target.queueRef()
|
val queueRef = target.queueRef()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -226,76 +237,75 @@ fun ItemContextMenu(
|
|||||||
dragHandle = null,
|
dragHandle = null,
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
) {
|
) {
|
||||||
MenuHeader(target)
|
Column(
|
||||||
when (target) {
|
modifier =
|
||||||
is MenuTarget.Song -> {
|
Modifier
|
||||||
queueItems()
|
.fillMaxWidth()
|
||||||
DownloadItem(target.download, target.song, actions, ::dismiss)
|
.heightIn(max = MENU_MAX_HEIGHT)
|
||||||
StarItem(
|
.verticalScroll(rememberScrollState()),
|
||||||
current = target.song.starred,
|
) {
|
||||||
onSet = { starring -> actions.setStar(StarType.Song, target.song.id, starring) },
|
MenuHeader(target)
|
||||||
onDismiss = { dismiss() },
|
when (target) {
|
||||||
)
|
is MenuTarget.Song -> {
|
||||||
target.song.albumId?.let { albumId ->
|
queueItems()
|
||||||
actions.viewAlbum?.let { view ->
|
DownloadItem(target.download, target.song, actions, ::dismiss)
|
||||||
MenuItem(
|
StarItem(
|
||||||
Icons.Rounded.Album,
|
current = target.song.starred,
|
||||||
target.song.album?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_album),
|
onSet = { starring -> actions.setStar(StarType.Song, target.song.id, starring) },
|
||||||
) {
|
onDismiss = { dismiss() },
|
||||||
dismiss { view(albumId) }
|
)
|
||||||
|
target.song.albumId?.let { albumId ->
|
||||||
|
actions.viewAlbum?.let { view ->
|
||||||
|
MenuItem(
|
||||||
|
Icons.Rounded.Album,
|
||||||
|
target.song.album?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_album),
|
||||||
|
) {
|
||||||
|
dismiss { view(albumId) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
ArtistItems(target.song.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||||
target.song.artistId?.let { artistId ->
|
onInfo?.let { info ->
|
||||||
actions.viewArtist?.let { view ->
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
MenuItem(
|
|
||||||
Icons.Rounded.Person,
|
|
||||||
target.song.artist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
|
||||||
) {
|
|
||||||
dismiss { view(artistId) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onInfo?.let { info ->
|
|
||||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target.song) } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is MenuTarget.Album -> {
|
is MenuTarget.Album -> {
|
||||||
queueItems()
|
queueItems()
|
||||||
BulkDownloadItem(target, actions, ::dismiss)
|
BulkDownloadItem(target, actions, ::dismiss)
|
||||||
StarItem(
|
StarItem(
|
||||||
current = target.album.starred,
|
current = target.album.starred,
|
||||||
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
||||||
onDismiss = { dismiss() },
|
onDismiss = { dismiss() },
|
||||||
)
|
)
|
||||||
target.album.artistId?.let { artistId ->
|
ArtistItems(target.album.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||||
actions.viewArtist?.let { view ->
|
onInfo?.let { info ->
|
||||||
MenuItem(
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
Icons.Rounded.Person,
|
}
|
||||||
target.album.albumArtist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
}
|
||||||
) {
|
|
||||||
dismiss { view(artistId) }
|
is MenuTarget.Artist -> {
|
||||||
}
|
BulkDownloadItem(target, actions, ::dismiss)
|
||||||
|
StarItem(
|
||||||
|
current = target.artist.starred,
|
||||||
|
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
|
||||||
|
onDismiss = { dismiss() },
|
||||||
|
)
|
||||||
|
onInfo?.let { info ->
|
||||||
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is MenuTarget.Playlist -> {
|
||||||
|
queueItems()
|
||||||
|
BulkDownloadItem(target, actions, ::dismiss)
|
||||||
|
onInfo?.let { info ->
|
||||||
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
is MenuTarget.Artist -> {
|
|
||||||
BulkDownloadItem(target, actions, ::dismiss)
|
|
||||||
StarItem(
|
|
||||||
current = target.artist.starred,
|
|
||||||
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
|
|
||||||
onDismiss = { dismiss() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is MenuTarget.Playlist -> {
|
|
||||||
queueItems()
|
|
||||||
BulkDownloadItem(target, actions, ::dismiss)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +341,55 @@ private fun MenuHeader(target: MenuTarget) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ArtistItems(
|
||||||
|
artists: List<ArtistRef>,
|
||||||
|
actions: ItemActions,
|
||||||
|
onChooseArtists: ((List<ArtistRef>) -> Unit)?,
|
||||||
|
dismiss: (() -> Unit) -> Unit,
|
||||||
|
replaceWith: (() -> Unit) -> Unit,
|
||||||
|
) {
|
||||||
|
val view = actions.viewArtist ?: return
|
||||||
|
val distinct = artists.distinctBy { it.id }
|
||||||
|
when (distinct.size) {
|
||||||
|
0 -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
1 -> {
|
||||||
|
val artist = distinct.first()
|
||||||
|
MenuItem(
|
||||||
|
Icons.Rounded.Person,
|
||||||
|
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||||
|
) {
|
||||||
|
dismiss { view(artist.id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
if (onChooseArtists == null) {
|
||||||
|
distinct.forEach { artist ->
|
||||||
|
MenuItem(
|
||||||
|
Icons.Rounded.Person,
|
||||||
|
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||||
|
) {
|
||||||
|
dismiss { view(artist.id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val fallback = stringResource(R.string.resources_filter_artist)
|
||||||
|
MenuItem(
|
||||||
|
Icons.Rounded.Person,
|
||||||
|
distinct.joinToString(" \u00B7 ") { it.name.ifBlank { fallback } },
|
||||||
|
trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) },
|
||||||
|
) {
|
||||||
|
replaceWith { onChooseArtists(distinct) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DownloadItem(
|
private fun DownloadItem(
|
||||||
download: SongDownload?,
|
download: SongDownload?,
|
||||||
@@ -428,11 +487,19 @@ private fun MenuItem(
|
|||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
label: String,
|
label: String,
|
||||||
tint: Color? = null,
|
tint: Color? = null,
|
||||||
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(label) },
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
leadingContent = { Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) },
|
leadingContent = { Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) },
|
||||||
|
trailingContent = trailing,
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
)
|
)
|
||||||
@@ -445,3 +512,4 @@ private fun starLabel(starred: Long?): String =
|
|||||||
if (starred == null) stringResource(R.string.actions_star) else stringResource(R.string.actions_unstar)
|
if (starred == null) stringResource(R.string.actions_star) else stringResource(R.string.actions_unstar)
|
||||||
|
|
||||||
private const val STAR_DISMISS_DELAY_MS = 200L
|
private const val STAR_DISMISS_DELAY_MS = 200L
|
||||||
|
private val MENU_MAX_HEIGHT = 560.dp
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
|
import com.subtracks.data.model.Playlist
|
||||||
|
|
||||||
|
private const val ARTIST_SEPARATOR = " \u00B7 "
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AlbumInfoDialog(
|
||||||
|
album: Album,
|
||||||
|
download: ListDownloadStatus?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.info)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
InfoRow(stringResource(R.string.resources_sort_by_title), album.name)
|
||||||
|
InfoRow(stringResource(R.string.info_sort_title), album.sortName.takeIfDifferentFrom(album.name))
|
||||||
|
InfoRow(stringResource(R.string.info_album_artists), album.artistRefs.names())
|
||||||
|
InfoRow(stringResource(R.string.info_album_artist_sort), album.albumArtistSort.takeIfDifferentFrom(album.albumArtist))
|
||||||
|
InfoRow(stringResource(R.string.resources_sort_by_year), album.year?.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.resources_filter_genre), album.genre)
|
||||||
|
InfoRow(stringResource(R.string.info_songs), album.songCount.takeIf { it > 0 }?.toString())
|
||||||
|
download?.takeIf { it.total > 0 }?.let { status ->
|
||||||
|
InfoRow(
|
||||||
|
stringResource(R.string.status_downloaded),
|
||||||
|
stringResource(R.string.info_downloaded_count, status.downloaded, status.total),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
InfoRow(stringResource(R.string.info_added), album.created.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||||
|
InfoRow(stringResource(R.string.info_last_played), album.played?.let { formatDate(context, it) })
|
||||||
|
InfoRow(stringResource(R.string.info_plays), album.playCount.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.info_starred), if (album.starred != null) stringResource(R.string.info_yes) else null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ArtistInfoDialog(
|
||||||
|
artist: Artist,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.info)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
InfoRow(stringResource(R.string.resources_filter_artist), artist.name)
|
||||||
|
InfoRow(stringResource(R.string.info_sort_name), artist.sortName.takeIfDifferentFrom(artist.name))
|
||||||
|
InfoRow(stringResource(R.string.info_albums), artist.albumCount.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.info_songs), artist.songCount.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.info_plays), artist.playCount.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.info_last_played), artist.played?.let { formatDate(context, it) })
|
||||||
|
InfoRow(stringResource(R.string.info_starred), if (artist.starred != null) stringResource(R.string.info_yes) else null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlaylistInfoDialog(
|
||||||
|
playlist: Playlist,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.info)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
InfoRow(stringResource(R.string.resources_sort_by_title), playlist.name)
|
||||||
|
InfoRow(stringResource(R.string.info_owner), playlist.owner)
|
||||||
|
InfoRow(stringResource(R.string.info_shared), if (playlist.shared) stringResource(R.string.info_yes) else null)
|
||||||
|
InfoRow(stringResource(R.string.info_songs), playlist.songCount.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.info_length), playlist.duration.takeIf { it > 0 }?.let(DateUtils::formatElapsedTime))
|
||||||
|
InfoRow(stringResource(R.string.info_created), playlist.created.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||||
|
InfoRow(stringResource(R.string.info_updated), playlist.changed.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String?.takeIfDifferentFrom(regular: String?): String? =
|
||||||
|
this?.trim()?.takeIf { value -> value.isNotEmpty() && !value.equals(regular?.trim(), ignoreCase = true) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun InfoRow(
|
||||||
|
label: String,
|
||||||
|
value: String?,
|
||||||
|
) {
|
||||||
|
if (value.isNullOrBlank()) return
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.width(INFO_LABEL_WIDTH),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(INFO_LABEL_GAP))
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<ArtistRef>.names(): String? =
|
||||||
|
map { it.name }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
.joinToString(ARTIST_SEPARATOR)
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
private fun formatDate(
|
||||||
|
context: Context,
|
||||||
|
epochSeconds: Long,
|
||||||
|
): String =
|
||||||
|
DateUtils.formatDateTime(
|
||||||
|
context,
|
||||||
|
epochSeconds * 1000,
|
||||||
|
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val INFO_LABEL_WIDTH = 100.dp
|
||||||
|
private val INFO_LABEL_GAP = 12.dp
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Shuffle
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.subtracks.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ShuffleFab(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
visible: Boolean = true,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn() + scaleIn(),
|
||||||
|
exit = fadeOut() + scaleOut(),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
FloatingActionButton(onClick = onClick) {
|
||||||
|
Icon(Icons.Rounded.Shuffle, contentDescription = stringResource(R.string.controls_shuffle_play))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,10 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
@@ -28,6 +32,8 @@ import androidx.compose.ui.graphics.SolidColor
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
@@ -43,6 +49,12 @@ fun SearchField(
|
|||||||
) {
|
) {
|
||||||
val hint = stringResource(R.string.search_input_placeholder)
|
val hint = stringResource(R.string.search_input_placeholder)
|
||||||
val closeLabel = stringResource(R.string.close_search)
|
val closeLabel = stringResource(R.string.close_search)
|
||||||
|
var textFieldValue by remember { mutableStateOf(TextFieldValue(value, selection = TextRange(value.length))) }
|
||||||
|
LaunchedEffect(value) {
|
||||||
|
if (value != textFieldValue.text) {
|
||||||
|
textFieldValue = TextFieldValue(value, selection = TextRange(value.length))
|
||||||
|
}
|
||||||
|
}
|
||||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
@@ -62,8 +74,11 @@ fun SearchField(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = value,
|
value = textFieldValue,
|
||||||
onValueChange = onValueChange,
|
onValueChange = {
|
||||||
|
textFieldValue = it
|
||||||
|
onValueChange(it.text)
|
||||||
|
},
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
|
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
@@ -73,7 +88,7 @@ fun SearchField(
|
|||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
.semantics { contentDescription = hint },
|
.semantics { contentDescription = hint },
|
||||||
decorationBox = { inner ->
|
decorationBox = { inner ->
|
||||||
if (value.isEmpty()) {
|
if (textFieldValue.text.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = hint,
|
text = hint,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
|
||||||
|
private const val ARTIST_SEPARATOR = " \u00B7 "
|
||||||
|
|
||||||
|
internal fun Song.artistSubtitle(): String? = artistSubtitle(artist, artists)
|
||||||
|
|
||||||
|
internal fun Song.artistNames(): List<String> = artistNames(artist, artists)
|
||||||
|
|
||||||
|
internal fun artistNames(
|
||||||
|
artist: String?,
|
||||||
|
artists: List<ArtistRef>,
|
||||||
|
): List<String> {
|
||||||
|
val names = artists.map { it.name }.filter { it.isNotBlank() }.distinct()
|
||||||
|
return names.ifEmpty { listOfNotNull(artist?.takeIf { it.isNotBlank() }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun artistSubtitle(
|
||||||
|
artist: String?,
|
||||||
|
artists: List<ArtistRef>,
|
||||||
|
): String? {
|
||||||
|
val names = artistNames(artist, artists)
|
||||||
|
if (names.isEmpty()) return null
|
||||||
|
val primary = artist?.takeIf { it in names }
|
||||||
|
return buildList {
|
||||||
|
primary?.let(::add)
|
||||||
|
addAll(names.filterNot { it == primary })
|
||||||
|
}.joinToString(ARTIST_SEPARATOR)
|
||||||
|
}
|
||||||
@@ -4,12 +4,7 @@ import android.text.format.DateUtils
|
|||||||
import android.text.format.Formatter
|
import android.text.format.Formatter
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -18,8 +13,6 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -53,8 +46,20 @@ fun SongInfoDialog(
|
|||||||
title = { Text(stringResource(R.string.info)) },
|
title = { Text(stringResource(R.string.info)) },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
val multipleArtists = song.artistNames().size > 1
|
||||||
|
val regularArtistSort = song.artist?.takeIf { it.isNotBlank() } ?: song.artistRefs.firstOrNull()?.name
|
||||||
InfoRow(stringResource(R.string.resources_sort_by_title), song.title)
|
InfoRow(stringResource(R.string.resources_sort_by_title), song.title)
|
||||||
InfoRow(stringResource(R.string.resources_filter_artist), song.artist)
|
InfoRow(stringResource(R.string.info_sort_title), song.sortName.takeIfDifferentFrom(song.title))
|
||||||
|
InfoRow(
|
||||||
|
stringResource(
|
||||||
|
if (multipleArtists) R.string.info_artists else R.string.resources_filter_artist,
|
||||||
|
),
|
||||||
|
song.artistSubtitle(),
|
||||||
|
)
|
||||||
|
InfoRow(
|
||||||
|
stringResource(if (multipleArtists) R.string.info_artists_sort else R.string.info_artist_sort),
|
||||||
|
song.artistSort.takeIfDifferentFrom(regularArtistSort),
|
||||||
|
)
|
||||||
InfoRow(stringResource(R.string.resources_filter_album), song.album)
|
InfoRow(stringResource(R.string.resources_filter_album), song.album)
|
||||||
InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
|
InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
|
||||||
InfoRow(
|
InfoRow(
|
||||||
@@ -92,32 +97,6 @@ fun SongInfoDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun InfoRow(
|
|
||||||
label: String,
|
|
||||||
value: String?,
|
|
||||||
) {
|
|
||||||
if (value.isNullOrBlank()) return
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.width(INFO_LABEL_WIDTH),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = value,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val INFO_LABEL_WIDTH = 80.dp
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun downloadStatus(status: DownloadStatus): String =
|
private fun downloadStatus(status: DownloadStatus): String =
|
||||||
when (status) {
|
when (status) {
|
||||||
|
|||||||
@@ -88,3 +88,34 @@ fun rememberViewportFill(state: LazyGridState): Dp {
|
|||||||
}
|
}
|
||||||
return with(density) { fillPx.toDp() }
|
return with(density) { fillPx.toDp() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls to the top when [request] changes to a new non-null value, but not when it merely becomes
|
||||||
|
* non-null. Selecting a page passes it the current request; only a fresh request (a tab re-tap)
|
||||||
|
* should move the list, so a stale scroll position survives tab switches.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ScrollToTopOnRequest(
|
||||||
|
request: Any?,
|
||||||
|
state: LazyListState,
|
||||||
|
) {
|
||||||
|
var previous by remember { mutableStateOf(request) }
|
||||||
|
LaunchedEffect(request) {
|
||||||
|
val stale = previous
|
||||||
|
previous = request
|
||||||
|
if (request != null && stale != null && request != stale) state.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ScrollToTopOnRequest(
|
||||||
|
request: Any?,
|
||||||
|
state: LazyGridState,
|
||||||
|
) {
|
||||||
|
var previous by remember { mutableStateOf(request) }
|
||||||
|
LaunchedEffect(request) {
|
||||||
|
val stale = previous
|
||||||
|
previous = request
|
||||||
|
if (request != null && stale != null && request != stale) state.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.rounded.Close
|
import androidx.compose.material.icons.rounded.Close
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
import androidx.compose.material.icons.rounded.Delete
|
||||||
import androidx.compose.material.icons.rounded.ExpandLess
|
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.rounded.ExpandMore
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -274,7 +274,7 @@ private fun NodeRow(
|
|||||||
leadingContent = {
|
leadingContent = {
|
||||||
IconButton(onClick = onToggle) {
|
IconButton(onClick = onToggle) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
|
imageVector = if (expanded) Icons.Rounded.KeyboardArrowDown else Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||||
contentDescription = if (expanded) stringResource(R.string.collapse) else stringResource(R.string.expand),
|
contentDescription = if (expanded) stringResource(R.string.collapse) else stringResource(R.string.expand),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ data class DownloadArtistNode(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val bytes: Long,
|
val bytes: Long,
|
||||||
val albums: List<DownloadAlbumNode>,
|
val albums: List<DownloadAlbumNode>,
|
||||||
|
val sortName: String = name,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DownloadAlbumNode(
|
data class DownloadAlbumNode(
|
||||||
@@ -38,6 +39,7 @@ data class DownloadAlbumNode(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val bytes: Long,
|
val bytes: Long,
|
||||||
val songs: List<DownloadedSong>,
|
val songs: List<DownloadedSong>,
|
||||||
|
val sortName: String = name,
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@@ -105,6 +107,7 @@ internal fun buildTree(
|
|||||||
id = artistKey.first,
|
id = artistKey.first,
|
||||||
name = artistKey.second.ifBlank { unknownArtist },
|
name = artistKey.second.ifBlank { unknownArtist },
|
||||||
bytes = artistSongs.sumOf { it.size },
|
bytes = artistSongs.sumOf { it.size },
|
||||||
|
sortName = artistSongs.first().artistSort.ifBlank { artistKey.second },
|
||||||
albums =
|
albums =
|
||||||
artistSongs
|
artistSongs
|
||||||
.groupBy { it.albumId.orEmpty() to it.albumName.orEmpty() }
|
.groupBy { it.albumId.orEmpty() to it.albumName.orEmpty() }
|
||||||
@@ -113,9 +116,10 @@ internal fun buildTree(
|
|||||||
id = albumKey.first,
|
id = albumKey.first,
|
||||||
name = albumKey.second.ifBlank { unknownAlbum },
|
name = albumKey.second.ifBlank { unknownAlbum },
|
||||||
bytes = albumSongs.sumOf { it.size },
|
bytes = albumSongs.sumOf { it.size },
|
||||||
songs = albumSongs.sortedBy { it.title.lowercase() },
|
sortName = albumSongs.first().albumSort.ifBlank { albumKey.second },
|
||||||
|
songs = albumSongs,
|
||||||
)
|
)
|
||||||
}.sortedBy { it.name.lowercase() },
|
}.sortedBy { it.sortName.lowercase() },
|
||||||
)
|
)
|
||||||
}.sortedBy { it.name.lowercase() },
|
}.sortedBy { it.sortName.lowercase() },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -31,11 +33,12 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalResources
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
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.model.Song
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.ArtistIcon
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
|
import com.subtracks.ui.components.ShuffleFab
|
||||||
import com.subtracks.ui.library.PlaylistsContent
|
import com.subtracks.ui.library.PlaylistsContent
|
||||||
import com.subtracks.ui.library.SongRow
|
import com.subtracks.ui.library.SongRow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
@@ -103,6 +110,7 @@ fun HomeListRoute(
|
|||||||
genres = genres,
|
genres = genres,
|
||||||
decades = decades,
|
decades = decades,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
playingSongId = playback.item?.id,
|
playingSongId = playback.item?.id,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
@@ -110,6 +118,7 @@ fun HomeListRoute(
|
|||||||
onPlaylistClick = onPlaylistClick,
|
onPlaylistClick = onPlaylistClick,
|
||||||
onSongClick = viewModel::play,
|
onSongClick = viewModel::play,
|
||||||
onLongClick = { contextMenuHost?.show(it, actions) },
|
onLongClick = { contextMenuHost?.show(it, actions) },
|
||||||
|
onShuffle = if (viewModel.canShuffle) viewModel::shuffle else null,
|
||||||
onGenreClick = onGenreClick,
|
onGenreClick = onGenreClick,
|
||||||
onDecadeClick = onDecadeClick,
|
onDecadeClick = onDecadeClick,
|
||||||
)
|
)
|
||||||
@@ -123,9 +132,11 @@ fun HomeListScreen(
|
|||||||
artists: LazyPagingItems<Artist>,
|
artists: LazyPagingItems<Artist>,
|
||||||
songs: LazyPagingItems<AlbumSongItem>,
|
songs: LazyPagingItems<AlbumSongItem>,
|
||||||
playlists: LazyPagingItems<Playlist>,
|
playlists: LazyPagingItems<Playlist>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
genres: List<String> = emptyList(),
|
genres: List<String> = emptyList(),
|
||||||
decades: List<Long> = emptyList(),
|
decades: List<Long> = emptyList(),
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
playingSongId: String? = null,
|
playingSongId: String? = null,
|
||||||
onBack: () -> Unit = {},
|
onBack: () -> Unit = {},
|
||||||
onAlbumClick: (Album) -> Unit = {},
|
onAlbumClick: (Album) -> Unit = {},
|
||||||
@@ -133,12 +144,14 @@ fun HomeListScreen(
|
|||||||
onPlaylistClick: (Playlist) -> Unit = {},
|
onPlaylistClick: (Playlist) -> Unit = {},
|
||||||
onSongClick: (Song) -> Unit = {},
|
onSongClick: (Song) -> Unit = {},
|
||||||
onLongClick: (MenuTarget) -> Unit = {},
|
onLongClick: (MenuTarget) -> Unit = {},
|
||||||
|
onShuffle: (() -> Unit)? = null,
|
||||||
onGenreClick: (String) -> Unit = {},
|
onGenreClick: (String) -> Unit = {},
|
||||||
onDecadeClick: (Long) -> Unit = {},
|
onDecadeClick: (Long) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
) {
|
||||||
val now = remember { System.currentTimeMillis() }
|
val now = remember { System.currentTimeMillis() }
|
||||||
val resources = LocalContext.current.resources
|
val resources = LocalResources.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val songListState = rememberLazyListState()
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -149,8 +162,21 @@ fun HomeListScreen(
|
|||||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
modifier =
|
||||||
|
if (onShuffle != null) {
|
||||||
|
Modifier.clickable(onClickLabel = stringResource(R.string.scroll_to_top)) {
|
||||||
|
scope.launch { songListState.animateScrollToItem(0) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
if (onShuffle != null && songs.itemCount > 0) {
|
||||||
|
ShuffleFab(onClick = onShuffle)
|
||||||
|
}
|
||||||
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
val downloaded = request.downloaded
|
val downloaded = request.downloaded
|
||||||
if (downloaded != null) {
|
if (downloaded != null) {
|
||||||
@@ -169,7 +195,7 @@ fun HomeListScreen(
|
|||||||
OfflineListKind.Artists -> {
|
OfflineListKind.Artists -> {
|
||||||
ArtistsList(
|
ArtistsList(
|
||||||
artists = artists,
|
artists = artists,
|
||||||
coverArt = coverArt,
|
artistArt = artistArt,
|
||||||
info = { artistListInfo(request, it, now, resources) },
|
info = { artistListInfo(request, it, now, resources) },
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
@@ -184,6 +210,7 @@ fun HomeListScreen(
|
|||||||
playingSongId = playingSongId,
|
playingSongId = playingSongId,
|
||||||
onSongClick = onSongClick,
|
onSongClick = onSongClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
listState = songListState,
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -222,7 +249,7 @@ fun HomeListScreen(
|
|||||||
HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> {
|
HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> {
|
||||||
ArtistsList(
|
ArtistsList(
|
||||||
artists = artists,
|
artists = artists,
|
||||||
coverArt = coverArt,
|
artistArt = artistArt,
|
||||||
info = { artistListInfo(request, it, now, resources) },
|
info = { artistListInfo(request, it, now, resources) },
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
@@ -237,6 +264,7 @@ fun HomeListScreen(
|
|||||||
playingSongId = playingSongId,
|
playingSongId = playingSongId,
|
||||||
onSongClick = onSongClick,
|
onSongClick = onSongClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
listState = songListState,
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -254,6 +282,7 @@ fun HomeListScreen(
|
|||||||
playingSongId = playingSongId,
|
playingSongId = playingSongId,
|
||||||
onSongClick = onSongClick,
|
onSongClick = onSongClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
listState = songListState,
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -297,6 +326,7 @@ private fun SongsList(
|
|||||||
playingSongId: String?,
|
playingSongId: String?,
|
||||||
onSongClick: (Song) -> Unit,
|
onSongClick: (Song) -> Unit,
|
||||||
onLongClick: (MenuTarget) -> Unit,
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
|
listState: LazyListState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (songs.itemCount == 0 && songs.loadState.refresh is LoadState.Loading) {
|
if (songs.itemCount == 0 && songs.loadState.refresh is LoadState.Loading) {
|
||||||
@@ -304,8 +334,9 @@ private fun SongsList(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 24.dp),
|
contentPadding = PaddingValues(bottom = 88.dp),
|
||||||
) {
|
) {
|
||||||
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
|
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
|
||||||
val item = songs[index]
|
val item = songs[index]
|
||||||
@@ -416,7 +447,7 @@ internal fun AlbumListRow(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ArtistsList(
|
private fun ArtistsList(
|
||||||
artists: LazyPagingItems<Artist>,
|
artists: LazyPagingItems<Artist>,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
artistArt: (Artist) -> ArtistArt,
|
||||||
info: (Artist) -> String?,
|
info: (Artist) -> String?,
|
||||||
onArtistClick: (Artist) -> Unit,
|
onArtistClick: (Artist) -> Unit,
|
||||||
onLongClick: (MenuTarget) -> Unit,
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
@@ -433,12 +464,13 @@ private fun ArtistsList(
|
|||||||
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
||||||
val artist = artists[index]
|
val artist = artists[index]
|
||||||
if (artist != null) {
|
if (artist != null) {
|
||||||
|
val art = artistArt(artist)
|
||||||
ArtistListRow(
|
ArtistListRow(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
coverArt = coverArt,
|
art = art,
|
||||||
info = info(artist),
|
info = info(artist),
|
||||||
onClick = { onArtistClick(artist) },
|
onClick = { onArtistClick(artist) },
|
||||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
|
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -448,7 +480,7 @@ private fun ArtistsList(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun ArtistListRow(
|
internal fun ArtistListRow(
|
||||||
artist: Artist,
|
artist: Artist,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
art: ArtistArt,
|
||||||
info: String?,
|
info: String?,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
@@ -461,9 +493,9 @@ internal fun ArtistListRow(
|
|||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
CoverArt(
|
ArtistIcon(
|
||||||
ref = coverArt(artist.coverArt, true),
|
artist = artist,
|
||||||
name = artist.name,
|
art = art,
|
||||||
modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape),
|
modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
|
|||||||
@@ -16,14 +16,18 @@ import com.subtracks.data.prefs.PlaylistSort
|
|||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.artistArt
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class HomeListRequest(
|
data class HomeListRequest(
|
||||||
val title: String,
|
val title: String,
|
||||||
@@ -51,6 +55,11 @@ class HomeListViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
|
|
||||||
|
val canShuffle: Boolean =
|
||||||
|
request.genre != null ||
|
||||||
|
request.section == HomeSection.RecentlyStarredSongs ||
|
||||||
|
request.downloaded == OfflineListKind.Songs
|
||||||
|
|
||||||
val albums: Flow<PagingData<Album>> =
|
val albums: Flow<PagingData<Album>> =
|
||||||
sourceId
|
sourceId
|
||||||
.flatMapLatest { id ->
|
.flatMapLatest { id ->
|
||||||
@@ -149,6 +158,11 @@ class HomeListViewModel(
|
|||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
|
fun artistArt(
|
||||||
|
artist: Artist,
|
||||||
|
forIcon: Boolean = true,
|
||||||
|
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||||
|
|
||||||
fun play(song: Song) {
|
fun play(song: Song) {
|
||||||
val genre = request.genre
|
val genre = request.genre
|
||||||
when {
|
when {
|
||||||
@@ -173,4 +187,18 @@ class HomeListViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun shuffle() {
|
||||||
|
val genre = request.genre
|
||||||
|
val downloaded = request.downloaded
|
||||||
|
val starred = request.section == HomeSection.RecentlyStarredSongs
|
||||||
|
viewModelScope.launch {
|
||||||
|
val id = sourceId.first()
|
||||||
|
when {
|
||||||
|
genre != null -> playbackController.shuffleGenre(id, genre)
|
||||||
|
downloaded == OfflineListKind.Songs -> playbackController.shuffleDownloaded(id)
|
||||||
|
starred -> playbackController.shuffleStarred(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ import com.subtracks.data.model.Playlist
|
|||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.ArtistIcon
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
@@ -137,6 +139,7 @@ fun HomeRoute(
|
|||||||
playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(),
|
playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(),
|
||||||
songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value,
|
songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
playingSongId = playback.item?.id,
|
playingSongId = playback.item?.id,
|
||||||
topInset = topInset,
|
topInset = topInset,
|
||||||
bottomInset = bottomInset,
|
bottomInset = bottomInset,
|
||||||
@@ -152,6 +155,7 @@ fun HomeRoute(
|
|||||||
HomeScreen(
|
HomeScreen(
|
||||||
feed = feed,
|
feed = feed,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
playingSongId = playback.item?.id,
|
playingSongId = playback.item?.id,
|
||||||
topInset = topInset,
|
topInset = topInset,
|
||||||
bottomInset = bottomInset,
|
bottomInset = bottomInset,
|
||||||
@@ -172,6 +176,8 @@ fun HomeScreen(
|
|||||||
feed: HomeFeed,
|
feed: HomeFeed,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
playingSongId: String?,
|
playingSongId: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
topInset: Dp = 0.dp,
|
topInset: Dp = 0.dp,
|
||||||
bottomInset: Dp = 0.dp,
|
bottomInset: Dp = 0.dp,
|
||||||
onAlbumClick: (Album) -> Unit = {},
|
onAlbumClick: (Album) -> Unit = {},
|
||||||
@@ -182,7 +188,6 @@ fun HomeScreen(
|
|||||||
onGenreClick: (String) -> Unit = {},
|
onGenreClick: (String) -> Unit = {},
|
||||||
onDecadeClick: (Long) -> Unit = {},
|
onDecadeClick: (Long) -> Unit = {},
|
||||||
onSync: () -> Unit = {},
|
onSync: () -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||||
if (feed.loading) {
|
if (feed.loading) {
|
||||||
@@ -215,6 +220,7 @@ fun HomeScreen(
|
|||||||
HomeSection.RecentlyPlayedArtists,
|
HomeSection.RecentlyPlayedArtists,
|
||||||
feed.recentlyPlayedArtists,
|
feed.recentlyPlayedArtists,
|
||||||
coverArt,
|
coverArt,
|
||||||
|
artistArt,
|
||||||
onArtistClick,
|
onArtistClick,
|
||||||
onLongClick,
|
onLongClick,
|
||||||
onMore,
|
onMore,
|
||||||
@@ -249,6 +255,7 @@ fun HomeScreen(
|
|||||||
HomeSection.MostPlayedArtists,
|
HomeSection.MostPlayedArtists,
|
||||||
feed.mostPlayedArtists,
|
feed.mostPlayedArtists,
|
||||||
coverArt,
|
coverArt,
|
||||||
|
artistArt,
|
||||||
onArtistClick,
|
onArtistClick,
|
||||||
onLongClick,
|
onLongClick,
|
||||||
onMore,
|
onMore,
|
||||||
@@ -295,6 +302,8 @@ fun OfflineHomeScreen(
|
|||||||
songs: List<AlbumSongItem>,
|
songs: List<AlbumSongItem>,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
playingSongId: String?,
|
playingSongId: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
topInset: Dp = 0.dp,
|
topInset: Dp = 0.dp,
|
||||||
bottomInset: Dp = 0.dp,
|
bottomInset: Dp = 0.dp,
|
||||||
onAlbumClick: (Album) -> Unit = {},
|
onAlbumClick: (Album) -> Unit = {},
|
||||||
@@ -303,7 +312,6 @@ fun OfflineHomeScreen(
|
|||||||
onSongPlay: (Song) -> Unit = {},
|
onSongPlay: (Song) -> Unit = {},
|
||||||
onLongClick: (MenuTarget) -> Unit = {},
|
onLongClick: (MenuTarget) -> Unit = {},
|
||||||
onMore: (OfflineListKind) -> Unit = {},
|
onMore: (OfflineListKind) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@@ -312,7 +320,7 @@ fun OfflineHomeScreen(
|
|||||||
) {
|
) {
|
||||||
homeTitle()
|
homeTitle()
|
||||||
downloadedAlbumRow(albums, coverArt, onAlbumClick, onLongClick) { onMore(OfflineListKind.Albums) }
|
downloadedAlbumRow(albums, coverArt, onAlbumClick, onLongClick) { onMore(OfflineListKind.Albums) }
|
||||||
downloadedArtistRow(artists, coverArt, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
|
downloadedArtistRow(artists, artistArt, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
|
||||||
downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) }
|
downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) }
|
||||||
songList(
|
songList(
|
||||||
key = "downloaded-songs",
|
key = "downloaded-songs",
|
||||||
@@ -364,7 +372,7 @@ private fun LazyListScope.downloadedAlbumRow(
|
|||||||
|
|
||||||
private fun LazyListScope.downloadedArtistRow(
|
private fun LazyListScope.downloadedArtistRow(
|
||||||
artists: LazyPagingItems<Artist>,
|
artists: LazyPagingItems<Artist>,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
artistArt: (Artist) -> ArtistArt,
|
||||||
onArtistClick: (Artist) -> Unit,
|
onArtistClick: (Artist) -> Unit,
|
||||||
onLongClick: (MenuTarget) -> Unit,
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
onMore: () -> Unit,
|
onMore: () -> Unit,
|
||||||
@@ -382,12 +390,12 @@ private fun LazyListScope.downloadedArtistRow(
|
|||||||
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
||||||
val artist = artists[index]
|
val artist = artists[index]
|
||||||
if (artist != null) {
|
if (artist != null) {
|
||||||
val ref = coverArt(artist.coverArt, true)
|
val art = artistArt(artist)
|
||||||
ArtistTile(
|
ArtistTile(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
ref = ref,
|
art = art,
|
||||||
onClick = { onArtistClick(artist) },
|
onClick = { onArtistClick(artist) },
|
||||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
|
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -607,6 +615,7 @@ private fun LazyListScope.artistRow(
|
|||||||
section: HomeSection,
|
section: HomeSection,
|
||||||
artists: List<Artist>,
|
artists: List<Artist>,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
artistArt: (Artist) -> ArtistArt,
|
||||||
onArtistClick: (Artist) -> Unit,
|
onArtistClick: (Artist) -> Unit,
|
||||||
onLongClick: (MenuTarget) -> Unit,
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
onMore: (HomeSection) -> Unit,
|
onMore: (HomeSection) -> Unit,
|
||||||
@@ -625,12 +634,12 @@ private fun LazyListScope.artistRow(
|
|||||||
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
|
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
|
||||||
) {
|
) {
|
||||||
items(artists, key = { it.id }) { artist ->
|
items(artists, key = { it.id }) { artist ->
|
||||||
val ref = coverArt(artist.coverArt, true)
|
val art = artistArt(artist)
|
||||||
ArtistTile(
|
ArtistTile(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
ref = ref,
|
art = art,
|
||||||
onClick = { onArtistClick(artist) },
|
onClick = { onArtistClick(artist) },
|
||||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
|
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -779,7 +788,7 @@ private fun AlbumTile(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ArtistTile(
|
private fun ArtistTile(
|
||||||
artist: Artist,
|
artist: Artist,
|
||||||
ref: CoverArtRef?,
|
art: ArtistArt,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -787,9 +796,9 @@ private fun ArtistTile(
|
|||||||
modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
CoverArt(
|
ArtistIcon(
|
||||||
ref = ref,
|
artist = artist,
|
||||||
name = artist.name,
|
art = art,
|
||||||
modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape),
|
modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import com.subtracks.data.repo.rediscoverCutoff
|
|||||||
import com.subtracks.data.sync.SyncManager
|
import com.subtracks.data.sync.SyncManager
|
||||||
import com.subtracks.data.sync.SyncStatus
|
import com.subtracks.data.sync.SyncStatus
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.artistArt
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -25,7 +27,6 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -53,11 +54,6 @@ class HomeViewModel(
|
|||||||
private val syncManager: SyncManager,
|
private val syncManager: SyncManager,
|
||||||
private val playbackController: PlaybackController,
|
private val playbackController: PlaybackController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val syncing: StateFlow<Boolean> =
|
|
||||||
syncManager.status
|
|
||||||
.map { it == SyncStatus.Running }
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
|
||||||
|
|
||||||
val offline: StateFlow<Boolean> =
|
val offline: StateFlow<Boolean> =
|
||||||
sourceRepository.offline.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
sourceRepository.offline.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
@@ -133,6 +129,11 @@ class HomeViewModel(
|
|||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
|
fun artistArt(
|
||||||
|
artist: Artist,
|
||||||
|
forIcon: Boolean = true,
|
||||||
|
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||||
|
|
||||||
fun playStarred(song: Song) {
|
fun playStarred(song: Song) {
|
||||||
viewModelScope.playStarredList(libraryRepository, playbackController, song)
|
viewModelScope.playStarredList(libraryRepository, playbackController, song)
|
||||||
}
|
}
|
||||||
@@ -141,7 +142,9 @@ class HomeViewModel(
|
|||||||
viewModelScope.playDownloadedList(libraryRepository, playbackController, song)
|
viewModelScope.playDownloadedList(libraryRepository, playbackController, song)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sync() = syncManager.requestSync()
|
fun sync() {
|
||||||
|
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
||||||
|
}
|
||||||
|
|
||||||
private data class PlayRows(
|
private data class PlayRows(
|
||||||
val recentAlbums: List<Album>,
|
val recentAlbums: List<Album>,
|
||||||
@@ -217,7 +220,7 @@ internal fun CoroutineScope.playGenreList(
|
|||||||
song: Song,
|
song: Song,
|
||||||
) {
|
) {
|
||||||
launch {
|
launch {
|
||||||
val ordinal = libraryRepository.genreSongOrdinal(song.sourceId, genre, song.title, song.id)
|
val ordinal = libraryRepository.genreSongOrdinal(song.sourceId, genre, song.sortName, song.id)
|
||||||
playbackController.playGenre(song.sourceId, genre, ordinal)
|
playbackController.playGenre(song.sourceId, genre, ordinal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -30,24 +29,26 @@ import com.subtracks.data.model.Album
|
|||||||
import com.subtracks.data.model.BulkDownloadAction
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
|
import com.subtracks.data.model.DownloadList
|
||||||
import com.subtracks.data.model.ListDownloadStatus
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
import com.subtracks.data.model.QueueKind
|
import com.subtracks.data.model.QueueKind
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistChooserSheet
|
||||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
|
||||||
import com.subtracks.ui.components.DismissOnRequest
|
import com.subtracks.ui.components.DismissOnRequest
|
||||||
import com.subtracks.ui.components.HeroDetailScaffold
|
import com.subtracks.ui.components.HeroDetailScaffold
|
||||||
import com.subtracks.ui.components.HeroHeader
|
import com.subtracks.ui.components.HeroHeader
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
|
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
|
||||||
|
import com.subtracks.ui.components.rememberPendingDownloadDelete
|
||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
import com.subtracks.ui.theme.rememberArtworkColors
|
import com.subtracks.ui.theme.rememberArtworkColors
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
@@ -66,24 +67,23 @@ fun AlbumDetailRoute(
|
|||||||
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
|
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
|
||||||
playbackController: PlaybackController = koinInject(),
|
playbackController: PlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
|
val album by viewModel.album.collectAsStateWithLifecycle()
|
||||||
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
|
val songs by viewModel.songs.collectAsStateWithLifecycle()
|
||||||
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
|
val discs by viewModel.discs.collectAsStateWithLifecycle()
|
||||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||||
val scope = rememberCoroutineScope()
|
val dismissals = contextMenuHost?.dismissals ?: emptyFlow()
|
||||||
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
val pendingDelete = rememberPendingDownloadDelete(dismissals) { _, _ -> viewModel.downloadedBytes() }
|
||||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
var showArtistChooser by remember { mutableStateOf(false) }
|
||||||
|
DismissOnRequest(dismissals) { showArtistChooser = false }
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
val context = playback.context
|
val context = playback.context
|
||||||
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||||
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
|
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
|
||||||
if (action == BulkDownloadAction.Delete) {
|
pendingDelete.onAction(album?.name.orEmpty(), DownloadList.Album, albumId, action) { _, _, requested ->
|
||||||
scope.launch { pendingDelete = viewModel.downloadedBytes() }
|
viewModel.onDownloadAction(requested)
|
||||||
} else {
|
|
||||||
viewModel.onDownloadAction(action)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val actions =
|
val actions =
|
||||||
@@ -126,18 +126,28 @@ fun AlbumDetailRoute(
|
|||||||
},
|
},
|
||||||
onArtistClick =
|
onArtistClick =
|
||||||
album
|
album
|
||||||
?.takeIf { !it.albumArtist.isNullOrBlank() }
|
?.takeIf { it.artistRefs.isNotEmpty() }
|
||||||
?.let { it.artistId }
|
?.let {
|
||||||
?.let { id -> { onViewArtist(id) } },
|
{
|
||||||
|
val artists = it.artistRefs
|
||||||
|
if (artists.size == 1) onViewArtist(artists.first().id) else showArtistChooser = true
|
||||||
|
}
|
||||||
|
},
|
||||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
||||||
)
|
)
|
||||||
|
|
||||||
pendingDelete?.let { bytes ->
|
PendingDownloadDeleteConfirmation(pendingDelete) {
|
||||||
DeleteDownloadsDialog(
|
viewModel.onDownloadAction(BulkDownloadAction.Delete)
|
||||||
name = album?.name.orEmpty(),
|
}
|
||||||
bytes = bytes,
|
|
||||||
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
|
if (showArtistChooser) {
|
||||||
onDismiss = { pendingDelete = null },
|
ArtistChooserSheet(
|
||||||
|
artists = album?.artistRefs.orEmpty(),
|
||||||
|
onArtistClick = { artist ->
|
||||||
|
showArtistChooser = false
|
||||||
|
onViewArtist(artist.id)
|
||||||
|
},
|
||||||
|
onDismiss = { showArtistChooser = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,6 +156,7 @@ fun AlbumDetailRoute(
|
|||||||
fun AlbumDetailScreen(
|
fun AlbumDetailScreen(
|
||||||
album: Album?,
|
album: Album?,
|
||||||
songs: List<Song>,
|
songs: List<Song>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
discs: List<Disc> = emptyList(),
|
discs: List<Disc> = emptyList(),
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
artwork: ArtworkColors?,
|
artwork: ArtworkColors?,
|
||||||
@@ -160,7 +171,6 @@ fun AlbumDetailScreen(
|
|||||||
onMore: () -> Unit = {},
|
onMore: () -> Unit = {},
|
||||||
onArtistClick: (() -> Unit)? = null,
|
onArtistClick: (() -> Unit)? = null,
|
||||||
playingSongId: String? = null,
|
playingSongId: String? = null,
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
) {
|
||||||
val multiDisc = songs.map { it.disc ?: 1L }.distinct().size > 1
|
val multiDisc = songs.map { it.disc ?: 1L }.distinct().size > 1
|
||||||
val resources = LocalResources.current
|
val resources = LocalResources.current
|
||||||
@@ -195,6 +205,7 @@ fun AlbumDetailScreen(
|
|||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
title = album?.name.orEmpty(),
|
title = album?.name.orEmpty(),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
contentReady = songs.isNotEmpty(),
|
||||||
header = { controlsModifier, topInset ->
|
header = { controlsModifier, topInset ->
|
||||||
val artistName = album?.albumArtist?.takeIf { it.isNotBlank() }
|
val artistName = album?.albumArtist?.takeIf { it.isNotBlank() }
|
||||||
HeroHeader(
|
HeroHeader(
|
||||||
@@ -215,6 +226,7 @@ fun AlbumDetailScreen(
|
|||||||
onMore = onMore,
|
onMore = onMore,
|
||||||
topInset = topInset,
|
topInset = topInset,
|
||||||
controlsModifier = controlsModifier,
|
controlsModifier = controlsModifier,
|
||||||
|
nameMaxLines = Int.MAX_VALUE,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
content = { rowModifier ->
|
content = { rowModifier ->
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import com.subtracks.data.repo.LibraryRepository
|
|||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
@@ -35,13 +34,20 @@ class AlbumDetailViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
|
|
||||||
val album: Flow<Album?> = sourceId.flatMapLatest { libraryRepository.album(it, albumId) }
|
val album: StateFlow<Album?> =
|
||||||
|
sourceId
|
||||||
|
.flatMapLatest { libraryRepository.album(it, albumId) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
val songs: Flow<List<Song>> =
|
val songs: StateFlow<List<Song>> =
|
||||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||||
.flatMapLatest { (id, offline) -> libraryRepository.albumSongs(id, albumId, offline) }
|
.flatMapLatest { (id, offline) -> libraryRepository.albumSongs(id, albumId, offline) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
val discs: Flow<List<Disc>> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
val discs: StateFlow<List<Disc>> =
|
||||||
|
sourceId
|
||||||
|
.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
val downloads: StateFlow<Map<String, SongDownload>> = downloadRepository.states()
|
val downloads: StateFlow<Map<String, SongDownload>> = downloadRepository.states()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.subtracks.ui.library
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -15,6 +16,7 @@ import androidx.compose.foundation.lazy.grid.GridItemSpan
|
|||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -37,17 +39,21 @@ import com.subtracks.ui.components.ListDownloadIndicator
|
|||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.ResetScrollOnChange
|
import com.subtracks.ui.components.ResetScrollOnChange
|
||||||
|
import com.subtracks.ui.components.ScrollToTopOnRequest
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
||||||
|
|
||||||
const val ALBUM_COVER_TAG = "album-cover"
|
const val ALBUM_COVER_TAG = "album-cover"
|
||||||
|
|
||||||
|
const val ALBUM_PLACEHOLDER_TAG = "album-placeholder"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AlbumsContent(
|
fun AlbumsContent(
|
||||||
items: LazyPagingItems<Album>,
|
items: LazyPagingItems<Album>,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
bottomInset: Dp,
|
bottomInset: Dp,
|
||||||
onAlbumClick: (Album) -> Unit,
|
onAlbumClick: (Album) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
onLongClick: (MenuTarget) -> Unit = {},
|
onLongClick: (MenuTarget) -> Unit = {},
|
||||||
filtered: Boolean = false,
|
filtered: Boolean = false,
|
||||||
onClearFilters: () -> Unit = {},
|
onClearFilters: () -> Unit = {},
|
||||||
@@ -55,7 +61,7 @@ fun AlbumsContent(
|
|||||||
topInset: Dp = 0.dp,
|
topInset: Dp = 0.dp,
|
||||||
onSync: () -> Unit = {},
|
onSync: () -> Unit = {},
|
||||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||||
modifier: Modifier = Modifier,
|
scrollToTopKey: Any? = null,
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||||
@@ -78,6 +84,7 @@ fun AlbumsContent(
|
|||||||
else -> {
|
else -> {
|
||||||
val gridState = rememberLazyGridState()
|
val gridState = rememberLazyGridState()
|
||||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { gridState.scrollToItem(0) }
|
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { gridState.scrollToItem(0) }
|
||||||
|
ScrollToTopOnRequest(scrollToTopKey, gridState)
|
||||||
val fill = rememberViewportFill(gridState)
|
val fill = rememberViewportFill(gridState)
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
state = gridState,
|
state = gridState,
|
||||||
@@ -114,6 +121,16 @@ fun AlbumsContent(
|
|||||||
scrim = true,
|
scrim = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.clip(RoundedCornerShape(2.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.testTag(ALBUM_PLACEHOLDER_TAG),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
|||||||
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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.subtracks.data.model.Album
|
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistAlbums
|
||||||
|
import com.subtracks.data.model.ArtistBio
|
||||||
import com.subtracks.data.model.BulkDownloadAction
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.DownloadList
|
import com.subtracks.data.model.DownloadList
|
||||||
@@ -12,9 +13,10 @@ import com.subtracks.data.repo.DownloadRepository
|
|||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.artistArt
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -36,21 +38,26 @@ class ArtistDetailViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
|
|
||||||
val artist: Flow<Artist?> = sourceId.flatMapLatest { libraryRepository.artist(it, artistId) }
|
val artist: StateFlow<Artist?> =
|
||||||
|
sourceId
|
||||||
|
.flatMapLatest { libraryRepository.artist(it, artistId) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
val artistName: Flow<String?> =
|
val artistName: StateFlow<String?> =
|
||||||
sourceId.flatMapLatest { id ->
|
sourceId
|
||||||
combine(
|
.flatMapLatest { id ->
|
||||||
libraryRepository.artist(id, artistId),
|
combine(
|
||||||
libraryRepository.artistName(id, artistId),
|
libraryRepository.artist(id, artistId),
|
||||||
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
|
libraryRepository.artistName(id, artistId),
|
||||||
}
|
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||||
|
|
||||||
val albums: Flow<List<Album>> =
|
val albums: StateFlow<ArtistAlbums?> =
|
||||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||||
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
|
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
val downloadStatus: StateFlow<ListDownloadStatus> =
|
val downloadStatus: StateFlow<ListDownloadStatus> =
|
||||||
sourceId
|
sourceId
|
||||||
@@ -62,6 +69,55 @@ class ArtistDetailViewModel(
|
|||||||
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
|
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||||
|
|
||||||
|
val artistBio: StateFlow<ArtistBio?> =
|
||||||
|
sourceId
|
||||||
|
.flatMapLatest { libraryRepository.artistBio(it, artistId) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
|
private val _bioLoading = MutableStateFlow(true)
|
||||||
|
val bioLoading: StateFlow<Boolean> = _bioLoading
|
||||||
|
|
||||||
|
private val _bioUnavailable = MutableStateFlow(false)
|
||||||
|
val bioUnavailable: StateFlow<Boolean> = _bioUnavailable
|
||||||
|
|
||||||
|
private val _bioExpanded = MutableStateFlow(false)
|
||||||
|
val bioExpanded: StateFlow<Boolean> = _bioExpanded
|
||||||
|
|
||||||
|
private var bioRefresh: Job? = null
|
||||||
|
|
||||||
|
fun refreshBio() {
|
||||||
|
if (bioRefresh?.isActive == true) return
|
||||||
|
bioRefresh =
|
||||||
|
viewModelScope.launch {
|
||||||
|
_bioUnavailable.value = false
|
||||||
|
if (sourceRepository.offline.value) {
|
||||||
|
_bioLoading.value = false
|
||||||
|
_bioUnavailable.value = true
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val id = sourceRepository.activeSourceIdOnce()
|
||||||
|
if (id == null) {
|
||||||
|
_bioLoading.value = false
|
||||||
|
_bioUnavailable.value = true
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
_bioLoading.value = true
|
||||||
|
try {
|
||||||
|
_bioUnavailable.value = libraryRepository.refreshArtistBio(id, artistId).isFailure
|
||||||
|
} finally {
|
||||||
|
_bioLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun expandBio() {
|
||||||
|
_bioExpanded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun collapseBio() {
|
||||||
|
_bioExpanded.value = false
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun downloadedBytes(
|
suspend fun downloadedBytes(
|
||||||
list: DownloadList,
|
list: DownloadList,
|
||||||
refId: String,
|
refId: String,
|
||||||
@@ -84,30 +140,36 @@ class ArtistDetailViewModel(
|
|||||||
private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null)
|
private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null)
|
||||||
val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail
|
val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail
|
||||||
|
|
||||||
|
private val _heroArt = MutableStateFlow<ArtistArt?>(null)
|
||||||
|
val heroArt: StateFlow<ArtistArt?> = _heroArt
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
artist
|
sourceRepository.activeSource
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.collectLatest { loaded ->
|
.flatMapLatest { sourceId ->
|
||||||
val (full, thumbnail) = resolveArt(loaded)
|
combine(
|
||||||
_art.value = full
|
libraryRepository.artist(sourceId, artistId),
|
||||||
_artThumbnail.value = thumbnail
|
libraryRepository.artistCoverArts(sourceId, artistId),
|
||||||
|
sourceRepository.offline,
|
||||||
|
) { loaded, covers, offline -> ArtistArtInput(loaded, artistId, offline, sourceId, covers) }
|
||||||
|
}.collectLatest { input ->
|
||||||
|
val resolved = resolveArtistArtInput(input)
|
||||||
|
if (resolved == null) {
|
||||||
|
_art.value = null
|
||||||
|
_artThumbnail.value = null
|
||||||
|
_heroArt.value = null
|
||||||
|
return@collectLatest
|
||||||
|
}
|
||||||
|
val hero = sourceRepository.artistArt(resolved, forIcon = false)
|
||||||
|
val icon = sourceRepository.artistArt(resolved, forIcon = true)
|
||||||
|
_art.value = hero.image ?: hero.covers.firstOrNull()
|
||||||
|
_artThumbnail.value = icon.image ?: icon.covers.firstOrNull()
|
||||||
|
_heroArt.value = hero
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun resolveArt(artist: Artist): Pair<CoverArtRef?, CoverArtRef?> {
|
|
||||||
if (artist.coverArt == null) return null to null
|
|
||||||
repeat(3) { attempt ->
|
|
||||||
val full = sourceRepository.coverArt(artist.coverArt)
|
|
||||||
if (full != null) {
|
|
||||||
return full to sourceRepository.coverArt(artist.coverArt, thumbnail = true)
|
|
||||||
}
|
|
||||||
if (attempt < 2) delay(300)
|
|
||||||
}
|
|
||||||
return null to null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun coverArt(
|
fun coverArt(
|
||||||
coverArt: String?,
|
coverArt: String?,
|
||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
@@ -119,3 +181,35 @@ class ArtistDetailViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal data class ArtistArtInput(
|
||||||
|
val artist: Artist?,
|
||||||
|
val artistId: String,
|
||||||
|
val offline: Boolean,
|
||||||
|
val sourceId: Long,
|
||||||
|
val covers: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun resolveArtistArtInput(input: ArtistArtInput): Artist? {
|
||||||
|
val artist = input.artist
|
||||||
|
if (input.covers.isEmpty()) return artist
|
||||||
|
if (artist == null) {
|
||||||
|
return Artist(
|
||||||
|
sourceId = input.sourceId,
|
||||||
|
id = input.artistId,
|
||||||
|
name = "",
|
||||||
|
albumCount = 0,
|
||||||
|
starred = null,
|
||||||
|
coverArts = input.covers,
|
||||||
|
downloadedCoverArts = if (input.offline) input.covers else emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return if (input.offline) {
|
||||||
|
artist.copy(
|
||||||
|
coverArts = input.covers,
|
||||||
|
downloadedCoverArts = artist.downloadedCoverArts.ifEmpty { input.covers },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
artist.copy(coverArts = input.covers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,13 +29,15 @@ import com.subtracks.R
|
|||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.ListDownloadStatus
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.ArtistIcon
|
||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
import com.subtracks.ui.components.FilteredEmptyState
|
import com.subtracks.ui.components.FilteredEmptyState
|
||||||
import com.subtracks.ui.components.ListDownloadIndicator
|
import com.subtracks.ui.components.ListDownloadIndicator
|
||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.ResetScrollOnChange
|
import com.subtracks.ui.components.ResetScrollOnChange
|
||||||
|
import com.subtracks.ui.components.ScrollToTopOnRequest
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@ fun ArtistsContent(
|
|||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
bottomInset: Dp,
|
bottomInset: Dp,
|
||||||
onArtistClick: (Artist) -> Unit,
|
onArtistClick: (Artist) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
onLongClick: (MenuTarget) -> Unit = {},
|
onLongClick: (MenuTarget) -> Unit = {},
|
||||||
filtered: Boolean = false,
|
filtered: Boolean = false,
|
||||||
onClearFilters: () -> Unit = {},
|
onClearFilters: () -> Unit = {},
|
||||||
@@ -52,7 +55,8 @@ fun ArtistsContent(
|
|||||||
topInset: Dp = 0.dp,
|
topInset: Dp = 0.dp,
|
||||||
onSync: () -> Unit = {},
|
onSync: () -> Unit = {},
|
||||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||||
modifier: Modifier = Modifier,
|
scrollToTopKey: Any? = null,
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||||
@@ -70,6 +74,7 @@ fun ArtistsContent(
|
|||||||
else -> {
|
else -> {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
||||||
|
ScrollToTopOnRequest(scrollToTopKey, listState)
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
@@ -79,7 +84,8 @@ fun ArtistsContent(
|
|||||||
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||||
val artist = items[index]
|
val artist = items[index]
|
||||||
if (artist != null) {
|
if (artist != null) {
|
||||||
PrefetchArtworkSeeds(coverArt(artist.coverArt, true))
|
val art = artistArt(artist)
|
||||||
|
PrefetchArtworkSeeds(art.primary)
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text(
|
Text(
|
||||||
@@ -101,9 +107,9 @@ fun ArtistsContent(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
CoverArt(
|
ArtistIcon(
|
||||||
ref = coverArt(artist.coverArt, true),
|
artist = artist,
|
||||||
name = artist.name,
|
art = art,
|
||||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -114,7 +120,7 @@ fun ArtistsContent(
|
|||||||
Modifier.combinedClickable(
|
Modifier.combinedClickable(
|
||||||
onClick = { onArtistClick(artist) },
|
onClick = { onArtistClick(artist) },
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true), downloadStatuses[artist.id]))
|
onLongClick(MenuTarget.Artist(artist, art.primary, downloadStatuses[artist.id]))
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.subtracks.ui.library
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -24,6 +26,7 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.PagerState
|
import androidx.compose.foundation.pager.PagerState
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -40,7 +43,9 @@ import androidx.compose.material.icons.rounded.Person
|
|||||||
import androidx.compose.material.icons.rounded.Remove
|
import androidx.compose.material.icons.rounded.Remove
|
||||||
import androidx.compose.material.icons.rounded.Search
|
import androidx.compose.material.icons.rounded.Search
|
||||||
import androidx.compose.material.icons.rounded.Settings
|
import androidx.compose.material.icons.rounded.Settings
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.FilterChipDefaults
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
@@ -63,13 +68,11 @@ import androidx.compose.runtime.mutableIntStateOf
|
|||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
@@ -91,12 +94,15 @@ import androidx.compose.ui.layout.layout
|
|||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.Constraints
|
import androidx.compose.ui.unit.Constraints
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
@@ -117,14 +123,14 @@ import com.subtracks.data.prefs.PlaylistSort
|
|||||||
import com.subtracks.data.prefs.StarredFilter
|
import com.subtracks.data.prefs.StarredFilter
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
|
||||||
import com.subtracks.ui.components.DismissOnRequest
|
import com.subtracks.ui.components.DismissOnRequest
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.PendingDownloadDelete
|
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
|
||||||
import com.subtracks.ui.components.SearchField
|
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.components.statusBarScrim
|
||||||
import com.subtracks.ui.home.HomeRoute
|
import com.subtracks.ui.home.HomeRoute
|
||||||
import com.subtracks.ui.home.HomeSection
|
import com.subtracks.ui.home.HomeSection
|
||||||
@@ -135,7 +141,6 @@ import com.subtracks.ui.theme.playerSurfaceColor
|
|||||||
import com.subtracks.ui.theme.rememberArtworkColors
|
import com.subtracks.ui.theme.rememberArtworkColors
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
@@ -243,6 +248,7 @@ fun LibraryRoute(
|
|||||||
var previousTabName by remember { mutableStateOf(selectedTab.name) }
|
var previousTabName by remember { mutableStateOf(selectedTab.name) }
|
||||||
val previousTab = LibraryTab.entries.firstOrNull { it.name == previousTabName } ?: LibraryTab.Albums
|
val previousTab = LibraryTab.entries.firstOrNull { it.name == previousTabName } ?: LibraryTab.Albums
|
||||||
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
||||||
|
val refreshing by viewModel.refreshing.collectAsStateWithLifecycle()
|
||||||
val listTab = selectedTab.listTab()
|
val listTab = selectedTab.listTab()
|
||||||
LaunchedEffect(selectedTab) {
|
LaunchedEffect(selectedTab) {
|
||||||
if (previousTab != selectedTab) {
|
if (previousTab != selectedTab) {
|
||||||
@@ -269,25 +275,13 @@ fun LibraryRoute(
|
|||||||
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true), fallbackName = playback.item?.title)
|
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true), fallbackName = playback.item?.title)
|
||||||
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
|
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
|
||||||
val downloads = downloadsState?.value ?: emptyMap()
|
val downloads = downloadsState?.value ?: emptyMap()
|
||||||
val scope = rememberCoroutineScope()
|
val focusManager = LocalFocusManager.current
|
||||||
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
|
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow(), viewModel::downloadedBytes)
|
||||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
|
||||||
val itemActions =
|
val itemActions =
|
||||||
ItemActions(
|
ItemActions(
|
||||||
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
||||||
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
||||||
bulkDownload = { target, action ->
|
bulkDownload = { target, action -> pendingDelete.onAction(target, action, viewModel::onDownloadAction) },
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setStar = setStar,
|
setStar = setStar,
|
||||||
viewAlbum = onViewAlbum,
|
viewAlbum = onViewAlbum,
|
||||||
viewArtist = onViewArtist,
|
viewArtist = onViewArtist,
|
||||||
@@ -296,19 +290,24 @@ fun LibraryRoute(
|
|||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
onTabSelected = { tab -> if (viewModel.selectedTab.value != tab) viewModel.selectTab(tab) },
|
onTabSelected = { tab -> if (viewModel.selectedTab.value != tab) viewModel.selectTab(tab) },
|
||||||
syncing = syncing,
|
syncing = syncing,
|
||||||
|
refreshing = refreshing,
|
||||||
bottomInset = bottomInset,
|
bottomInset = bottomInset,
|
||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
albums = viewModel.albums,
|
albums = viewModel.albums,
|
||||||
artists = viewModel.artists,
|
artists = viewModel.artists,
|
||||||
playlists = viewModel.playlists,
|
playlists = viewModel.playlists,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onPlaylistClick = onPlaylistClick,
|
onPlaylistClick = onPlaylistClick,
|
||||||
onSync = viewModel::sync,
|
onSync = viewModel::sync,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onItemLongClick = { contextMenuHost?.show(it, itemActions) },
|
onItemLongClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
contextMenuHost?.show(it, itemActions)
|
||||||
|
},
|
||||||
listQuery = displayQuery,
|
listQuery = displayQuery,
|
||||||
resetKeys = resetKeys,
|
resetKeys = resetKeys,
|
||||||
offline = offline,
|
offline = offline,
|
||||||
@@ -362,13 +361,8 @@ fun LibraryRoute(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
pendingDelete?.let { pending ->
|
PendingDownloadDeleteConfirmation(pendingDelete) { pending ->
|
||||||
DeleteDownloadsDialog(
|
viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete)
|
||||||
name = pending.name,
|
|
||||||
bytes = pending.bytes,
|
|
||||||
onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) },
|
|
||||||
onDismiss = { pendingDelete = null },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,11 +380,14 @@ fun LibraryScreen(
|
|||||||
onPlaylistClick: (Playlist) -> Unit,
|
onPlaylistClick: (Playlist) -> Unit,
|
||||||
onSync: () -> Unit,
|
onSync: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
onOpenSearch: () -> Unit = {},
|
onOpenSearch: () -> Unit = {},
|
||||||
offline: Boolean = false,
|
offline: Boolean = false,
|
||||||
onExitOffline: () -> Unit = {},
|
onExitOffline: () -> Unit = {},
|
||||||
onItemLongClick: (MenuTarget) -> Unit = {},
|
onItemLongClick: (MenuTarget) -> Unit = {},
|
||||||
syncing: Boolean = false,
|
syncing: Boolean = false,
|
||||||
|
refreshing: Boolean = false,
|
||||||
bottomInset: Dp = 0.dp,
|
bottomInset: Dp = 0.dp,
|
||||||
listQuery: ListQuery = ListQuery(""),
|
listQuery: ListQuery = ListQuery(""),
|
||||||
resetKeys: Map<LibraryTab, Any?> = emptyMap(),
|
resetKeys: Map<LibraryTab, Any?> = emptyMap(),
|
||||||
@@ -409,7 +406,6 @@ fun LibraryScreen(
|
|||||||
playlistDownloads: Map<String, ListDownloadStatus> = emptyMap(),
|
playlistDownloads: Map<String, ListDownloadStatus> = emptyMap(),
|
||||||
dismissals: Flow<Unit> = emptyFlow(),
|
dismissals: Flow<Unit> = emptyFlow(),
|
||||||
homeContent: @Composable (topInset: Dp, bottomInset: Dp) -> Unit = { _, _ -> },
|
homeContent: @Composable (topInset: Dp, bottomInset: Dp) -> Unit = { _, _ -> },
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
) {
|
||||||
val pagerState =
|
val pagerState =
|
||||||
rememberPagerState(
|
rememberPagerState(
|
||||||
@@ -418,10 +414,16 @@ fun LibraryScreen(
|
|||||||
)
|
)
|
||||||
var showOptions by rememberSaveable { mutableStateOf(false) }
|
var showOptions by rememberSaveable { mutableStateOf(false) }
|
||||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var topRequest by remember { mutableIntStateOf(0) }
|
||||||
val searchFocus = remember { FocusRequester() }
|
val searchFocus = remember { FocusRequester() }
|
||||||
|
val closeSearch = {
|
||||||
|
searchActive = false
|
||||||
|
onSearchChange("")
|
||||||
|
}
|
||||||
|
|
||||||
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
|
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
|
||||||
DismissOnRequest(dismissals) { showOptions = false }
|
DismissOnRequest(dismissals) { showOptions = false }
|
||||||
|
BackHandler(enabled = searchActive, onBack = closeSearch)
|
||||||
|
|
||||||
LaunchedEffect(pagerState) {
|
LaunchedEffect(pagerState) {
|
||||||
snapshotFlow { pagerState.settledPage }.collect { page ->
|
snapshotFlow { pagerState.settledPage }.collect { page ->
|
||||||
@@ -440,14 +442,8 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismissSearch() {
|
|
||||||
if (searchActive) {
|
|
||||||
searchActive = false
|
|
||||||
onSearchChange("")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||||
val filtersActive = listQuery.starred != StarredFilter.Any || listQuery.downloaded || search.isNotEmpty()
|
val filtersActive = listQuery.starred != StarredFilter.Any || listQuery.downloaded || search.isNotEmpty()
|
||||||
var tabBarHeightPx by remember { mutableFloatStateOf(with(density) { (TAB_BAR_CONTENT_HEIGHT + bottomInset).toPx() }) }
|
var tabBarHeightPx by remember { mutableFloatStateOf(with(density) { (TAB_BAR_CONTENT_HEIGHT + bottomInset).toPx() }) }
|
||||||
@@ -470,15 +466,16 @@ fun LibraryScreen(
|
|||||||
) { page ->
|
) { page ->
|
||||||
val pageTab = LibraryTab.entries[page]
|
val pageTab = LibraryTab.entries[page]
|
||||||
val resetKey = resetKeys[pageTab]
|
val resetKey = resetKeys[pageTab]
|
||||||
|
val scrollToTopKey = if (pageTab == selectedTab) topRequest else null
|
||||||
val pullToRefreshState = rememberPullToRefreshState()
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
PullToRefreshBox(
|
PullToRefreshBox(
|
||||||
isRefreshing = syncing,
|
isRefreshing = refreshing,
|
||||||
onRefresh = onSync,
|
onRefresh = onSync,
|
||||||
state = pullToRefreshState,
|
state = pullToRefreshState,
|
||||||
indicator = {
|
indicator = {
|
||||||
PullToRefreshDefaults.Indicator(
|
PullToRefreshDefaults.Indicator(
|
||||||
state = pullToRefreshState,
|
state = pullToRefreshState,
|
||||||
isRefreshing = syncing,
|
isRefreshing = refreshing,
|
||||||
modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding(),
|
modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding(),
|
||||||
containerColor = headerColor,
|
containerColor = headerColor,
|
||||||
color = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.primary,
|
color = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.primary,
|
||||||
@@ -497,7 +494,7 @@ fun LibraryScreen(
|
|||||||
coverArt,
|
coverArt,
|
||||||
listBottomInset,
|
listBottomInset,
|
||||||
{
|
{
|
||||||
dismissSearch()
|
focusManager.clearFocus()
|
||||||
onAlbumClick(it)
|
onAlbumClick(it)
|
||||||
},
|
},
|
||||||
onLongClick = onItemLongClick,
|
onLongClick = onItemLongClick,
|
||||||
@@ -507,6 +504,7 @@ fun LibraryScreen(
|
|||||||
topInset = listTopInset,
|
topInset = listTopInset,
|
||||||
onSync = onSync,
|
onSync = onSync,
|
||||||
downloadStatuses = albumDownloads,
|
downloadStatuses = albumDownloads,
|
||||||
|
scrollToTopKey = scrollToTopKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,7 +514,7 @@ fun LibraryScreen(
|
|||||||
coverArt,
|
coverArt,
|
||||||
listBottomInset,
|
listBottomInset,
|
||||||
{
|
{
|
||||||
dismissSearch()
|
focusManager.clearFocus()
|
||||||
onArtistClick(it)
|
onArtistClick(it)
|
||||||
},
|
},
|
||||||
onLongClick = onItemLongClick,
|
onLongClick = onItemLongClick,
|
||||||
@@ -526,6 +524,8 @@ fun LibraryScreen(
|
|||||||
topInset = listTopInset,
|
topInset = listTopInset,
|
||||||
onSync = onSync,
|
onSync = onSync,
|
||||||
downloadStatuses = artistDownloads,
|
downloadStatuses = artistDownloads,
|
||||||
|
scrollToTopKey = scrollToTopKey,
|
||||||
|
artistArt = artistArt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,7 +535,7 @@ fun LibraryScreen(
|
|||||||
coverArt,
|
coverArt,
|
||||||
listBottomInset,
|
listBottomInset,
|
||||||
{
|
{
|
||||||
dismissSearch()
|
focusManager.clearFocus()
|
||||||
onPlaylistClick(it)
|
onPlaylistClick(it)
|
||||||
},
|
},
|
||||||
onLongClick = onItemLongClick,
|
onLongClick = onItemLongClick,
|
||||||
@@ -545,6 +545,7 @@ fun LibraryScreen(
|
|||||||
topInset = listTopInset,
|
topInset = listTopInset,
|
||||||
onSync = onSync,
|
onSync = onSync,
|
||||||
downloadStatuses = playlistDownloads,
|
downloadStatuses = playlistDownloads,
|
||||||
|
scrollToTopKey = scrollToTopKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -570,11 +571,12 @@ fun LibraryScreen(
|
|||||||
) {
|
) {
|
||||||
LibraryTabs(
|
LibraryTabs(
|
||||||
pagerState = pagerState,
|
pagerState = pagerState,
|
||||||
onTabSelected = onTabSelected,
|
onTabSelected = { tab ->
|
||||||
|
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
|
||||||
|
},
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
offline = offline,
|
|
||||||
onExitOffline = onExitOffline,
|
|
||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
|
syncing = syncing,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,10 +585,7 @@ fun LibraryScreen(
|
|||||||
SearchField(
|
SearchField(
|
||||||
value = search,
|
value = search,
|
||||||
onValueChange = onSearchChange,
|
onValueChange = onSearchChange,
|
||||||
onClose = {
|
onClose = closeSearch,
|
||||||
searchActive = false
|
|
||||||
onSearchChange("")
|
|
||||||
},
|
|
||||||
focusRequester = searchFocus,
|
focusRequester = searchFocus,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -597,11 +596,29 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!searchActive) {
|
if (!searchActive) {
|
||||||
|
val fabContainerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
val fabContentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground
|
||||||
|
if (offline) {
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onExitOffline,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.padding(start = 16.dp, bottom = tabBarHeight + 16.dp)
|
||||||
|
.size(48.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.CloudOff,
|
||||||
|
contentDescription = stringResource(R.string.offline_mode_status),
|
||||||
|
modifier = Modifier.size(24.dp).offset(y = (-0.75).dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
val searchFab = selectedTab == LibraryTab.Home
|
val searchFab = selectedTab == LibraryTab.Home
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = { if (searchFab) onOpenSearch() else showOptions = true },
|
onClick = { if (searchFab) onOpenSearch() else showOptions = true },
|
||||||
containerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh,
|
containerColor = fabContainerColor,
|
||||||
contentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground,
|
contentColor = fabContentColor,
|
||||||
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp),
|
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp),
|
||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp),
|
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp),
|
||||||
) {
|
) {
|
||||||
@@ -620,11 +637,18 @@ fun LibraryScreen(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.offset(x = 4.dp, y = (-4).dp)
|
.offset(x = 1.dp, y = 2.5.dp)
|
||||||
.size(8.dp)
|
.size(11.dp)
|
||||||
.clip(CircleShape)
|
.background(fabContainerColor, CircleShape),
|
||||||
.background(artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.primary),
|
contentAlignment = Alignment.Center,
|
||||||
)
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(7.dp)
|
||||||
|
.background(fabContentColor, CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -684,7 +708,7 @@ private fun ListOptionsSheet(
|
|||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
Text(stringResource(R.string.search_this_list), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(stringResource(R.string.search_this_list), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
@@ -806,6 +830,7 @@ private fun ListOptionsSheet(
|
|||||||
|
|
||||||
private val BOTTOM_CLEARANCE = 80.dp
|
private val BOTTOM_CLEARANCE = 80.dp
|
||||||
private val TAB_BAR_CONTENT_HEIGHT = 52.dp
|
private val TAB_BAR_CONTENT_HEIGHT = 52.dp
|
||||||
|
private val TAB_STRIP_LEADING_GAP = 8.dp
|
||||||
private val TAB_ICON_SIZE = 24.dp
|
private val TAB_ICON_SIZE = 24.dp
|
||||||
private val TAB_VERTICAL_PADDING = 6.dp
|
private val TAB_VERTICAL_PADDING = 6.dp
|
||||||
private val TOUCH_TARGET = 48.dp
|
private val TOUCH_TARGET = 48.dp
|
||||||
@@ -831,18 +856,19 @@ private fun ListOptionsGlyph(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("FrequentlyChangingValue")
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun LibraryTabs(
|
internal fun LibraryTabs(
|
||||||
pagerState: PagerState,
|
pagerState: PagerState,
|
||||||
onTabSelected: (LibraryTab) -> Unit,
|
onTabSelected: (LibraryTab) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
offline: Boolean = false,
|
|
||||||
onExitOffline: () -> Unit = {},
|
|
||||||
artwork: ArtworkColors?,
|
artwork: ArtworkColors?,
|
||||||
|
syncing: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val indicatorStretch = 18.dp
|
val indicatorStretch = 18.dp
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
val bounds = remember { mutableStateMapOf<Int, Rect>() }
|
val bounds = remember { mutableStateMapOf<Int, Rect>() }
|
||||||
val page = pagerState.currentPage
|
val page = pagerState.currentPage
|
||||||
val fraction = pagerState.currentPageOffsetFraction
|
val fraction = pagerState.currentPageOffsetFraction
|
||||||
@@ -874,68 +900,111 @@ internal fun LibraryTabs(
|
|||||||
val indicatorColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.onBackground
|
val indicatorColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.onBackground
|
||||||
val tabContentHeightPx = with(density) { (TAB_ICON_SIZE + TAB_VERTICAL_PADDING * 2).toPx() }
|
val tabContentHeightPx = with(density) { (TAB_ICON_SIZE + TAB_VERTICAL_PADDING * 2).toPx() }
|
||||||
|
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
var viewportWidth by remember { mutableIntStateOf(0) }
|
||||||
|
var contentWidth by remember { mutableIntStateOf(0) }
|
||||||
|
var revealed by remember { mutableStateOf(false) }
|
||||||
|
val leadingGap = with(density) { TAB_STRIP_LEADING_GAP.toPx() }
|
||||||
|
LaunchedEffect(pagerState.settledPage, viewportWidth, contentWidth, layoutDirection, bounds[pagerState.settledPage]) {
|
||||||
|
if (viewportWidth == 0) return@LaunchedEffect
|
||||||
|
val settled = bounds[pagerState.settledPage] ?: return@LaunchedEffect
|
||||||
|
val start = if (layoutDirection == LayoutDirection.Rtl) contentWidth - settled.right else leadingGap + settled.left
|
||||||
|
val end = if (layoutDirection == LayoutDirection.Rtl) contentWidth - settled.left else leadingGap + settled.right
|
||||||
|
val target =
|
||||||
|
when {
|
||||||
|
start - leadingGap < scrollState.value -> start - leadingGap
|
||||||
|
end > scrollState.value + viewportWidth -> end - viewportWidth
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (target != null) {
|
||||||
|
val offset = target.roundToInt().coerceIn(0, scrollState.maxValue)
|
||||||
|
if (revealed) scrollState.animateScrollTo(offset) else scrollState.scrollTo(offset)
|
||||||
|
}
|
||||||
|
revealed = true
|
||||||
|
}
|
||||||
|
|
||||||
Box(Modifier.fillMaxWidth()) {
|
Box(Modifier.fillMaxWidth()) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(headerColor)
|
.background(headerColor)
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
.padding(top = 2.dp, end = 8.dp, bottom = 2.dp),
|
||||||
) {
|
) {
|
||||||
Box(
|
Row(
|
||||||
modifier =
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Modifier.drawBehind {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
if (current != null && indicatorLeft != null && indicatorRight != null && indicatorRight > indicatorLeft) {
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
val height = tabContentHeightPx.coerceAtMost(current.height)
|
|
||||||
drawRoundRect(
|
|
||||||
color = indicatorColor,
|
|
||||||
topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f),
|
|
||||||
size = Size(indicatorRight - indicatorLeft, height),
|
|
||||||
cornerRadius = CornerRadius(8.dp.toPx()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.onSizeChanged { viewportWidth = it.width }
|
||||||
|
.horizontalScroll(scrollState)
|
||||||
|
.onSizeChanged { contentWidth = it.width },
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
) {
|
||||||
LibraryTab.entries.forEachIndexed { index, tab ->
|
Spacer(Modifier.width(TAB_STRIP_LEADING_GAP))
|
||||||
TabButton(
|
Box(
|
||||||
tab = tab,
|
modifier =
|
||||||
artwork = artwork,
|
Modifier
|
||||||
progress =
|
.drawBehind {
|
||||||
when (index) {
|
if (current != null &&
|
||||||
page -> 1f - transition
|
indicatorLeft != null &&
|
||||||
page + (if (fraction >= 0f) 1 else -1) -> transition
|
indicatorRight != null &&
|
||||||
else -> 0f
|
indicatorRight > indicatorLeft
|
||||||
|
) {
|
||||||
|
val height = tabContentHeightPx.coerceAtMost(current.height)
|
||||||
|
drawRoundRect(
|
||||||
|
color = indicatorColor,
|
||||||
|
topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f),
|
||||||
|
size = Size(indicatorRight - indicatorLeft, height),
|
||||||
|
cornerRadius = CornerRadius(8.dp.toPx()),
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
indicatorLeft = indicatorLeft,
|
) {
|
||||||
indicatorRight = indicatorRight,
|
Row(
|
||||||
onClick = { onTabSelected(tab) },
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier =
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
Modifier.onGloballyPositioned { coordinates ->
|
) {
|
||||||
bounds[index] = coordinates.boundsInParent()
|
LibraryTab.entries.forEachIndexed { index, tab ->
|
||||||
},
|
TabButton(
|
||||||
)
|
tab = tab,
|
||||||
}
|
artwork = artwork,
|
||||||
Spacer(Modifier.weight(1f))
|
progress =
|
||||||
if (offline) {
|
when (index) {
|
||||||
IconButton(onClick = onExitOffline) {
|
page -> 1f - transition
|
||||||
Icon(
|
page + (if (fraction >= 0f) 1 else -1) -> transition
|
||||||
imageVector = Icons.Rounded.CloudOff,
|
else -> 0f
|
||||||
contentDescription = stringResource(R.string.offline_mode_status),
|
},
|
||||||
tint = MaterialTheme.colorScheme.onBackground,
|
indicatorLeft = indicatorLeft,
|
||||||
)
|
indicatorRight = indicatorRight,
|
||||||
|
onClick = { onTabSelected(tab) },
|
||||||
|
modifier =
|
||||||
|
Modifier.onGloballyPositioned { coordinates ->
|
||||||
|
bounds[index] = coordinates.boundsInParent()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = onOpenSettings) {
|
}
|
||||||
|
IconButton(onClick = onOpenSettings) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Settings,
|
imageVector = Icons.Rounded.Settings,
|
||||||
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
||||||
tint = MaterialTheme.colorScheme.onBackground,
|
tint = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
|
if (syncing) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import com.subtracks.data.repo.SourceRepository
|
|||||||
import com.subtracks.data.sync.SyncManager
|
import com.subtracks.data.sync.SyncManager
|
||||||
import com.subtracks.data.sync.SyncStatus
|
import com.subtracks.data.sync.SyncStatus
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.artistArt
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -117,7 +119,12 @@ class LibraryViewModel(
|
|||||||
|
|
||||||
val syncing: StateFlow<Boolean> =
|
val syncing: StateFlow<Boolean> =
|
||||||
syncManager.status
|
syncManager.status
|
||||||
.map { it == SyncStatus.Running }
|
.map { it is SyncStatus.Running }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
|
val refreshing: StateFlow<Boolean> =
|
||||||
|
syncManager.status
|
||||||
|
.map { it is SyncStatus.Running && !it.silent }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
private val activeSourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val activeSourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
@@ -206,7 +213,14 @@ class LibraryViewModel(
|
|||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
fun sync() = syncManager.requestSync()
|
fun artistArt(
|
||||||
|
artist: Artist,
|
||||||
|
forIcon: Boolean = true,
|
||||||
|
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||||
|
|
||||||
|
fun sync() {
|
||||||
|
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name
|
internal fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -18,6 +17,7 @@ import androidx.paging.compose.itemKey
|
|||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.data.model.BulkDownloadAction
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.model.DownloadList
|
||||||
import com.subtracks.data.model.ListDownloadStatus
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
@@ -27,16 +27,16 @@ import com.subtracks.data.source.StarType
|
|||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
|
||||||
import com.subtracks.ui.components.DismissOnRequest
|
import com.subtracks.ui.components.DismissOnRequest
|
||||||
import com.subtracks.ui.components.HeroDetailScaffold
|
import com.subtracks.ui.components.HeroDetailScaffold
|
||||||
import com.subtracks.ui.components.HeroHeader
|
import com.subtracks.ui.components.HeroHeader
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
|
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
|
||||||
|
import com.subtracks.ui.components.rememberPendingDownloadDelete
|
||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
import com.subtracks.ui.theme.rememberArtworkColors
|
import com.subtracks.ui.theme.rememberArtworkColors
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
@@ -55,21 +55,17 @@ fun PlaylistDetailRoute(
|
|||||||
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
|
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
|
||||||
playbackController: PlaybackController = koinInject(),
|
playbackController: PlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
|
val playlist by viewModel.playlist.collectAsStateWithLifecycle()
|
||||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||||
val scope = rememberCoroutineScope()
|
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow()) { _, _ -> viewModel.downloadedBytes() }
|
||||||
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
|
||||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
val context = playback.context
|
val context = playback.context
|
||||||
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
|
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
|
||||||
if (action == BulkDownloadAction.Delete) {
|
pendingDelete.onAction(playlist?.name.orEmpty(), DownloadList.Playlist, playlistId, action) { _, _, requested ->
|
||||||
scope.launch { pendingDelete = viewModel.downloadedBytes() }
|
viewModel.onDownloadAction(requested)
|
||||||
} else {
|
|
||||||
viewModel.onDownloadAction(action)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val actions =
|
val actions =
|
||||||
@@ -104,13 +100,8 @@ fun PlaylistDetailRoute(
|
|||||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId },
|
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId },
|
||||||
)
|
)
|
||||||
|
|
||||||
pendingDelete?.let { bytes ->
|
PendingDownloadDeleteConfirmation(pendingDelete) {
|
||||||
DeleteDownloadsDialog(
|
viewModel.onDownloadAction(BulkDownloadAction.Delete)
|
||||||
name = playlist?.name.orEmpty(),
|
|
||||||
bytes = bytes,
|
|
||||||
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
|
|
||||||
onDismiss = { pendingDelete = null },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +111,7 @@ fun PlaylistDetailScreen(
|
|||||||
songs: LazyPagingItems<PlaylistSongItem>,
|
songs: LazyPagingItems<PlaylistSongItem>,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
artwork: ArtworkColors?,
|
artwork: ArtworkColors?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
downloads: Map<String, SongDownload> = emptyMap(),
|
downloads: Map<String, SongDownload> = emptyMap(),
|
||||||
downloadStatus: ListDownloadStatus = ListDownloadStatus(),
|
downloadStatus: ListDownloadStatus = ListDownloadStatus(),
|
||||||
offline: Boolean = false,
|
offline: Boolean = false,
|
||||||
@@ -131,13 +123,13 @@ fun PlaylistDetailScreen(
|
|||||||
onPlay: () -> Unit = {},
|
onPlay: () -> Unit = {},
|
||||||
onMore: () -> Unit = {},
|
onMore: () -> Unit = {},
|
||||||
playingSongId: String? = null,
|
playingSongId: String? = null,
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
HeroDetailScaffold(
|
HeroDetailScaffold(
|
||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
title = playlist?.name.orEmpty(),
|
title = playlist?.name.orEmpty(),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
contentReady = songs.itemCount > 0,
|
||||||
header = { controlsModifier, topInset ->
|
header = { controlsModifier, topInset ->
|
||||||
HeroHeader(
|
HeroHeader(
|
||||||
art = coverArt(playlist?.coverArt, false),
|
art = coverArt(playlist?.coverArt, false),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user